Skip to content

Commit 64b8e89

Browse files
feat: add MCP (Model Context Protocol) support
- MCP stdio client for connecting to MCP servers - Support for playwright, fetch, and memory MCP servers - Add /mcp slash command to display MCP server status 新增 MCP(模型上下文协议)支持 - 实现 MCP stdio 客户端,用于连接 MCP 服务器 - 支持 playwright、fetch、memory 三个 MCP 服务器 - 新增 /mcp 命令,显示 MCP 服务器状态和可用工具
1 parent a489e2d commit 64b8e89

10 files changed

Lines changed: 423 additions & 8 deletions

File tree

src/prompt.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ export type ToolDefinition = {
419419
};
420420
};
421421

422-
export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] {
422+
export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDefinition[] = []): ToolDefinition[] {
423423
const tools: ToolDefinition[] = [
424424
{
425425
type: "function",
@@ -610,5 +610,9 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] {
610610
},
611611
});
612612

613+
for (const tool of externalTools) {
614+
tools.push(tool);
615+
}
616+
613617
return tools;
614618
}

src/session.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open
99
import { launchNotifyScript } from "./notify";
1010
import { buildThinkingRequestOptions } from "./openai-thinking";
1111
import { DEEPSEEK_V4_MODELS } from "./model-capabilities";
12-
import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } from "./prompt";
12+
import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL, type ToolDefinition } from "./prompt";
1313
import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor";
14+
import { McpManager } from "./tools/mcp-manager";
1415
import { logApiError } from "./error-logger";
1516
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger";
1617

@@ -180,6 +181,8 @@ export class SessionManager {
180181
private activePromptController: AbortController | null = null;
181182
private readonly sessionControllers = new Map<string, AbortController>();
182183
private readonly toolExecutor: ToolExecutor;
184+
private readonly mcpManager = new McpManager();
185+
private mcpToolDefinitions: ToolDefinition[] = [];
183186

184187
constructor(options: SessionManagerOptions) {
185188
this.projectRoot = options.projectRoot;
@@ -188,7 +191,18 @@ export class SessionManager {
188191
this.onAssistantMessage = options.onAssistantMessage;
189192
this.onSessionEntryUpdated = options.onSessionEntryUpdated;
190193
this.onLlmStreamProgress = options.onLlmStreamProgress;
191-
this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient);
194+
this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager);
195+
}
196+
197+
async initMcpServers(
198+
servers?: Record<string, { command: string; args?: string[]; env?: Record<string, string> }>
199+
): Promise<void> {
200+
await this.mcpManager.initialize(servers);
201+
this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions();
202+
}
203+
204+
getMcpStatus() {
205+
return this.mcpManager.getStatus();
192206
}
193207

194208
private estimateStreamTokens(text: string): number {
@@ -1001,7 +1015,7 @@ ${skillMd}
10011015
{
10021016
model,
10031017
messages,
1004-
tools: getTools(this.getPromptToolOptions()),
1018+
tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions),
10051019
...thinkingOptions,
10061020
},
10071021
{ signal: sessionController.signal },

src/settings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ export type DeepcodingEnv = {
99

1010
export type ReasoningEffort = "high" | "max";
1111

12+
export type McpServerConfig = {
13+
command: string;
14+
args?: string[];
15+
env?: Record<string, string>;
16+
};
17+
1218
export type DeepcodingSettings = {
1319
env?: DeepcodingEnv;
1420
thinkingEnabled?: boolean;
1521
reasoningEffort?: ReasoningEffort;
1622
debugLogEnabled?: boolean;
1723
notify?: string;
1824
webSearchTool?: string;
25+
mcpServers?: Record<string, McpServerConfig>;
1926
};
2027

2128
export type ResolvedDeepcodingSettings = {
@@ -27,6 +34,7 @@ export type ResolvedDeepcodingSettings = {
2734
debugLogEnabled: boolean;
2835
notify?: string;
2936
webSearchTool?: string;
37+
mcpServers?: Record<string, McpServerConfig>;
3038
};
3139

3240
function resolveReasoningEffort(value: unknown): ReasoningEffort {
@@ -55,6 +63,8 @@ export function resolveSettings(
5563
const notify = typeof settings?.notify === "string" ? settings.notify.trim() : "";
5664
const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : "";
5765

66+
const mcpServers = settings?.mcpServers;
67+
5868
return {
5969
apiKey: env.API_KEY?.trim(),
6070
baseURL: env.BASE_URL?.trim() || defaults.baseURL,
@@ -64,5 +74,6 @@ export function resolveSettings(
6474
debugLogEnabled: settings?.debugLogEnabled === true,
6575
notify: notify || undefined,
6676
webSearchTool: webSearchTool || undefined,
77+
mcpServers,
6778
};
6879
}

src/tests/slashCommands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
1919
assert.equal(items[0].kind, "skill");
2020
assert.equal(items[0].name, "skill-writer");
2121
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
22-
assert.deepEqual(builtinNames, ["skills", "new", "init", "resume", "exit"]);
22+
assert.deepEqual(builtinNames, ["skills", "new", "init", "resume", "mcp", "exit"]);
2323
});
2424

2525
test("filterSlashCommands matches partial prefixes", () => {

src/tools/executor.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { handleEditTool } from "./edit-handler";
66
import { handleReadTool } from "./read-handler";
77
import { handleWebSearchTool } from "./web-search-handler";
88
import { handleWriteTool } from "./write-handler";
9+
import type { McpManager } from "./mcp-manager";
910

1011
export type CreateOpenAIClient = () => {
1112
client: OpenAI | null;
@@ -73,11 +74,13 @@ export type ToolCallExecution = {
7374
export class ToolExecutor {
7475
private readonly projectRoot: string;
7576
private readonly createOpenAIClient?: CreateOpenAIClient;
77+
private readonly mcpManager?: McpManager;
7678
private readonly toolHandlers = new Map<string, ToolHandler>();
7779

78-
constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient) {
80+
constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient, mcpManager?: McpManager) {
7981
this.projectRoot = projectRoot;
8082
this.createOpenAIClient = createOpenAIClient;
83+
this.mcpManager = mcpManager;
8184
this.registerToolHandlers();
8285
}
8386

@@ -161,6 +164,12 @@ export class ToolExecutor {
161164
const toolName = toolCall.function.name;
162165
const handler = this.toolHandlers.get(toolName);
163166
if (!handler) {
167+
// Try MCP tools
168+
if (toolName.startsWith("mcp__") && this.mcpManager) {
169+
const parsedArgs = this.parseToolArguments(toolCall.function.arguments);
170+
const args = parsedArgs.ok ? parsedArgs.args : {};
171+
return this.mcpManager.executeMcpTool(toolName, args);
172+
}
164173
return {
165174
ok: false,
166175
name: toolName,

src/tools/mcp-client.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { spawn, type ChildProcess } from "child_process";
2+
import { createInterface, type Interface } from "readline";
3+
import * as os from "os";
4+
5+
type JsonRpcRequest = {
6+
jsonrpc: "2.0";
7+
id: number;
8+
method: string;
9+
params?: Record<string, unknown>;
10+
};
11+
12+
type JsonRpcResponse = {
13+
jsonrpc: "2.0";
14+
id: number;
15+
result?: unknown;
16+
error?: { code: number; message: string; data?: unknown };
17+
};
18+
19+
export type McpToolDefinition = {
20+
name: string;
21+
description?: string;
22+
inputSchema: {
23+
type: "object";
24+
properties: Record<string, unknown>;
25+
required?: string[];
26+
};
27+
};
28+
29+
type ListToolsResult = {
30+
tools: McpToolDefinition[];
31+
};
32+
33+
type CallToolResult = {
34+
content: Array<{ type: string; text?: string }>;
35+
isError?: boolean;
36+
};
37+
38+
export class McpClient {
39+
private process: ChildProcess | null = null;
40+
private reader: Interface | null = null;
41+
private nextId = 1;
42+
private pendingRequests = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
43+
private buffer = "";
44+
45+
constructor(
46+
private readonly serverName: string,
47+
private readonly command: string,
48+
private readonly args: string[] = [],
49+
private readonly env?: Record<string, string>
50+
) {}
51+
52+
async connect(): Promise<void> {
53+
return new Promise((resolve, reject) => {
54+
const childEnv = {
55+
...process.env,
56+
...this.env,
57+
};
58+
59+
const isWindows = os.platform() === "win32";
60+
61+
if (isWindows) {
62+
// On Windows, .cmd files require shell: true to be spawned.
63+
// Build a single command string so cmd.exe handles quoting correctly.
64+
const cmd = [this.command + ".cmd", ...this.args].join(" ");
65+
this.process = spawn(cmd, [], {
66+
stdio: ["pipe", "pipe", "pipe"],
67+
env: childEnv,
68+
shell: true,
69+
windowsHide: true,
70+
});
71+
} else {
72+
this.process = spawn(this.command, this.args, {
73+
stdio: ["pipe", "pipe", "pipe"],
74+
env: childEnv,
75+
});
76+
}
77+
78+
this.process.on("error", (err) => {
79+
reject(new Error(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`));
80+
});
81+
82+
this.process.on("exit", (code) => {
83+
const error = new Error(`MCP server "${this.serverName}" exited with code ${code}`);
84+
for (const [, pending] of this.pendingRequests) {
85+
pending.reject(error);
86+
}
87+
this.pendingRequests.clear();
88+
});
89+
90+
if (this.process.stderr) {
91+
this.process.stderr.on("data", (data: Buffer) => {
92+
// MCP servers log to stderr; we ignore for now
93+
});
94+
}
95+
96+
this.reader = createInterface({ input: this.process.stdout! });
97+
this.reader.on("line", (line: string) => {
98+
this.handleLine(line);
99+
});
100+
101+
// Send initialize request (MCP protocol handshake)
102+
this.sendRequest("initialize", {
103+
protocolVersion: "2024-11-05",
104+
capabilities: {},
105+
clientInfo: { name: "deepcode-cli", version: "0.1.0" },
106+
})
107+
.then(() => {
108+
// Send initialized notification
109+
this.sendNotification("notifications/initialized");
110+
resolve();
111+
})
112+
.catch(reject);
113+
});
114+
}
115+
116+
async listTools(): Promise<McpToolDefinition[]> {
117+
const result = (await this.sendRequest("tools/list", {})) as ListToolsResult;
118+
return result.tools ?? [];
119+
}
120+
121+
async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
122+
return (await this.sendRequest("tools/call", { name, arguments: args })) as CallToolResult;
123+
}
124+
125+
disconnect(): void {
126+
if (this.reader) {
127+
this.reader.close();
128+
this.reader = null;
129+
}
130+
if (this.process) {
131+
this.process.kill();
132+
this.process = null;
133+
}
134+
}
135+
136+
private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
137+
return new Promise((resolve, reject) => {
138+
const id = this.nextId++;
139+
const request: JsonRpcRequest = {
140+
jsonrpc: "2.0",
141+
id,
142+
method,
143+
params,
144+
};
145+
this.pendingRequests.set(id, { resolve, reject });
146+
this.writeLine(JSON.stringify(request));
147+
});
148+
}
149+
150+
private sendNotification(method: string, params?: Record<string, unknown>): void {
151+
const notification = {
152+
jsonrpc: "2.0" as const,
153+
method,
154+
params,
155+
};
156+
this.writeLine(JSON.stringify(notification));
157+
}
158+
159+
private writeLine(data: string): void {
160+
if (this.process?.stdin) {
161+
this.process.stdin.write(data + "\n");
162+
}
163+
}
164+
165+
private handleLine(line: string): void {
166+
try {
167+
const message = JSON.parse(line) as JsonRpcResponse;
168+
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
169+
const pending = this.pendingRequests.get(message.id)!;
170+
this.pendingRequests.delete(message.id);
171+
if (message.error) {
172+
pending.reject(new Error(`MCP error: ${message.error.message}`));
173+
} else {
174+
pending.resolve(message.result);
175+
}
176+
}
177+
} catch {
178+
// Ignore unparseable lines
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)