From 0a70ab6179cb2b5b4b2d9dc256a7a3b69a0e5dd6 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 03:07:33 -0400 Subject: [PATCH] feat(profiles): Add MCP configuration to Claude Code rules (#980) * add .mcp.json with claude profile * add changeset * update changeset * update test --- .changeset/swift-turtles-sit.md | 5 ++ src/profiles/base-profile.js | 4 +- src/profiles/claude.js | 67 ++++++++++++++++++- .../claude-init-functionality.test.js | 7 +- .../unit/profiles/claude-integration.test.js | 16 ++++- .../profiles/mcp-config-validation.test.js | 65 ++++++++++-------- tests/unit/profiles/rule-transformer.test.js | 51 ++++++-------- 7 files changed, 149 insertions(+), 66 deletions(-) create mode 100644 .changeset/swift-turtles-sit.md diff --git a/.changeset/swift-turtles-sit.md b/.changeset/swift-turtles-sit.md new file mode 100644 index 00000000..b5f57475 --- /dev/null +++ b/.changeset/swift-turtles-sit.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Add MCP configuration support to Claude Code rules diff --git a/src/profiles/base-profile.js b/src/profiles/base-profile.js index 6f6add59..6f9c5e56 100644 --- a/src/profiles/base-profile.js +++ b/src/profiles/base-profile.js @@ -46,7 +46,9 @@ export function createProfile(editorConfig) { onPostConvert } = editorConfig; - const mcpConfigPath = mcpConfigName ? `${profileDir}/${mcpConfigName}` : null; + const mcpConfigPath = mcpConfigName + ? path.join(profileDir, mcpConfigName) + : null; // Standard file mapping with custom overrides // Use taskmaster subdirectory only if profile supports it diff --git a/src/profiles/claude.js b/src/profiles/claude.js index 2fc347f0..9790a2a8 100644 --- a/src/profiles/claude.js +++ b/src/profiles/claude.js @@ -197,9 +197,73 @@ function onRemoveRulesProfile(targetDir) { } } +/** + * Transform standard MCP config format to Claude format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Claude configuration object + */ +function transformToClaudeFormat(mcpConfig) { + const claudeConfig = {}; + + // Transform mcpServers to servers (keeping the same structure but adding type) + if (mcpConfig.mcpServers) { + claudeConfig.mcpServers = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration with type as first key + const reorderedServer = {}; + + // Add type: "stdio" as the first key + reorderedServer.type = 'stdio'; + + // Then add the rest of the properties in order + if (serverConfig.command) reorderedServer.command = serverConfig.command; + if (serverConfig.args) reorderedServer.args = serverConfig.args; + if (serverConfig.env) reorderedServer.env = serverConfig.env; + + // Add any other properties that might exist + Object.keys(serverConfig).forEach((key) => { + if (!['command', 'args', 'env', 'type'].includes(key)) { + reorderedServer[key] = serverConfig[key]; + } + }); + + claudeConfig.mcpServers[serverName] = reorderedServer; + } + } + + return claudeConfig; +} + function onPostConvertRulesProfile(targetDir, assetsDir) { // For Claude, post-convert is the same as add since we don't transform rules onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP configuration to Claude format + const mcpConfigPath = path.join(targetDir, '.mcp.json'); + if (fs.existsSync(mcpConfigPath)) { + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); + const claudeConfig = transformToClaudeFormat(mcpConfig); + + // Write back the transformed configuration + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(claudeConfig, null, '\t') + '\n' + ); + log( + 'debug', + `[Claude] Transformed MCP configuration to Claude format at ${mcpConfigPath}` + ); + } catch (err) { + log( + 'error', + `[Claude] Failed to transform MCP configuration: ${err.message}` + ); + } + } } // Create and export claude profile using the base factory @@ -210,8 +274,7 @@ export const claudeProfile = createProfile({ docsUrl: 'docs.anthropic.com/en/docs/claude-code', profileDir: '.', // Root directory rulesDir: '.', // No specific rules directory needed - mcpConfig: false, - mcpConfigName: null, + mcpConfigName: '.mcp.json', // Place MCP config in project root includeDefaultRules: false, fileMap: { 'AGENTS.md': '.taskmaster/CLAUDE.md' diff --git a/tests/integration/profiles/claude-init-functionality.test.js b/tests/integration/profiles/claude-init-functionality.test.js index ed623630..7ae49dc3 100644 --- a/tests/integration/profiles/claude-init-functionality.test.js +++ b/tests/integration/profiles/claude-init-functionality.test.js @@ -21,7 +21,7 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfileContent).toContain("displayName: 'Claude Code'"); expect(claudeProfileContent).toContain("profileDir: '.'"); // non-default expect(claudeProfileContent).toContain("rulesDir: '.'"); // non-default - expect(claudeProfileContent).toContain('mcpConfig: false'); // non-default + expect(claudeProfileContent).toContain("mcpConfigName: '.mcp.json'"); // non-default expect(claudeProfileContent).toContain('includeDefaultRules: false'); // non-default expect(claudeProfileContent).toContain( "'AGENTS.md': '.taskmaster/CLAUDE.md'" @@ -32,8 +32,9 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfile.displayName).toBe('Claude Code'); expect(claudeProfile.profileDir).toBe('.'); expect(claudeProfile.rulesDir).toBe('.'); - expect(claudeProfile.mcpConfig).toBe(false); - expect(claudeProfile.mcpConfigName).toBe(null); // computed + expect(claudeProfile.mcpConfig).toBe(true); // default from base profile + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); // explicitly set + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); // computed expect(claudeProfile.includeDefaultRules).toBe(false); expect(claudeProfile.fileMap['AGENTS.md']).toBe('.taskmaster/CLAUDE.md'); }); diff --git a/tests/unit/profiles/claude-integration.test.js b/tests/unit/profiles/claude-integration.test.js index 4fe723a8..900468e3 100644 --- a/tests/unit/profiles/claude-integration.test.js +++ b/tests/unit/profiles/claude-integration.test.js @@ -2,6 +2,7 @@ import { jest } from '@jest/globals'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { claudeProfile } from '../../../src/profiles/claude.js'; // Mock external modules jest.mock('child_process', () => ({ @@ -77,11 +78,22 @@ describe('Claude Profile Integration', () => { expect(mkdirCalls).toHaveLength(0); }); - test('does not create MCP configuration files', () => { + test('supports MCP configuration when using rule transformer', () => { + // This test verifies that the Claude profile is configured to support MCP + // The actual MCP file creation is handled by the rule transformer + + // Assert - Claude profile should now support MCP configuration + expect(claudeProfile.mcpConfig).toBe(true); + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); + }); + + test('mock function does not create MCP configuration files', () => { // Act mockCreateClaudeStructure(); - // Assert - Claude profile should not create any MCP config files + // Assert - The mock function should not create MCP config files + // (This is expected since the mock doesn't use the rule transformer) const writeFileCalls = fs.writeFileSync.mock.calls; const mcpConfigCalls = writeFileCalls.filter( (call) => diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 9397ae9f..91f4c0cb 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -92,7 +92,12 @@ describe('MCP Configuration Validation', () => { RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); + // Claude profile uses root directory (.), so its path is just '.mcp.json' + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); + } } }); }); @@ -123,17 +128,13 @@ describe('MCP Configuration Validation', () => { }); test('should have null config name for non-MCP profiles', () => { - const clineProfile = getRulesProfile('cline'); - expect(clineProfile.mcpConfigName).toBe(null); + // Only codex, cline, and trae profiles should have null config names + const nonMcpProfiles = ['codex', 'cline', 'trae']; - const traeProfile = getRulesProfile('trae'); - expect(traeProfile.mcpConfigName).toBe(null); - - const claudeProfile = getRulesProfile('claude'); - expect(claudeProfile.mcpConfigName).toBe(null); - - const codexProfile = getRulesProfile('codex'); - expect(codexProfile.mcpConfigName).toBe(null); + for (const profileName of nonMcpProfiles) { + const profile = getRulesProfile(profileName); + expect(profile.mcpConfigName).toBe(null); + } }); }); @@ -185,17 +186,19 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Creation Logic', () => { test('should indicate which profiles require MCP configuration creation', () => { + // Get all profiles that have MCP configuration enabled const mcpEnabledProfiles = RULE_PROFILES.filter((profileName) => { const profile = getRulesProfile(profileName); return profile.mcpConfig !== false; }); + // Verify expected MCP-enabled profiles + expect(mcpEnabledProfiles).toContain('claude'); expect(mcpEnabledProfiles).toContain('cursor'); expect(mcpEnabledProfiles).toContain('gemini'); expect(mcpEnabledProfiles).toContain('roo'); expect(mcpEnabledProfiles).toContain('vscode'); expect(mcpEnabledProfiles).toContain('windsurf'); - expect(mcpEnabledProfiles).not.toContain('claude'); expect(mcpEnabledProfiles).not.toContain('cline'); expect(mcpEnabledProfiles).not.toContain('codex'); expect(mcpEnabledProfiles).not.toContain('trae'); @@ -215,18 +218,25 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Path Usage Verification', () => { test('should verify that rule transformer functions use mcpConfigPath correctly', () => { - // This test verifies that the mcpConfigPath property exists and is properly formatted - // for use with the setupMCPConfiguration function RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { // Verify the path is properly formatted for path.join usage expect(profile.mcpConfigPath.startsWith('/')).toBe(false); - expect(profile.mcpConfigPath).toContain('/'); + + // Claude profile uses root directory (.), so its path is just '.mcp.json' + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profile.mcpConfigPath).toContain('/'); + } // Verify it matches the expected pattern: profileDir/configName const expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; - expect(profile.mcpConfigPath).toBe(expectedPath); + // For Claude, path.join('.', '.mcp.json') returns '.mcp.json' + const normalizedExpected = + profileName === 'claude' ? '.mcp.json' : expectedPath; + expect(profile.mcpConfigPath).toBe(normalizedExpected); } }); }); @@ -250,20 +260,19 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Function Integration', () => { test('should verify that setupMCPConfiguration receives the correct mcpConfigPath parameter', () => { - // This test verifies the integration between rule transformer and mcp-utils RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - // Verify that the mcpConfigPath can be used directly with setupMCPConfiguration - // The function signature is: setupMCPConfiguration(projectDir, mcpConfigPath) - expect(profile.mcpConfigPath).toBeDefined(); - expect(typeof profile.mcpConfigPath).toBe('string'); - // Verify the path structure is correct for the new function signature - const parts = profile.mcpConfigPath.split('/'); - expect(parts).toHaveLength(2); // Should be profileDir/configName - expect(parts[0]).toBe(profile.profileDir); - expect(parts[1]).toBe(profile.mcpConfigName); + if (profileName === 'claude') { + // Claude profile uses root directory, so path is just '.mcp.json' + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + const parts = profile.mcpConfigPath.split('/'); + expect(parts).toHaveLength(2); // Should be profileDir/configName + expect(parts[0]).toBe(profile.profileDir); + expect(parts[1]).toBe(profile.mcpConfigName); + } } }); }); @@ -271,7 +280,9 @@ describe('MCP Configuration Validation', () => { describe('MCP configuration validation', () => { const mcpProfiles = ['cursor', 'gemini', 'roo', 'windsurf', 'vscode']; - const nonMcpProfiles = ['claude', 'codex', 'cline', 'trae']; + const nonMcpProfiles = ['codex', 'cline', 'trae']; + const profilesWithLifecycle = ['claude']; + const profilesWithoutLifecycle = ['codex']; test.each(mcpProfiles)( 'should have valid MCP config for %s profile', diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 7950d738..6ab1083a 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -3,6 +3,7 @@ import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; import { RULE_PROFILES } from '../../../src/constants/profiles.js'; +import path from 'path'; describe('Rule Transformer - General', () => { describe('Profile Configuration Validation', () => { @@ -166,19 +167,13 @@ describe('Rule Transformer - General', () => { // Check types based on MCP configuration expect(typeof profileConfig.mcpConfig).toBe('boolean'); - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration - expect(profileConfig.mcpConfigName).toBe(null); - expect(profileConfig.mcpConfigPath).toBe(null); - } else { - // Profiles with MCP configuration - expect(typeof profileConfig.mcpConfigName).toBe('string'); - expect(typeof profileConfig.mcpConfigPath).toBe('string'); - + if (profileConfig.mcpConfig !== false) { // Check that mcpConfigPath is properly constructed - expect(profileConfig.mcpConfigPath).toBe( - `${profileConfig.profileDir}/${profileConfig.mcpConfigName}` + const expectedPath = path.join( + profileConfig.profileDir, + profileConfig.mcpConfigName ); + expect(profileConfig.mcpConfigPath).toBe(expectedPath); } }); }); @@ -186,9 +181,9 @@ describe('Rule Transformer - General', () => { it('should have correct MCP configuration for each profile', () => { const expectedConfigs = { claude: { - mcpConfig: false, - mcpConfigName: null, - expectedPath: null + mcpConfig: true, + mcpConfigName: '.mcp.json', + expectedPath: '.mcp.json' }, cline: { mcpConfig: false, @@ -245,25 +240,19 @@ describe('Rule Transformer - General', () => { it('should have consistent profileDir and mcpConfigPath relationship', () => { RULE_PROFILES.forEach((profile) => { const profileConfig = getRulesProfile(profile); - - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration have null mcpConfigPath - expect(profileConfig.mcpConfigPath).toBe(null); - } else { + if (profileConfig.mcpConfig !== false) { // Profiles with MCP configuration should have valid paths // The mcpConfigPath should start with the profileDir - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` - ) - ); - - // The mcpConfigPath should end with the mcpConfigName - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `${profileConfig.mcpConfigName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$` - ) - ); + if (profile === 'claude') { + // Claude uses root directory (.), so path.join('.', '.mcp.json') = '.mcp.json' + expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + } else { + expect(profileConfig.mcpConfigPath).toMatch( + new RegExp( + `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` + ) + ); + } } }); });