Skip to content
Merged
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
9 changes: 8 additions & 1 deletion packages/core/src/mcp/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/fixture/mcp-legacy-silent.ts
Original file line number Diff line number Diff line change
@@ -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" } }] } })
}
24 changes: 24 additions & 0 deletions packages/core/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading