diff --git a/README-en.md b/README-en.md index 453dd64c..d5531386 100644 --- a/README-en.md +++ b/README-en.md @@ -133,7 +133,7 @@ Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compati ### How do I configure MCP? -Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it, then use the `/mcp` command to view MCP server status and available tools. +Deep Code supports MCP (Model Context Protocol) to connect external services such as GitHub, browsers, databases, and more. Configure the `mcpServers` field in `settings.json` to enable it — both local stdio servers and remote Streamable HTTP servers are supported — then use the `/mcp` command to view MCP server status and available tools. For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) diff --git a/README-zh_CN.md b/README-zh_CN.md index cde02314..76d5aa27 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -118,7 +118,7 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 ### 如何配置 MCP? -Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,既支持本地 stdio 服务器,也支持远程 Streamable HTTP 服务器;启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 详细配置指南:[docs/mcp.md](docs/mcp.md) diff --git a/README.md b/README.md index cde02314..76d5aa27 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 ### 如何配置 MCP? -Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,既支持本地 stdio 服务器,也支持远程 Streamable HTTP 服务器;启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 详细配置指南:[docs/mcp.md](docs/mcp.md) diff --git a/docs/mcp.md b/docs/mcp.md index 73034a38..5d3b2931 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -43,9 +43,38 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, | 字段 | 类型 | 必填 | 说明 | | --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `command` | string | 否 | 本地 stdio 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | | `args` | string[] | 否 | 传递给命令的参数列表 | | `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | +| `type` | string | 否 | 传输类型:`stdio`(本地子进程,默认)或 `http`(远程 Streamable HTTP)。给出 `url` 时默认为 `http`。 | +| `url` | string | 否 | 远程 MCP 服务器的 HTTP(S) 端点(Streamable HTTP)。设置后即为远程服务器,无需 `command`。 | +| `headers` | object | 否 | 远程请求附带的 HTTP 头(如 `Authorization`),常用于鉴权。 | + +> 本地服务器用 `command`/`args`/`env`,远程服务器用 `url`/`headers`,二者择一。 + +## 远程 MCP 服务器(Streamable HTTP) + +除了本地 stdio 服务器,Deep Code 也支持通过 **Streamable HTTP**(MCP 2025-03-26)连接远程 MCP 服务器。只要在配置里给出 `url`(或显式设置 `type: "http"`)即可: + +```json +{ + "mcpServers": { + "remote-service": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +- `url`:远程服务器的 HTTP(S) 端点。 +- `headers`:可选,附加到每个请求的 HTTP 头,常用于鉴权(如 `Authorization`)。 +- 初始化时服务器返回的 `Mcp-Session-Id` 会被自动记录,并在后续请求中回传。 + +> 当前仅支持 Streamable HTTP;尚不支持旧版 HTTP+SSE 双端点传输与 OAuth 授权流程。 ## 常用 MCP 示例 diff --git a/docs/mcp_en.md b/docs/mcp_en.md index 03c4b30c..6931f7bb 100644 --- a/docs/mcp_en.md +++ b/docs/mcp_en.md @@ -43,9 +43,38 @@ Edit `~/.deepcode/settings.json` and add the `mcpServers` field: | Field | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `command` | string | No | Executable path or command for a local stdio server (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | | `args` | string[] | No | List of arguments to pass to the command | | `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | +| `type` | string | No | Transport: `stdio` (local subprocess, default) or `http` (remote Streamable HTTP). Defaults to `http` when a `url` is given. | +| `url` | string | No | HTTP(S) endpoint of a remote MCP server (Streamable HTTP). When set, the server is remote and no `command` is needed. | +| `headers` | object | No | HTTP headers (e.g., `Authorization`) sent with each remote request, typically for authentication. | + +> Use `command`/`args`/`env` for local servers, or `url`/`headers` for remote servers — one or the other. + +## Remote MCP Servers (Streamable HTTP) + +In addition to local stdio servers, Deep Code can connect to remote MCP servers over **Streamable HTTP** (MCP 2025-03-26). Just provide a `url` (or set `type: "http"` explicitly): + +```json +{ + "mcpServers": { + "remote-service": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +- `url`: the remote server's HTTP(S) endpoint. +- `headers`: optional HTTP headers attached to every request, typically for authentication (e.g., `Authorization`). +- The `Mcp-Session-Id` returned by the server on initialize is captured automatically and echoed on subsequent requests. + +> Only Streamable HTTP is supported for now; the legacy two-endpoint HTTP+SSE transport and OAuth authorization flows are not yet handled. ## Common MCP Examples diff --git a/packages/core/src/mcp/mcp-client.ts b/packages/core/src/mcp/mcp-client.ts index 4420c569..2c0e608d 100644 --- a/packages/core/src/mcp/mcp-client.ts +++ b/packages/core/src/mcp/mcp-client.ts @@ -1,7 +1,11 @@ -import { spawn, type ChildProcess } from "child_process"; -import { createInterface, type Interface } from "readline"; -import * as path from "path"; -import { killProcessTree } from "../common/process-tree"; +import type { McpServerConfig } from "../settings"; +import type { McpTransport } from "./mcp-transport"; +import { StdioTransport, createMcpSpawnSpec } from "./mcp-stdio-transport"; +import { HttpTransport } from "./mcp-http-transport"; + +export { createMcpSpawnSpec }; +export type { McpSpawnSpec } from "./mcp-stdio-transport"; +export type { McpTransport } from "./mcp-transport"; type JsonRpcRequest = { jsonrpc: "2.0"; @@ -96,31 +100,27 @@ type ReadResourceResult = { export type McpNotificationHandler = (method: string, params?: Record) => void; -export type McpSpawnSpec = { - command: string; - args: string[]; - shell: boolean; - windowsHide?: boolean; -}; +const PROTOCOL_VERSION = "2025-03-26"; +const SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION, "2024-11-05"]); +/** + * MCP JSON-RPC client. Owns request/response correlation, the initialize + * handshake and notification dispatch; the underlying byte transport (stdio + * subprocess or remote HTTP) is injected via {@link McpTransport}. + */ export class McpClient { - private process: ChildProcess | null = null; - private reader: Interface | null = null; private nextId = 1; private pendingRequests = new Map< number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } >(); - private stderrBuffer = ""; - private notificationHandler: McpNotificationHandler | null = null; - private disconnectHandler: ((reason: string) => void) | null = null; - private intentionallyDisconnected = false; + private notificationHandler: McpNotificationHandler | null; + private disconnectHandler: ((reason: string) => void) | null; + private connected = false; constructor( private readonly serverName: string, - private readonly command: string, - private readonly args: string[] = [], - private readonly env?: Record, + private readonly transport: McpTransport, onNotification?: McpNotificationHandler, onDisconnect?: (reason: string) => void ) { @@ -129,93 +129,33 @@ export class McpClient { } async connect(timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - this.intentionallyDisconnected = false; - const childEnv = { - ...process.env, - ...this.env, - }; - const args = this.withNpxYesArg(this.command, this.args); - const spawnSpec = createMcpSpawnSpec(this.command, args); - - this.process = spawn(spawnSpec.command, spawnSpec.args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - shell: spawnSpec.shell, - windowsHide: spawnSpec.windowsHide, - }); - - let resolved = false; - const safeReject = (err: Error) => { - if (!resolved) { - resolved = true; - reject(err); - } - }; - - this.process.on("error", (err) => { - safeReject( - this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`) - ); - }); - - this.process.on("close", (code) => { - const reason = `MCP server "${this.serverName}" exited with code ${code}`; - const error = this.withStderr(reason); - for (const [, pending] of this.pendingRequests) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.pendingRequests.clear(); - this.reader?.close(); - this.reader = null; - this.process = null; - if (!this.intentionallyDisconnected && this.disconnectHandler) { - this.disconnectHandler(reason); - } - safeReject(error); - }); + await this.transport.start({ + onMessage: (message) => this.handleSingleMessage(message), + onClose: (reason) => this.handleTransportClose(reason), + }); - if (this.process.stderr) { - this.process.stderr.on("data", (data: Buffer) => { - this.appendStderr(data.toString("utf8")); - }); - } + // MCP protocol handshake + const result = await this.sendRequest( + "initialize", + { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "deepcode-cli", version: "0.1.0" }, + }, + timeoutMs + ); + + // Validate protocol version from server response (per MCP spec §4.2.1.2) + const serverVersion = (result as { protocolVersion?: string } | undefined)?.protocolVersion; + if (serverVersion && !SUPPORTED_PROTOCOL_VERSIONS.has(serverVersion)) { + throw new Error( + `Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` + + `Client supports ${[...SUPPORTED_PROTOCOL_VERSIONS].join(" and ")}.` + ); + } - this.reader = createInterface({ input: this.process.stdout! }); - this.reader.on("line", (line: string) => { - this.handleLine(line); - }); - - // Send initialize request (MCP protocol handshake) - this.sendRequest( - "initialize", - { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "deepcode-cli", version: "0.1.0" }, - }, - timeoutMs - ) - .then((result) => { - // Validate protocol version from server response (per MCP spec §4.2.1.2) - const initResult = result as { protocolVersion?: string } | undefined; - const serverVersion = initResult?.protocolVersion; - if (serverVersion && serverVersion !== "2025-03-26" && serverVersion !== "2024-11-05") { - reject( - new Error( - `Unsupported MCP protocol version "${serverVersion}" from server "${this.serverName}". ` + - `Client supports 2025-03-26 and 2024-11-05.` - ) - ); - return; - } - // Send initialized notification - this.sendNotification("notifications/initialized"); - resolve(); - }) - .catch(reject); - }); + this.sendNotification("notifications/initialized"); + this.connected = true; } async listTools(timeoutMs: number): Promise { @@ -232,7 +172,7 @@ export class McpClient { } } - throw this.withStderr(`MCP server "${this.serverName}" returned too many tools/list pages`); + throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many tools/list pages`); } async callTool(name: string, args: Record, timeoutMs = 60_000): Promise { @@ -253,7 +193,7 @@ export class McpClient { } } - throw this.withStderr(`MCP server "${this.serverName}" returned too many prompts/list pages`); + throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many prompts/list pages`); } async getPrompt(name: string, args: Record, timeoutMs = 30_000): Promise { @@ -274,7 +214,7 @@ export class McpClient { } } - throw this.withStderr(`MCP server "${this.serverName}" returned too many resources/list pages`); + throw this.transport.decorateError(`MCP server "${this.serverName}" returned too many resources/list pages`); } async readResource(uri: string, timeoutMs = 30_000): Promise { @@ -282,23 +222,12 @@ export class McpClient { } disconnect(): void { - this.intentionallyDisconnected = true; - if (this.reader) { - this.reader.close(); - this.reader = null; - } - if (this.process) { - if (typeof this.process.pid === "number") { - killProcessTree(this.process.pid, "SIGTERM", { killGroupOnNonWindows: false }); - } else { - this.process.kill(); - } - this.process = null; - } + this.connected = false; + this.transport.close(); } isConnected(): boolean { - return this.process !== null && this.process.exitCode === null; + return this.connected && this.transport.isConnected(); } private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise { @@ -313,52 +242,39 @@ export class McpClient { const timer = setTimeout(() => { this.pendingRequests.delete(id); reject( - this.withStderr( + this.transport.decorateError( `Timed out after ${timeoutMs}ms waiting for MCP server "${this.serverName}" to respond to ${method}` ) ); }, timeoutMs); this.pendingRequests.set(id, { resolve, reject, timer }); - this.writeLine(JSON.stringify(request)); + this.transport.send(request); }); } private sendNotification(method: string, params?: Record): void { - const notification = { - jsonrpc: "2.0" as const, + const notification: JsonRpcNotification = { + jsonrpc: "2.0", method, params, }; - this.writeLine(JSON.stringify(notification)); + this.transport.send(notification); } - private writeLine(data: string): void { - if (this.process?.stdin) { - this.process.stdin.write(data + "\n"); + private handleTransportClose(reason: string): void { + const error = this.transport.decorateError(reason); + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error); } - } - - private handleLine(line: string): void { - try { - const parsed: unknown = JSON.parse(line); - - // Handle JSON-RPC batch (array of requests/notifications/responses) - // Per MCP 2025-03-26 §4.1.1.3: implementations MUST support receiving batches. - if (Array.isArray(parsed)) { - for (const item of parsed) { - if (item && typeof item === "object") { - this.handleSingleMessage(item); - } - } - return; - } - - // Handle single message - if (parsed && typeof parsed === "object") { - this.handleSingleMessage(parsed); - } - } catch { - // Ignore unparseable lines + this.pendingRequests.clear(); + + // Only surface a crash once the server was fully connected; failures during + // the initial handshake are reported via connect() rejecting instead. + const wasConnected = this.connected; + this.connected = false; + if (wasConnected) { + this.disconnectHandler?.(reason); } } @@ -383,69 +299,28 @@ export class McpClient { this.pendingRequests.delete(message.id); clearTimeout(pending.timer); if (message.error) { - pending.reject(this.withStderr(`MCP error: ${message.error.message}`)); + pending.reject(this.transport.decorateError(`MCP error: ${message.error.message}`)); } else { pending.resolve(message.result); } } } - - private withNpxYesArg(command: string, args: string[]): string[] { - const executable = path - .basename(command) - .toLowerCase() - .replace(/\.cmd$/, ""); - if (executable !== "npx") { - return args; - } - if (args.includes("-y") || args.includes("--yes")) { - return args; - } - return ["-y", ...args]; - } - - private appendStderr(text: string): void { - this.stderrBuffer = `${this.stderrBuffer}${text}`; - if (this.stderrBuffer.length > 4000) { - this.stderrBuffer = this.stderrBuffer.slice(-4000); - } - } - - private withStderr(message: string): Error { - const stderr = this.stderrBuffer.trim(); - return new Error(stderr ? `${message}. stderr: ${stderr}` : message); - } -} - -export function createMcpSpawnSpec( - command: string, - args: string[], - platform: NodeJS.Platform = process.platform -): McpSpawnSpec { - if (platform === "win32") { - return { - // On Windows, shell: true lets cmd.exe resolve the command via PATHEXT - // (npx -> npx.cmd, etc.). Join command and args into a single string - // with empty spawn args to avoid Node 24 DEP0190. - // Only quote arguments that need protection from cmd.exe to prevent - // double-wrapping by Node.js's own shell quoting. - command: [command, ...args].map(quoteWindowsArgIfNeeded).join(" "), - args: [], - shell: true, - windowsHide: true, - }; - } - - return { - command, - args, - shell: false, - }; } -function quoteWindowsArgIfNeeded(arg: string): string { - if (/[\s"&|<>^()]/.test(arg)) { - return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`; - } - return arg; +/** + * Build an {@link McpClient} for a configured server, selecting the transport + * from the config (remote Streamable HTTP when a `url`/`type: "http"` is given, + * otherwise a local stdio subprocess). + */ +export function createMcpClient( + serverName: string, + config: McpServerConfig, + onNotification?: McpNotificationHandler, + onDisconnect?: (reason: string) => void +): McpClient { + const isRemote = config.type === "http" || (config.type !== "stdio" && !!config.url); + const transport: McpTransport = isRemote + ? new HttpTransport(serverName, { url: config.url ?? "", headers: config.headers }) + : new StdioTransport(serverName, config.command ?? "", config.args ?? [], config.env); + return new McpClient(serverName, transport, onNotification, onDisconnect); } diff --git a/packages/core/src/mcp/mcp-http-transport.ts b/packages/core/src/mcp/mcp-http-transport.ts new file mode 100644 index 00000000..91da968c --- /dev/null +++ b/packages/core/src/mcp/mcp-http-transport.ts @@ -0,0 +1,202 @@ +import type { McpTransport, McpTransportHandlers } from "./mcp-transport"; + +export type HttpTransportOptions = { + url: string; + headers?: Record; +}; + +/** + * Remote transport implementing the MCP "Streamable HTTP" protocol + * (spec 2025-03-26). Each outbound JSON-RPC message is delivered with an HTTP + * POST; the server replies either with a single JSON object or with an SSE + * stream that may carry the response together with server notifications. + * + * The `Mcp-Session-Id` returned on initialize is echoed on every subsequent + * request. The legacy two-endpoint HTTP+SSE transport (2024-11-05) is not + * handled here. + */ +export class HttpTransport implements McpTransport { + private handlers: McpTransportHandlers | null = null; + private sessionId: string | null = null; + private closed = false; + private readonly activeRequests = new Set(); + + constructor( + private readonly serverName: string, + private readonly options: HttpTransportOptions + ) {} + + async start(handlers: McpTransportHandlers): Promise { + this.handlers = handlers; + this.closed = false; + this.sessionId = null; + // Streamable HTTP is connectionless until the first message is sent, so + // there is nothing to open here. + } + + send(message: object): void { + void this.post(message); + } + + close(): void { + this.closed = true; + for (const controller of this.activeRequests) { + try { + controller.abort(); + } catch { + // ignore abort errors + } + } + this.activeRequests.clear(); + } + + isConnected(): boolean { + return !this.closed; + } + + decorateError(message: string): Error { + return new Error(message); + } + + private async post(message: object): Promise { + if (this.closed) return; + + const controller = new AbortController(); + this.activeRequests.add(controller); + try { + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...(this.options.headers ?? {}), + }; + if (this.sessionId) { + headers["mcp-session-id"] = this.sessionId; + } + + const response = await fetch(this.options.url, { + method: "POST", + headers, + body: JSON.stringify(message), + signal: controller.signal, + }); + + const sessionId = response.headers.get("mcp-session-id"); + if (sessionId) { + this.sessionId = sessionId; + } + + // 202 Accepted (or 204) acknowledges a notification/response with no body. + if (response.status === 202 || response.status === 204) { + return; + } + + if (!response.ok) { + const body = await safeReadText(response); + this.reportClose( + `MCP server "${this.serverName}" returned HTTP ${response.status}${body ? `: ${truncate(body)}` : ""}` + ); + return; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream") && response.body) { + await this.consumeEventStream(response.body); + } else { + const text = await safeReadText(response); + if (text) { + this.dispatchPayload(text); + } + } + } catch (err) { + if (this.closed || controller.signal.aborted) { + return; + } + const message = err instanceof Error ? err.message : String(err); + this.reportClose(`MCP server "${this.serverName}" request failed: ${message}`); + } finally { + this.activeRequests.delete(controller); + } + } + + private async consumeEventStream(body: ReadableStream): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); + + let boundary: number; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + const rawEvent = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = readSseData(rawEvent); + if (data) { + this.dispatchPayload(data); + } + } + + if (this.closed) break; + } + } catch { + // Stream aborted or errored; close handling happens elsewhere. + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } + } + + private dispatchPayload(text: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return; + } + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + this.handlers?.onMessage(item as object); + } + } + return; + } + if (parsed && typeof parsed === "object") { + this.handlers?.onMessage(parsed as object); + } + } + + private reportClose(reason: string): void { + if (this.closed) return; + this.closed = true; + this.handlers?.onClose(reason); + } +} + +function readSseData(rawEvent: string): string { + const dataLines: string[] = []; + for (const line of rawEvent.split("\n")) { + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).replace(/^ /, "")); + } + // event:, id:, retry: and comment (":") lines are ignored. + } + return dataLines.join("\n"); +} + +async function safeReadText(response: Response): Promise { + try { + return (await response.text()).trim(); + } catch { + return ""; + } +} + +function truncate(text: string, max = 500): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} diff --git a/packages/core/src/mcp/mcp-manager.ts b/packages/core/src/mcp/mcp-manager.ts index 6d2edc63..9e43d053 100644 --- a/packages/core/src/mcp/mcp-manager.ts +++ b/packages/core/src/mcp/mcp-manager.ts @@ -1,5 +1,11 @@ import { createHash } from "crypto"; -import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; +import type { McpClient } from "./mcp-client"; +import { + createMcpClient, + type McpToolDefinition, + type McpPromptDefinition, + type McpResourceDefinition, +} from "./mcp-client"; import type { McpServerConfig } from "../settings"; const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT @@ -154,11 +160,9 @@ export class McpManager { let client: McpClient | null = null; try { - client = new McpClient( + client = createMcpClient( name, - config.command, - config.args ?? [], - config.env, + config, (method) => { if (method === "notifications/tools/list_changed") { this.refreshServerTools(name, client!).catch(() => {}); diff --git a/packages/core/src/mcp/mcp-stdio-transport.ts b/packages/core/src/mcp/mcp-stdio-transport.ts new file mode 100644 index 00000000..b5d3cad2 --- /dev/null +++ b/packages/core/src/mcp/mcp-stdio-transport.ts @@ -0,0 +1,194 @@ +import { spawn, type ChildProcess } from "child_process"; +import { createInterface, type Interface } from "readline"; +import * as path from "path"; +import { killProcessTree } from "../common/process-tree"; +import type { McpTransport, McpTransportHandlers } from "./mcp-transport"; + +export type McpSpawnSpec = { + command: string; + args: string[]; + shell: boolean; + windowsHide?: boolean; +}; + +const STDERR_BUFFER_LIMIT = 4000; + +/** + * Stdio transport: launches the MCP server as a child process and exchanges + * newline-delimited JSON-RPC messages over stdin/stdout. Captured stderr is + * attached to errors for easier debugging. + */ +export class StdioTransport implements McpTransport { + private process: ChildProcess | null = null; + private reader: Interface | null = null; + private stderrBuffer = ""; + private handlers: McpTransportHandlers | null = null; + private intentionallyClosed = false; + private closeReported = false; + + constructor( + private readonly serverName: string, + private readonly command: string, + private readonly args: string[] = [], + private readonly env?: Record + ) {} + + async start(handlers: McpTransportHandlers): Promise { + this.handlers = handlers; + this.intentionallyClosed = false; + this.closeReported = false; + + const childEnv = { + ...process.env, + ...this.env, + }; + const args = withNpxYesArg(this.command, this.args); + const spawnSpec = createMcpSpawnSpec(this.command, args); + + this.process = spawn(spawnSpec.command, spawnSpec.args, { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + shell: spawnSpec.shell, + windowsHide: spawnSpec.windowsHide, + }); + + this.process.on("error", (err) => { + this.reportClose(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`); + }); + + this.process.on("close", (code) => { + this.reportClose(`MCP server "${this.serverName}" exited with code ${code}`); + }); + + if (this.process.stderr) { + this.process.stderr.on("data", (data: Buffer) => { + this.appendStderr(data.toString("utf8")); + }); + } + + this.reader = createInterface({ input: this.process.stdout! }); + this.reader.on("line", (line: string) => { + this.handleLine(line); + }); + } + + send(message: object): void { + if (this.process?.stdin) { + this.process.stdin.write(`${JSON.stringify(message)}\n`); + } + } + + close(): void { + this.intentionallyClosed = true; + if (this.reader) { + this.reader.close(); + this.reader = null; + } + if (this.process) { + if (typeof this.process.pid === "number") { + killProcessTree(this.process.pid, "SIGTERM", { killGroupOnNonWindows: false }); + } else { + this.process.kill(); + } + this.process = null; + } + } + + isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + + decorateError(message: string): Error { + const stderr = this.stderrBuffer.trim(); + return new Error(stderr ? `${message}. stderr: ${stderr}` : message); + } + + private reportClose(reason: string): void { + if (this.closeReported) return; + this.closeReported = true; + this.reader?.close(); + this.reader = null; + this.process = null; + if (!this.intentionallyClosed) { + this.handlers?.onClose(reason); + } + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Ignore unparseable lines (e.g. server debug noise on stdout) + return; + } + + // Per MCP 2025-03-26 §4.1.1.3 a payload may be a JSON-RPC batch. + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item === "object") { + this.handlers?.onMessage(item as object); + } + } + return; + } + + if (parsed && typeof parsed === "object") { + this.handlers?.onMessage(parsed as object); + } + } + + private appendStderr(text: string): void { + this.stderrBuffer = `${this.stderrBuffer}${text}`; + if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) { + this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT); + } + } +} + +function withNpxYesArg(command: string, args: string[]): string[] { + const executable = path + .basename(command) + .toLowerCase() + .replace(/\.cmd$/, ""); + if (executable !== "npx") { + return args; + } + if (args.includes("-y") || args.includes("--yes")) { + return args; + } + return ["-y", ...args]; +} + +export function createMcpSpawnSpec( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform +): McpSpawnSpec { + if (platform === "win32") { + return { + // On Windows, shell: true lets cmd.exe resolve the command via PATHEXT + // (npx -> npx.cmd, etc.). Join command and args into a single string + // with empty spawn args to avoid Node 24 DEP0190. + // Only quote arguments that need protection from cmd.exe to prevent + // double-wrapping by Node.js's own shell quoting. + command: [command, ...args].map(quoteWindowsArgIfNeeded).join(" "), + args: [], + shell: true, + windowsHide: true, + }; + } + + return { + command, + args, + shell: false, + }; +} + +function quoteWindowsArgIfNeeded(arg: string): string { + if (/[\s"&|<>^()]/.test(arg)) { + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`; + } + return arg; +} diff --git a/packages/core/src/mcp/mcp-transport.ts b/packages/core/src/mcp/mcp-transport.ts new file mode 100644 index 00000000..4fa4e499 --- /dev/null +++ b/packages/core/src/mcp/mcp-transport.ts @@ -0,0 +1,28 @@ +/** + * Transport abstraction for the MCP JSON-RPC layer. + * + * `McpClient` owns the protocol (request/response correlation, handshake, + * notifications) and is transport-agnostic. A transport is responsible only + * for moving already-encoded JSON-RPC messages to and from a server and for + * delivering inbound messages back to the client as parsed objects. + */ + +export type McpTransportHandlers = { + /** Called once per inbound JSON-RPC message (response or server notification). */ + onMessage: (message: object) => void; + /** Called when the channel closes unexpectedly (process exit, network error). */ + onClose: (reason: string) => void; +}; + +export interface McpTransport { + /** Establish the channel and start delivering inbound messages. */ + start(handlers: McpTransportHandlers): Promise; + /** Send a single JSON-RPC request or notification object. */ + send(message: object): void; + /** Tear the channel down. Must not trigger `onClose`. */ + close(): void; + /** Whether the channel is currently usable. */ + isConnected(): boolean; + /** Wrap an error message with transport-specific context (e.g. captured stderr). */ + decorateError(message: string): Error; +} diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..7d07233c 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -17,9 +17,18 @@ export type DeepcodingEnv = Record & { export type ReasoningEffort = "high" | "max"; export type McpServerConfig = { - command: string; + /** + * Transport kind. Defaults to "stdio" when a `command` is given and "http" + * when a `url` is given, so it is usually optional. + */ + type?: "stdio" | "http"; + // stdio transport (local subprocess) + command?: string; args?: string[]; env?: Record; + // remote transport (Streamable HTTP) + url?: string; + headers?: Record; }; export type PermissionScope = @@ -433,6 +442,24 @@ function mergeMcpServers( for (const name of serverNames) { const userConfig = userServers[name]; const projectConfig = projectServers[name]; + + // Remote (Streamable HTTP) server: selected by a url or an explicit + // type: "http". Project settings override user settings; headers merge. + const url = trimString(projectConfig?.url) || trimString(userConfig?.url); + const type = projectConfig?.type ?? userConfig?.type; + if (url && type !== "stdio") { + const headers = { + ...(userConfig?.headers ?? {}), + ...(projectConfig?.headers ?? {}), + }; + const config: McpServerConfig = { type: "http", url }; + if (Object.keys(headers).length > 0) { + config.headers = headers; + } + merged[name] = config; + continue; + } + const command = projectConfig?.command ?? userConfig?.command; if (!command) { continue; diff --git a/packages/core/src/tests/mcp-client.test.ts b/packages/core/src/tests/mcp-client.test.ts index 6a7dc016..a4587c60 100644 --- a/packages/core/src/tests/mcp-client.test.ts +++ b/packages/core/src/tests/mcp-client.test.ts @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { McpClient, createMcpSpawnSpec } from "../mcp/mcp-client"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { createMcpClient, createMcpSpawnSpec } from "../mcp/mcp-client"; test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => { assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), { @@ -93,7 +95,7 @@ test("McpClient starts a PATH-resolved cmd MCP server on Windows", { skip: proce ); process.env.PATH = `${serverDir}${path.delimiter}${originalPath ?? ""}`; - const client = new McpClient("probe", "mcp-probe", []); + const client = createMcpClient("probe", { command: "mcp-probe", args: [] }); try { await client.connect(5_000); @@ -108,3 +110,81 @@ test("McpClient starts a PATH-resolved cmd MCP server on Windows", { skip: proce rmSync(serverDir, { recursive: true, force: true }); } }); + +test("McpClient connects to a Streamable HTTP server and echoes the session id", async () => { + const SESSION_ID = "sess-123"; + const seenSessionIds: Array = []; + + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const message = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const sessionHeader = req.headers["mcp-session-id"]; + seenSessionIds.push(Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader); + + // Notifications (no id) are acknowledged with 202 and no body. + if (message.id === undefined) { + res.writeHead(202).end(); + return; + } + + if (message.method === "initialize") { + // Single JSON response branch + session assignment via header. + res.writeHead(200, { + "content-type": "application/json", + "mcp-session-id": SESSION_ID, + }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-03-26", + capabilities: {}, + serverInfo: { name: "remote", version: "1.0.0" }, + }, + }) + ); + return; + } + + if (message.method === "tools/list") { + // SSE response branch: the JSON-RPC response is delivered as one event. + res.writeHead(200, { "content-type": "text/event-stream" }); + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: { tools: [{ name: "remote_tool", inputSchema: { type: "object", properties: {} } }] }, + }); + res.end(`event: message\ndata: ${payload}\n\n`); + return; + } + + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const client = createMcpClient("remote", { type: "http", url: `http://127.0.0.1:${port}/mcp` }); + + try { + await client.connect(5_000); + const tools = await client.listTools(5_000); + assert.deepEqual( + tools.map((tool) => tool.name), + ["remote_tool"] + ); + // initialize carries no session id; every request after the handshake must echo it. + assert.equal(seenSessionIds[0], undefined); + assert.ok( + seenSessionIds.slice(1).every((id) => id === SESSION_ID), + `expected later requests to carry session id, saw ${JSON.stringify(seenSessionIds)}` + ); + } finally { + client.disconnect(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index ceddc43e..6cafba3e 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -303,6 +303,40 @@ test("resolveSettingsSources merges MCP env with documented priority", () => { }); }); +test("resolveSettingsSources resolves remote MCP servers with merged headers", () => { + const resolved = resolveSettingsSources( + { + mcpServers: { + remote: { + url: "https://user.example/mcp", + headers: { Authorization: "Bearer user", "X-User": "1" }, + }, + }, + }, + { + mcpServers: { + remote: { + url: "https://project.example/mcp", + headers: { Authorization: "Bearer project" }, + }, + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + {} + ); + + const remote = resolved.mcpServers?.remote; + assert.equal(remote?.type, "http"); + // Project url and header win; user-only header is preserved. + assert.equal(remote?.url, "https://project.example/mcp"); + assert.deepEqual(remote?.headers, { Authorization: "Bearer project", "X-User": "1" }); + // Remote servers carry no stdio fields. + assert.equal(remote?.command, undefined); +}); + test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { const resolved = resolveSettings( { diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md index 9a4e27e0..3bb24345 100644 --- a/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md @@ -120,7 +120,7 @@ Deep Code自带免费的、且大部分情况够用的Web Search工具。如果 ### 如何配置 MCP? -Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,既支持本地 stdio 服务器,也支持远程 Streamable HTTP 服务器;启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 详细配置指南:[docs/mcp.md](docs/mcp.md) diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md index 73034a38..5d3b2931 100644 --- a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md @@ -43,9 +43,38 @@ MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`, | 字段 | 类型 | 必填 | 说明 | | --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `command` | string | 否 | 本地 stdio 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | | `args` | string[] | 否 | 传递给命令的参数列表 | | `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | +| `type` | string | 否 | 传输类型:`stdio`(本地子进程,默认)或 `http`(远程 Streamable HTTP)。给出 `url` 时默认为 `http`。 | +| `url` | string | 否 | 远程 MCP 服务器的 HTTP(S) 端点(Streamable HTTP)。设置后即为远程服务器,无需 `command`。 | +| `headers` | object | 否 | 远程请求附带的 HTTP 头(如 `Authorization`),常用于鉴权。 | + +> 本地服务器用 `command`/`args`/`env`,远程服务器用 `url`/`headers`,二者择一。 + +## 远程 MCP 服务器(Streamable HTTP) + +除了本地 stdio 服务器,Deep Code 也支持通过 **Streamable HTTP**(MCP 2025-03-26)连接远程 MCP 服务器。只要在配置里给出 `url`(或显式设置 `type: "http"`)即可: + +```json +{ + "mcpServers": { + "remote-service": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +- `url`:远程服务器的 HTTP(S) 端点。 +- `headers`:可选,附加到每个请求的 HTTP 头,常用于鉴权(如 `Authorization`)。 +- 初始化时服务器返回的 `Mcp-Session-Id` 会被自动记录,并在后续请求中回传。 + +> 当前仅支持 Streamable HTTP;尚不支持旧版 HTTP+SSE 双端点传输与 OAuth 授权流程。 ## 常用 MCP 示例 diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md index 7933db6a..69dbbfd5 100644 --- a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md @@ -43,9 +43,38 @@ Edit `~/.deepcode/settings.json` and add the `mcpServers` field: | Field | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `command` | string | No | Executable path or command for a local stdio server (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | | `args` | string[] | No | List of arguments to pass to the command | | `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | +| `type` | string | No | Transport: `stdio` (local subprocess, default) or `http` (remote Streamable HTTP). Defaults to `http` when a `url` is given. | +| `url` | string | No | HTTP(S) endpoint of a remote MCP server (Streamable HTTP). When set, the server is remote and no `command` is needed. | +| `headers` | object | No | HTTP headers (e.g., `Authorization`) sent with each remote request, typically for authentication. | + +> Use `command`/`args`/`env` for local servers, or `url`/`headers` for remote servers — one or the other. + +## Remote MCP Servers (Streamable HTTP) + +In addition to local stdio servers, Deep Code can connect to remote MCP servers over **Streamable HTTP** (MCP 2025-03-26). Just provide a `url` (or set `type: "http"` explicitly): + +```json +{ + "mcpServers": { + "remote-service": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +- `url`: the remote server's HTTP(S) endpoint. +- `headers`: optional HTTP headers attached to every request, typically for authentication (e.g., `Authorization`). +- The `Mcp-Session-Id` returned by the server on initialize is captured automatically and echoed on subsequent requests. + +> Only Streamable HTTP is supported for now; the legacy two-endpoint HTTP+SSE transport and OAuth authorization flows are not yet handled. ## Common MCP Examples