Skip to content

Commit e408cae

Browse files
oratisclaude
andauthored
feat(mcp): deepcode mcp serve — expose tools as an MCP server over stdio (#113)
Adds the server side of MCP (DEVELOPMENT_PLAN §3.3): other MCP clients (Claude Desktop, another DeepCode, etc.) can now call DeepCode's tools. core (packages/core/src/mcp/serve.ts): - buildMcpServer({cwd,...}) — wraps the tool registry in an SDK `Server` with ListTools + CallTool handlers; returned unconnected so any transport attaches (stdio in prod, in-memory pair in tests). - serveMcpOverStdio() — connects StdioServerTransport, resolves on peer disconnect / stdin EOF / abort (closes the transport cleanly on SIGINT). - mcpServableTools() / MCP_SERVE_EXCLUDE — exposes only STATELESS tools (Read/Write/Edit/Bash/Grep/Glob/NotebookEdit/TodoWrite/WebFetch/WebSearch); excludes the 9 host-coupled ones (AskUserQuestion, plan/worktree/Task, Cron*) that need context an MCP peer can't supply. cli (apps/cli/src/mcp-cmd.ts): - `deepcode mcp serve` + `deepcode mcp` help. Banner/readiness go to stderr — stdout stays the pure JSON-RPC channel. Serve impl is injectable for tests. - Wired into cli.ts dispatch + --help. Tests: 6 core (in-memory client↔server: listTools hides excluded tools, Write→Read round-trip, unknown-tool + tool-error → isError) + 3 CLI (help, unknown subcommand → exit 2, serve forwards cwd & logs only to stderr). Verified end-to-end: a real StdioClientTransport spawned `mcp serve`, listed 10 tools, and called Bash successfully. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4e1be3 commit e408cae

8 files changed

Lines changed: 369 additions & 3 deletions

File tree

apps/cli/src/cli.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CredentialsStore, VERSION, redact } from '@deepcode/core';
77
import { homedir } from 'node:os';
88
import { resolve } from 'node:path';
99
import { runHeadless } from './headless.js';
10+
import { runMcpCommand } from './mcp-cmd.js';
1011
import { runOnboarding } from './onboarding.js';
1112
import { helpText, parseArgs } from './parse-args.js';
1213
import { startRepl } from './repl.js';
@@ -52,6 +53,13 @@ async function main(): Promise<number> {
5253
errOutput: process.stderr,
5354
});
5455
}
56+
if (args.positional[0] === 'mcp') {
57+
return runMcpCommand(args.positional.slice(1), {
58+
cwd: process.cwd(),
59+
output: process.stdout,
60+
errOutput: process.stderr,
61+
});
62+
}
5563

5664
// Headless one-shot (-p / --print)
5765
if (args.prompt !== undefined) {

apps/cli/src/mcp-cmd.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Writable } from 'node:stream';
2+
import { describe, expect, it } from 'vitest';
3+
import { runMcpCommand } from './mcp-cmd.js';
4+
5+
function sink(): { stream: Writable; text: () => string } {
6+
let buf = '';
7+
const stream = new Writable({
8+
write(chunk, _enc, cb) {
9+
buf += chunk.toString();
10+
cb();
11+
},
12+
});
13+
return { stream, text: () => buf };
14+
}
15+
16+
describe('runMcpCommand', () => {
17+
it('prints help and returns 0 with no subcommand', async () => {
18+
const out = sink();
19+
const code = await runMcpCommand([], { cwd: '/tmp', output: out.stream });
20+
expect(code).toBe(0);
21+
expect(out.text()).toMatch(/Usage: deepcode mcp/);
22+
expect(out.text()).toContain('"mcp", "serve"');
23+
});
24+
25+
it('prints help and returns 2 for an unknown subcommand', async () => {
26+
const out = sink();
27+
const code = await runMcpCommand(['bogus'], { cwd: '/tmp', output: out.stream });
28+
expect(code).toBe(2);
29+
expect(out.text()).toMatch(/Usage: deepcode mcp/);
30+
});
31+
32+
it('serve logs readiness to stderr (not stdout) and forwards cwd', async () => {
33+
const out = sink();
34+
const err = sink();
35+
let receivedCwd = '';
36+
const code = await runMcpCommand(['serve'], {
37+
cwd: '/my/project',
38+
output: out.stream,
39+
errOutput: err.stream,
40+
// Fake serve: record cwd, fire onReady, return immediately (no real stdio).
41+
serve: async (opts) => {
42+
receivedCwd = opts.cwd;
43+
opts.onReady?.(['Read', 'Write']);
44+
},
45+
});
46+
expect(code).toBe(0);
47+
expect(receivedCwd).toBe('/my/project');
48+
// stdout must stay clean — it's the JSON-RPC channel.
49+
expect(out.text()).toBe('');
50+
expect(err.text()).toMatch(/exposing \d+ tools over stdio in \/my\/project/);
51+
expect(err.text()).toMatch(/\[mcp\] ready: Read, Write/);
52+
});
53+
});

apps/cli/src/mcp-cmd.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// `deepcode mcp <serve|...>` — MCP subcommands.
2+
// Spec: docs/DEVELOPMENT_PLAN.md §3.3
3+
//
4+
// `mcp serve` exposes DeepCode's stateless tools as an MCP server over stdio.
5+
// CRITICAL: in serve mode stdout is the JSON-RPC channel — every diagnostic
6+
// line goes to stderr, and nothing else may touch stdout.
7+
8+
import {
9+
VERSION,
10+
mcpServableTools,
11+
serveMcpOverStdio,
12+
type ServeMcpStdioOpts,
13+
} from '@deepcode/core';
14+
import type { Writable } from 'node:stream';
15+
16+
export interface McpCmdDeps {
17+
cwd: string;
18+
/** Diagnostics sink — defaults to process.stderr (NEVER stdout in serve mode). */
19+
errOutput?: Writable;
20+
/** Help/normal output sink — defaults to process.stdout. */
21+
output?: Writable;
22+
/** Abort signal to stop the server (tests / SIGINT). */
23+
signal?: AbortSignal;
24+
/** Serve implementation — injectable so tests don't grab the real stdio. */
25+
serve?: (opts: ServeMcpStdioOpts) => Promise<void>;
26+
}
27+
28+
export async function runMcpCommand(sub: string[], deps: McpCmdDeps): Promise<number> {
29+
const err = deps.errOutput ?? process.stderr;
30+
const out = deps.output ?? process.stdout;
31+
const cmd = sub[0];
32+
33+
if (cmd === 'serve') {
34+
const tools = mcpServableTools();
35+
err.write(
36+
`DeepCode MCP server v${VERSION} — exposing ${tools.length} tools over stdio in ${deps.cwd}\n`,
37+
);
38+
await (deps.serve ?? serveMcpOverStdio)({
39+
cwd: deps.cwd,
40+
version: VERSION,
41+
signal: deps.signal,
42+
onReady: (names) => err.write(`[mcp] ready: ${names.join(', ')}\n`),
43+
});
44+
return 0;
45+
}
46+
47+
out.write(mcpHelp());
48+
return cmd ? 2 : 0;
49+
}
50+
51+
function mcpHelp(): string {
52+
return [
53+
'Usage: deepcode mcp <command>',
54+
'',
55+
' serve Expose DeepCode tools as an MCP server over stdio',
56+
'',
57+
'Add to another MCP client (e.g. Claude Desktop) as:',
58+
' { "command": "deepcode", "args": ["mcp", "serve"] }',
59+
'',
60+
'Configure servers DeepCode connects TO in settings.json under mcpServers.',
61+
'',
62+
].join('\n');
63+
}

apps/cli/src/parse-args.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ USAGE
268268
deepcode upgrade Self-update (CLI; Mac client auto-updates)
269269
deepcode cron <cmd> Scheduled tasks: install/uninstall/list/status
270270
deepcode scheduler run Run due scheduled jobs (invoked by launchd)
271+
deepcode mcp serve Expose DeepCode tools as an MCP server (stdio)
271272
272273
MODE
273274
--mode <name> default / acceptEdits / plan / auto / dontAsk / bypassPermissions

packages/core/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,20 @@ export {
183183
type SandboxedCommand,
184184
} from './sandbox/index.js';
185185

186-
// MCP client (M3c — stdio transport; http/sse/OAuth/serve → M3c-ext)
186+
// MCP client (M3c — stdio transport; http/sse → M3c-ext) + server (`mcp serve`)
187187
export {
188188
connectMcpServer,
189189
connectAllMcpServers,
190190
closeAllMcpServers,
191+
buildMcpServer,
192+
serveMcpOverStdio,
193+
mcpServableTools,
194+
MCP_SERVE_EXCLUDE,
191195
type McpClientHandle,
192196
type McpToolMeta,
193197
type ConnectAllResult,
198+
type BuildMcpServerOpts,
199+
type ServeMcpStdioOpts,
194200
} from './mcp/index.js';
195201

196202
// Plugins (M5 — manifest + hash pin; M5.1 — subprocess runtime + RPC bridge;

packages/core/src/mcp/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// MCP client subsystem entry — stdio + Streamable HTTP + SSE transports, with
2-
// static + headersHelper auth. (Full OAuth + Elicitation + `mcp serve` → later.)
1+
// MCP subsystem entry — client (stdio + Streamable HTTP + SSE transports, with
2+
// static + headersHelper auth) + server (`mcp serve` exposes our tools over
3+
// stdio). (Full OAuth + Elicitation → later.)
34
// Spec: docs/DEVELOPMENT_PLAN.md §3.3
45

56
export {
@@ -13,3 +14,12 @@ export {
1314
type McpTransportKind,
1415
type ConnectAllResult,
1516
} from './client.js';
17+
18+
export {
19+
buildMcpServer,
20+
serveMcpOverStdio,
21+
mcpServableTools,
22+
MCP_SERVE_EXCLUDE,
23+
type BuildMcpServerOpts,
24+
type ServeMcpStdioOpts,
25+
} from './serve.js';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3+
import { promises as fs } from 'node:fs';
4+
import { mkdtemp, rm } from 'node:fs/promises';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8+
import { buildMcpServer, MCP_SERVE_EXCLUDE, mcpServableTools } from './serve.js';
9+
10+
describe('mcpServableTools', () => {
11+
it('excludes interactive / host-coupled tools', () => {
12+
const names = mcpServableTools().map((t) => t.name);
13+
for (const excluded of MCP_SERVE_EXCLUDE) {
14+
expect(names).not.toContain(excluded);
15+
}
16+
});
17+
18+
it('includes the core file/shell tools', () => {
19+
const names = mcpServableTools().map((t) => t.name);
20+
expect(names).toEqual(
21+
expect.arrayContaining(['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob']),
22+
);
23+
});
24+
});
25+
26+
describe('buildMcpServer over an in-memory transport', () => {
27+
let dir: string;
28+
let client: Client;
29+
30+
beforeEach(async () => {
31+
dir = await mkdtemp(join(tmpdir(), 'dc-mcp-serve-'));
32+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
33+
const server = buildMcpServer({ cwd: dir, name: 'deepcode-test', version: '9.9.9' });
34+
client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
35+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
36+
});
37+
afterEach(async () => {
38+
await client.close();
39+
await rm(dir, { recursive: true, force: true });
40+
});
41+
42+
it('lists tools (and hides excluded ones)', async () => {
43+
const { tools } = await client.listTools();
44+
const names = tools.map((t) => t.name);
45+
expect(names).toContain('Read');
46+
expect(names).toContain('Write');
47+
expect(names).not.toContain('AskUserQuestion');
48+
expect(names).not.toContain('Task');
49+
// every listed tool carries a description + object input schema
50+
for (const t of tools) {
51+
expect(typeof t.description).toBe('string');
52+
expect(t.inputSchema).toMatchObject({ type: 'object' });
53+
}
54+
});
55+
56+
it('executes a tool round-trip (Write then Read)', async () => {
57+
const file = join(dir, 'note.txt');
58+
const writeRes = await client.callTool({
59+
name: 'Write',
60+
arguments: { file_path: file, content: 'hello from mcp' },
61+
});
62+
expect(writeRes.isError ?? false).toBe(false);
63+
expect(await fs.readFile(file, 'utf8')).toBe('hello from mcp');
64+
65+
const readRes = (await client.callTool({
66+
name: 'Read',
67+
arguments: { file_path: file },
68+
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
69+
expect(readRes.isError ?? false).toBe(false);
70+
expect(readRes.content[0]!.text).toContain('hello from mcp');
71+
});
72+
73+
it('returns isError for an unknown tool', async () => {
74+
const res = (await client.callTool({ name: 'NoSuchTool', arguments: {} })) as {
75+
content: Array<{ text: string }>;
76+
isError?: boolean;
77+
};
78+
expect(res.isError).toBe(true);
79+
expect(res.content[0]!.text).toMatch(/Unknown tool/);
80+
});
81+
82+
it('surfaces a tool-level error as isError (Read of a missing file)', async () => {
83+
const res = (await client.callTool({
84+
name: 'Read',
85+
arguments: { file_path: join(dir, 'does-not-exist.txt') },
86+
})) as { content: Array<{ text: string }>; isError?: boolean };
87+
expect(res.isError).toBe(true);
88+
});
89+
});

0 commit comments

Comments
 (0)