diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 3342f1376..a45b05109 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -21,6 +21,7 @@ codemie update [agent] # Update installed agents codemie self-update # Update CodeMie CLI itself codemie doctor [options] # Health check and diagnostics codemie plugin # Manage native plugins +codemie mcp # Manage MCP servers registered with Claude Code codemie mcp-proxy # Stdio-to-HTTP MCP proxy with OAuth support codemie codebase # Manage Codebase Memory graph UI codemie docs # Manage documentation & knowledge tools @@ -926,6 +927,33 @@ codemie plugin disable For full documentation, see [Plugin System](./PLUGINS.md). +## MCP Command + +Manage MCP servers registered with Claude Code. Both subcommands are thin wrappers around `claude mcp add`/`claude mcp remove`. + +```bash +codemie mcp add [--scope ] # Register an MCP server via codemie-mcp-proxy +codemie mcp remove [--scope ] # Remove a registered MCP server +codemie mcp list # List registered MCP servers +``` + +**`codemie mcp add`** +- `` — Name for the MCP server +- `` — MCP server URL (must be a valid HTTP/HTTPS URL) +- `--scope ` — Scope for the MCP server (e.g. `project`, `user`) + +Registers the server by running `claude mcp add -- codemie-mcp-proxy `, so the server is launched through the [MCP proxy](#mcp-proxy-command) for OAuth support. + +**`codemie mcp remove`** +- `` — Name of the MCP server to remove +- `--scope ` — Scope for the MCP server (e.g. `project`, `user`) + +Removes the server by running `claude mcp remove `. + +**`codemie mcp list`** + +Lists registered servers by running `claude mcp list`. + ## MCP Proxy Command Run a stdio-to-HTTP bridge that connects MCP clients (like Claude Code) to remote MCP servers, handling OAuth 2.0 authorization automatically. diff --git a/src/cli/commands/mcp/__tests__/index.test.ts b/src/cli/commands/mcp/__tests__/index.test.ts new file mode 100644 index 000000000..85b397f23 --- /dev/null +++ b/src/cli/commands/mcp/__tests__/index.test.ts @@ -0,0 +1,290 @@ +/** + * MCP command tests + * @group unit + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'os'; + +vi.mock('../../../../utils/exec.js', () => ({ + exec: vi.fn(), +})); + +vi.mock('../../../../utils/processes.js', () => ({ + getCommandPath: vi.fn(), +})); + +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn().mockReturnValue('/home/test/.local/bin/claude'), +})); + +// The fast-path claude lookup is only attempted on non-Windows; the fallback shell flag +// mirrors os.platform() === 'win32' either way, so compute the expectation from the real os module. +const expectedShell = os.platform() === 'win32'; + +describe('mcp command', () => { + let consoleErrorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new Error(`process.exit:${code}`); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + /** Makes the ~/.local/bin/claude fast-path check fail so resolution falls back to PATH lookup. */ + async function mockClaudeResolved(mcpResultCode = 0) { + const { exec } = await import('../../../../utils/exec.js'); + const { getCommandPath } = await import('../../../../utils/processes.js'); + + vi.mocked(getCommandPath).mockImplementation(async (command: string) => { + if (command === 'claude') return '/usr/local/bin/claude'; + if (command === 'codemie-mcp-proxy') return '/usr/local/bin/codemie-mcp-proxy'; + return null; + }); + + vi.mocked(exec).mockImplementation(async (_command: string, args: string[] = []) => { + if (args.includes('--version')) { + throw new Error('ENOENT: claude not found at fast path'); + } + return { code: mcpResultCode, stdout: '', stderr: '' }; + }); + + return { exec, getCommandPath }; + } + + describe('add', () => { + it('registers the MCP server via codemie-mcp-proxy and exits with the claude exit code', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', 'my-server', 'https://example.com/mcp'], { from: 'user' }) + ).rejects.toThrow(/^process\.exit:/); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exec).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['mcp', 'add', 'my-server', '--', 'codemie-mcp-proxy', 'https://example.com/mcp'], + { interactive: true, shell: expectedShell } + ); + }); + + it('inserts --scope before the server name when provided', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', 'my-server', 'https://example.com/mcp', '--scope', 'project'], { + from: 'user', + }) + ).rejects.toThrow(/^process\.exit:/); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exec).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['mcp', 'add', '--scope', 'project', 'my-server', '--', 'codemie-mcp-proxy', 'https://example.com/mcp'], + { interactive: true, shell: expectedShell } + ); + }); + + it('rejects an invalid MCP server URL before touching claude', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', 'my-server', 'not-a-url'], { from: 'user' }) + ).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid MCP server URL: not-a-url'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('rejects a server name that looks like a flag', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', '--', '-badname', 'https://example.com/mcp'], { from: 'user' }) + ).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid server name: -badname'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('errors out when codemie-mcp-proxy is not installed', async () => { + const { exec, getCommandPath } = await mockClaudeResolved(0); + vi.mocked(getCommandPath).mockImplementation(async (command: string) => { + if (command === 'claude') return '/usr/local/bin/claude'; + return null; + }); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', 'my-server', 'https://example.com/mcp'], { from: 'user' }) + ).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'codemie-mcp-proxy not found. Reinstall @codemieai/code to restore the MCP proxy binary.' + ); + expect(exec).not.toHaveBeenCalled(); + }); + + it('errors out when the claude CLI cannot be found', async () => { + const { getCommandPath } = await mockClaudeResolved(0); + vi.mocked(getCommandPath).mockImplementation(async (command: string) => { + if (command === 'codemie-mcp-proxy') return '/usr/local/bin/codemie-mcp-proxy'; + return null; + }); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['add', 'my-server', 'https://example.com/mcp'], { from: 'user' }) + ).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('claude CLI not found. Install Claude Code: https://claude.ai/code'); + }); + }); + + describe('remove', () => { + it('removes a registered MCP server by name', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['remove', 'my-server'], { from: 'user' })).rejects.toThrow( + /^process\.exit:/ + ); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exec).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['mcp', 'remove', 'my-server'], + { interactive: true, shell: expectedShell } + ); + }); + + it('forwards --scope to the underlying claude mcp remove call', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['remove', 'my-server', '--scope', 'project'], { from: 'user' }) + ).rejects.toThrow(/^process\.exit:/); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exec).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['mcp', 'remove', '--scope', 'project', 'my-server'], + { interactive: true, shell: expectedShell } + ); + }); + + it('rejects a server name that looks like a flag', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect( + command.parseAsync(['remove', '--', '-badname'], { from: 'user' }) + ).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid server name: -badname'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('errors out when the claude CLI cannot be found', async () => { + const { getCommandPath } = await mockClaudeResolved(0); + vi.mocked(getCommandPath).mockResolvedValue(null); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['remove', 'my-server'], { from: 'user' })).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('claude CLI not found. Install Claude Code: https://claude.ai/code'); + }); + }); + + describe('list', () => { + it('lists registered MCP servers', async () => { + const { exec } = await mockClaudeResolved(0); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['list'], { from: 'user' })).rejects.toThrow(/^process\.exit:/); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 0); + expect(exec).toHaveBeenCalledWith( + '/usr/local/bin/claude', + ['mcp', 'list'], + { interactive: true, shell: expectedShell } + ); + }); + + it('errors out when the claude CLI cannot be found', async () => { + const { getCommandPath } = await mockClaudeResolved(0); + vi.mocked(getCommandPath).mockResolvedValue(null); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['list'], { from: 'user' })).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('claude CLI not found. Install Claude Code: https://claude.ai/code'); + }); + + it('propagates the exit code returned by the claude CLI', async () => { + await mockClaudeResolved(3); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['list'], { from: 'user' })).rejects.toThrow(/^process\.exit:/); + + expect(exitSpy).toHaveBeenNthCalledWith(1, 3); + }); + + it('exits 1 when claude cannot be spawned (ENOENT)', async () => { + const { exec } = await mockClaudeResolved(0); + vi.mocked(exec).mockImplementation(async (_command: string, args: string[] = []) => { + if (args.includes('--version')) { + throw new Error('ENOENT: claude not found at fast path'); + } + throw new Error('spawn claude ENOENT'); + }); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['list'], { from: 'user' })).rejects.toThrow('process.exit:1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('claude CLI not found. Install Claude Code: https://claude.ai/code'); + }); + + it('extracts the exit code from a non-interactive rejection message', async () => { + const { exec } = await mockClaudeResolved(0); + vi.mocked(exec).mockImplementation(async (_command: string, args: string[] = []) => { + if (args.includes('--version')) { + throw new Error('ENOENT: claude not found at fast path'); + } + throw new Error('Command exited with code 7'); + }); + const { createMcpCommand } = await import('../index.js'); + + const command = createMcpCommand(); + await expect(command.parseAsync(['list'], { from: 'user' })).rejects.toThrow('process.exit:7'); + }); + }); +}); diff --git a/src/cli/commands/mcp/index.ts b/src/cli/commands/mcp/index.ts index d26ce939a..1178203e3 100644 --- a/src/cli/commands/mcp/index.ts +++ b/src/cli/commands/mcp/index.ts @@ -35,6 +35,30 @@ async function resolveClaudeCommand(): Promise<{ command: string; shell: boolean }; } +async function runClaudeMcpCommand(claudeCommand: string, useShell: boolean, args: string[]): Promise { + try { + const result = await exec(claudeCommand, args, { + interactive: true, + shell: useShell, + }); + process.exit(result.code); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('ENOENT')) { + console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); + process.exit(1); + } + + if (message.includes('terminated by signal')) { + process.exit(1); + } + + // exec rejects on non-zero exit in interactive mode — extract and propagate the code + const match = /code (\d+)/.exec(message); + process.exit(match ? parseInt(match[1], 10) : 1); + } +} + function createMcpAddCommand(): Command { const command = new Command('add'); @@ -82,34 +106,72 @@ function createMcpAddCommand(): Command { args.push(name, '--', 'codemie-mcp-proxy', url); + await runClaudeMcpCommand(claudeCommand, useShell, args); + }); + + return command; +} + +function createMcpRemoveCommand(): Command { + const command = new Command('remove'); + + command + .description('Remove a registered MCP server') + .argument('', 'Name of the MCP server to remove') + .option('--scope ', 'Scope for the MCP server (e.g. project, user)') + .action(async (name: string, options: { scope?: string }) => { + // Reject names that look like flags to avoid corrupting the claude command + if (name.startsWith('-')) { + console.error(`Invalid server name: ${name}`); + process.exit(1); + } + + let claudeCommand: string; + let useShell: boolean; try { - const result = await exec(claudeCommand, args, { - interactive: true, - shell: useShell, - }); - process.exit(result.code); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('ENOENT')) { - console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); - process.exit(1); - } + ({ command: claudeCommand, shell: useShell } = await resolveClaudeCommand()); + } catch { + console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); + process.exit(1); + } - if (message.includes('terminated by signal')) { - process.exit(1); - } + const args: string[] = ['mcp', 'remove']; - // exec rejects on non-zero exit in interactive mode — extract and propagate the code - const match = /code (\d+)/.exec(message); - process.exit(match ? parseInt(match[1], 10) : 1); + if (options.scope) { + args.push('--scope', options.scope); } + + args.push(name); + + await runClaudeMcpCommand(claudeCommand, useShell, args); }); return command; } +function createMcpListCommand(): Command { + const command = new Command('list'); + + command.description('List registered MCP servers').action(async () => { + let claudeCommand: string; + let useShell: boolean; + try { + ({ command: claudeCommand, shell: useShell } = await resolveClaudeCommand()); + } catch { + console.error('claude CLI not found. Install Claude Code: https://claude.ai/code'); + process.exit(1); + } + + await runClaudeMcpCommand(claudeCommand, useShell, ['mcp', 'list']); + }); + + return command; +} + export function createMcpCommand(): Command { const mcp = new Command('mcp').description('Manage MCP servers'); mcp.addCommand(createMcpAddCommand()); + mcp.addCommand(createMcpRemoveCommand()); + mcp.addCommand(createMcpListCommand()); return mcp; }