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..3363b0f57cc1 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -984,6 +984,30 @@ 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 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; Windows needs room to start Bun. + timeout: new ConfigMCP.Timeout({ startup: 1_000 }), + }), + 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"]) +}) + test("applies the configured MCP catalog timeout", async () => { const result = Effect.runPromise( Effect.scoped(