From 43a47771221b695994803f5b7275cb47bafe7881 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 4 Nov 2026 16:33:51 -0600 Subject: [PATCH 1/2] fix(core): fall back to legacy for stdio MCP servers that ignore the version probe --- packages/core/src/mcp/stdio.ts | 9 ++++++- .../core/test/fixture/mcp-legacy-silent.ts | 22 ++++++++++++++++ packages/core/test/mcp.test.ts | 26 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/fixture/mcp-legacy-silent.ts diff --git a/packages/core/src/mcp/stdio.ts b/packages/core/src/mcp/stdio.ts index 569f78f1aaff..ceb6e3c6bf6d 100644 --- a/packages/core/src/mcp/stdio.ts +++ b/packages/core/src/mcp/stdio.ts @@ -87,7 +87,14 @@ export const make = Effect.fnUntraced(function* (options: Options) { }).pipe(Effect.ensuring(Queue.shutdown(outgoing))), )) - const transport: Transport = { + // `pid` and `stderr` match the SDK's StdioClientTransport accessors. The SDK detects stdio by their + // presence, and only for stdio does it treat an unanswered server/discover probe as a legacy server. + // stderr is drained into the debug log, so no stream is handed out. + const transport: Transport & { readonly pid: number | null; readonly stderr: null } = { + get pid() { + return state.handle?.pid ?? null + }, + stderr: null, start: () => { if (state.phase !== "ready") return Promise.reject(new Error("Stdio transport already started")) state.phase = "starting" diff --git a/packages/core/test/fixture/mcp-legacy-silent.ts b/packages/core/test/fixture/mcp-legacy-silent.ts new file mode 100644 index 000000000000..7c3370d3dd2f --- /dev/null +++ b/packages/core/test/fixture/mcp-legacy-silent.ts @@ -0,0 +1,22 @@ +import { createInterface } from "node:readline" + +// A 2025-11-25 stdio server that never answers requests it doesn't know, such as server/discover. +const send = (message: unknown) => process.stdout.write(JSON.stringify(message) + "\n") + +for await (const line of createInterface({ input: process.stdin })) { + if (!line.trim()) continue + const message = JSON.parse(line) + if (message.id === undefined || message.method === undefined) continue + if (message.method === "initialize") + send({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "legacy-silent", version: "1.0.0" }, + }, + }) + if (message.method === "tools/list") + send({ jsonrpc: "2.0", id: message.id, result: { tools: [{ name: "echo", inputSchema: { type: "object" } }] } }) +} diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2d6044a7f0c4..2db8616dda45 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -984,6 +984,32 @@ test("closes a stdio process that finishes spawning after close", async () => { expect(signals).toEqual(["SIGTERM"]) }) +test("falls back to legacy when a stdio server ignores the version probe", async () => { + const started = performance.now() + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* connect( + "legacy-silent", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-legacy-silent.ts")], + protocol: "auto", + // The probe waits out the startup timeout before falling back. + timeout: new ConfigMCP.Timeout({ startup: 300 }), + }), + import.meta.dir, + ) + return { modern: connection.modern, tools: yield* connection.tools() } + }), + ), + ) + + expect(result.modern).toBe(false) + expect(result.tools.map((tool) => tool.name)).toEqual(["echo"]) + expect(performance.now() - started).toBeLessThan(2_000) +}) + test("applies the configured MCP catalog timeout", async () => { const result = Effect.runPromise( Effect.scoped( From ec2a91e5591717f780f220aca2dc0ded1aa8774c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 4 Nov 2026 16:45:32 -0600 Subject: [PATCH 2/2] test(core): drop wall-clock assertion from probe fallback test --- packages/core/test/mcp.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2db8616dda45..3363b0f57cc1 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -985,7 +985,6 @@ test("closes a stdio process that finishes spawning after close", async () => { }) test("falls back to legacy when a stdio server ignores the version probe", async () => { - const started = performance.now() const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { @@ -995,8 +994,8 @@ test("falls back to legacy when a stdio server ignores the version probe", async type: "local", command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-legacy-silent.ts")], protocol: "auto", - // The probe waits out the startup timeout before falling back. - timeout: new ConfigMCP.Timeout({ startup: 300 }), + // The probe waits out the startup timeout before falling back; Windows needs room to start Bun. + timeout: new ConfigMCP.Timeout({ startup: 1_000 }), }), import.meta.dir, ) @@ -1007,7 +1006,6 @@ test("falls back to legacy when a stdio server ignores the version probe", async expect(result.modern).toBe(false) expect(result.tools.map((tool) => tool.name)).toEqual(["echo"]) - expect(performance.now() - started).toBeLessThan(2_000) }) test("applies the configured MCP catalog timeout", async () => {