From c4667771e75d4088b6066b52406cd416989cedbf Mon Sep 17 00:00:00 2001 From: syf2211 Date: Tue, 25 Aug 2026 00:10:14 +0000 Subject: [PATCH] fix(mcp): answer server-initiated roots/list requests The MCP client advertised roots capability during initialize but dropped inbound JSON-RPC requests in _flush and on SSE streams. Handle roots/list with the current workspace as a file:// root and return -32601 for other server methods. Fixes #59 --- src/mcp/client.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++ src/mcp/client.ts | 70 +++++++++++++++++++++++++++++++---- 2 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 src/mcp/client.test.ts diff --git a/src/mcp/client.test.ts b/src/mcp/client.test.ts new file mode 100644 index 0000000..9f9e40c --- /dev/null +++ b/src/mcp/client.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { MCPServerClient, mcpRootsListResult } from "./client.js"; + +describe("mcpRootsListResult", () => { + test("returns a file:// URI for the workspace root", () => { + const result = mcpRootsListResult("/workspace/demo"); + expect(result.roots).toHaveLength(1); + expect(result.roots[0]!.uri).toBe("file:///workspace/demo"); + expect(result.roots[0]!.name).toBe("demo"); + }); +}); + +describe("MCPServerClient server-initiated requests", () => { + test("_flush answers roots/list instead of dropping the request", () => { + const written: string[] = []; + const client = new MCPServerClient("test", { command: "true" }); + const priv = client as unknown as { + _write: (msg: unknown) => void; + _buffer: string; + _flush: () => void; + }; + priv._write = (msg: unknown) => written.push(JSON.stringify(msg)); + + priv._buffer = `${JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "roots/list", + })}\n`; + priv._flush(); + + expect(written).toHaveLength(1); + const resp = JSON.parse(written[0]!) as { id: number; result: ReturnType }; + expect(resp.id).toBe(7); + expect(resp.result.roots[0]!.uri).toMatch(/^file:\/\//); + }); + + test("_flush still resolves pending client responses", () => { + const client = new MCPServerClient("test", { command: "true" }); + const priv = client as unknown as { + _pending: Map void; reject: (e: Error) => void; timer: ReturnType }>; + _buffer: string; + _flush: () => void; + }; + + let resolved: unknown; + priv._pending.set(3, { + resolve: (v) => { resolved = v; }, + reject: () => { throw new Error("unexpected reject"); }, + timer: setTimeout(() => {}, 60_000), + }); + + priv._buffer = `${JSON.stringify({ + jsonrpc: "2.0", + id: 3, + result: { tools: [] }, + })}\n`; + priv._flush(); + + expect(resolved).toEqual({ tools: [] }); + expect(priv._pending.has(3)).toBe(false); + }); + + test("_flush returns method-not-found for unknown server requests", () => { + const written: string[] = []; + const client = new MCPServerClient("test", { command: "true" }); + const priv = client as unknown as { + _write: (msg: unknown) => void; + _buffer: string; + _flush: () => void; + }; + priv._write = (msg: unknown) => written.push(JSON.stringify(msg)); + + priv._buffer = `${JSON.stringify({ + jsonrpc: "2.0", + id: 9, + method: "sampling/createMessage", + })}\n`; + priv._flush(); + + const resp = JSON.parse(written[0]!) as { id: number; error: { code: number } }; + expect(resp.id).toBe(9); + expect(resp.error.code).toBe(-32601); + }); +}); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 02d19b6..0b78e32 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -36,6 +36,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { join } from "node:path"; import { homedir } from "node:os"; +import { pathToFileURL } from "node:url"; import type { ToolDefinition } from "../api/client.js"; import { storedMcpToken, refreshMcpToken, authorizeMcpServer } from "./oauth.js"; import { @@ -121,6 +122,19 @@ interface JsonRpcResponse { error?: { code: number; message: string; data?: unknown }; } +/** Result shape for MCP ``roots/list`` responses. */ +export function mcpRootsListResult(projectRoot: string = process.cwd()): { + roots: { uri: string; name?: string }[]; +} { + const uri = pathToFileURL(projectRoot).href; + const name = projectRoot.split(/[/\\]/).filter(Boolean).pop(); + return { roots: [{ uri, ...(name ? { name } : {}) }] }; +} + +function isJsonRpcRequest(msg: JsonRpcRequest | JsonRpcResponse): msg is JsonRpcRequest { + return typeof (msg as JsonRpcRequest).method === "string"; +} + interface MCPToolSchema { type: "object"; properties?: Record; @@ -391,7 +405,8 @@ export class MCPServerClient { /** * Read an SSE body until the JSON-RPC response matching `id` arrives. - * Server-initiated requests/notifications on the stream are ignored. + * Server-initiated requests on the stream are answered inline; unrelated + * responses are ignored until the matching id is seen. */ private async _readSSEResponse(res: Response, id: number, method: string): Promise { if (!res.body) throw new Error(`empty SSE body (${method})`); @@ -407,11 +422,16 @@ export class MCPServerClient { .map(l => l.slice(5).trimStart()) .join("\n"); if (!data) return undefined; - let msg: JsonRpcResponse; - try { msg = JSON.parse(data) as JsonRpcResponse; } catch { return undefined; } - if (msg.id !== id) return undefined; // server-initiated message — ignore - if (msg.error) throw new Error(`[${msg.error.code}] ${msg.error.message}`); - return { result: msg.result }; + let msg: JsonRpcRequest | JsonRpcResponse; + try { msg = JSON.parse(data) as JsonRpcRequest | JsonRpcResponse; } catch { return undefined; } + if (isJsonRpcRequest(msg) && typeof msg.id === "number") { + this._handleServerRequest(msg); + return undefined; + } + const resp = msg as JsonRpcResponse; + if (resp.id !== id) return undefined; // unrelated response — keep reading + if (resp.error) throw new Error(`[${resp.error.code}] ${resp.error.message}`); + return { result: resp.result }; }; try { @@ -456,8 +476,12 @@ export class MCPServerClient { const t = raw.trim(); if (!t) continue; try { - const msg = JSON.parse(t) as JsonRpcResponse; + const msg = JSON.parse(t) as JsonRpcRequest | JsonRpcResponse; if (typeof msg.id !== "number") continue; // ignore notifications from server + if (isJsonRpcRequest(msg)) { + this._handleServerRequest(msg); + continue; + } const p = this._pending.get(msg.id); if (!p) continue; this._pending.delete(msg.id); @@ -471,6 +495,38 @@ export class MCPServerClient { } } + private _handleServerRequest(req: JsonRpcRequest): void { + if (typeof req.id !== "number") return; + if (req.method === "roots/list") { + this._respondToServer(req.id, mcpRootsListResult()); + return; + } + this._respondToServer(req.id, undefined, { + code: -32601, + message: `Method not found: ${req.method}`, + }); + } + + private _respondToServer( + id: number, + result?: unknown, + error?: { code: number; message: string; data?: unknown }, + ): void { + const payload = error + ? { jsonrpc: "2.0" as const, id, error } + : { jsonrpc: "2.0" as const, id, result }; + if (this.isRemote) { + void fetch(this._config.url!, { + method: "POST", + headers: this._httpHeaders(), + body: JSON.stringify(payload), + signal: AbortSignal.timeout(15_000), + }).catch(() => { /* best effort */ }); + return; + } + this._write(payload); + } + private _request(method: string, params: unknown): Promise { if (this.isRemote) return this._httpRpc(method, params); return new Promise((resolve, reject) => {