Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> # Manage native plugins
codemie mcp <command> # Manage MCP servers registered with Claude Code
codemie mcp-proxy <url> # Stdio-to-HTTP MCP proxy with OAuth support
codemie codebase <command> # Manage Codebase Memory graph UI
codemie docs <command> # Manage documentation & knowledge tools
Expand Down Expand Up @@ -926,6 +927,33 @@ codemie plugin disable <name>

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 <name> <url> [--scope <scope>] # Register an MCP server via codemie-mcp-proxy
codemie mcp remove <name> [--scope <scope>] # Remove a registered MCP server
codemie mcp list # List registered MCP servers
```

**`codemie mcp add`**
- `<name>` — Name for the MCP server
- `<url>` — MCP server URL (must be a valid HTTP/HTTPS URL)
- `--scope <scope>` — Scope for the MCP server (e.g. `project`, `user`)

Registers the server by running `claude mcp add <name> -- codemie-mcp-proxy <url>`, so the server is launched through the [MCP proxy](#mcp-proxy-command) for OAuth support.

**`codemie mcp remove`**
- `<name>` — Name of the MCP server to remove
- `--scope <scope>` — Scope for the MCP server (e.g. `project`, `user`)

Removes the server by running `claude mcp remove <name>`.

**`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.
Expand Down
290 changes: 290 additions & 0 deletions src/cli/commands/mcp/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;

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');
});
});
});
Loading
Loading