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
84 changes: 84 additions & 0 deletions src/mcp/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mcpRootsListResult> };
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<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>;
_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);
});
});
70 changes: 63 additions & 7 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<unknown> {
if (!res.body) throw new Error(`empty SSE body (${method})`);
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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<unknown> {
if (this.isRemote) return this._httpRpc(method, params);
return new Promise<unknown>((resolve, reject) => {
Expand Down
Loading