From 4a53e15d38e989a884e5f41bd8019bd893d249e9 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 26 Aug 2026 09:50:39 +0800 Subject: [PATCH 1/3] chore: record delivery binding for add-mcp-dual --- .specgit.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 1a76cc0ea..0ac2cefae 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,9 +1,7 @@ version: 1 -delivery: upgrade-mcp-client +delivery: add-mcp-dual context: kind: branch - branch: refactor/447-upgrade-mcp-client + branch: test/452-add-mcp-dual issues: - - 447 - - 448 -pr: 449 + - 452 From a0042d9a807bb36802761bd1cb64b04009ff5ab6 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 26 Aug 2026 09:51:01 +0800 Subject: [PATCH 2/3] chore: record delivery binding for add-mcp-dual --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 0ac2cefae..080a1b4d5 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: test/452-add-mcp-dual issues: - 452 +pr: 453 From 2aa92590e0cc339eb92dd2a5745914e817bc4b7b Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 26 Aug 2026 10:21:23 +0800 Subject: [PATCH 3/3] test(mcp): add dual-era interop regression tests --- .../test/mcp/fixtures/interop-probe.ts | 89 +++++++++++++++++++ .../opencode/test/mcp/fixtures/server-http.ts | 29 ++++++ .../test/mcp/fixtures/server-stdio.ts | 23 +++++ packages/opencode/test/mcp/interop.test.ts | 71 +++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 packages/opencode/test/mcp/fixtures/interop-probe.ts create mode 100644 packages/opencode/test/mcp/fixtures/server-http.ts create mode 100644 packages/opencode/test/mcp/fixtures/server-stdio.ts create mode 100644 packages/opencode/test/mcp/interop.test.ts diff --git a/packages/opencode/test/mcp/fixtures/interop-probe.ts b/packages/opencode/test/mcp/fixtures/interop-probe.ts new file mode 100644 index 000000000..f460e3948 --- /dev/null +++ b/packages/opencode/test/mcp/fixtures/interop-probe.ts @@ -0,0 +1,89 @@ +// Runs the real v2 client against the fixture servers in a fresh process. +// Sibling test files mock.module("@modelcontextprotocol/client") in the shared +// bun test process, so real-transport coverage must not import the SDK there. +import path from "node:path" +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" + +const mode = process.argv[2] ?? "stdio-auto" +const TIMEOUT_MS = 10_000 +let httpServer: Bun.Subprocess<"ignore", "pipe", "inherit"> | undefined + +process.on("SIGTERM", () => { + httpServer?.kill() + process.exit(1) +}) + +function withTimeout(promise: Promise, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), TIMEOUT_MS) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +function firstText(result: { content: Array<{ type: string; text?: string }> }) { + return result.content[0]?.text +} + +async function runStdio(legacy: boolean) { + const client = new Client({ name: "interop-probe", version: "0.0.0" }, { versionNegotiation: { mode: legacy ? "legacy" : "auto" } }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(import.meta.dir, "server-stdio.ts")], + }) + await withTimeout(client.connect(transport), "stdio connect timed out") + try { + const result = await client.callTool({ name: "echo", arguments: { text: legacy ? "legacy" : "hello" } }) + return { era: client.getProtocolEra(), version: client.getNegotiatedProtocolVersion(), echo: firstText(result as { content: Array<{ type: string; text?: string }> }) } + } finally { + await client.close() + } +} + +async function readListeningPort(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = "" + while (true) { + const { done, value } = await reader.read() + if (value) buffer += decoder.decode(value) + const port = Number(buffer.match(/listening (\d+)/)?.[1]) + if (port) { + reader.releaseLock() + return port + } + if (done) throw new Error(`http fixture exited before reporting a port: ${buffer}`) + } +} + +async function runHttp() { + httpServer = Bun.spawn([process.execPath, path.join(import.meta.dir, "server-http.ts")], { + cwd: path.join(import.meta.dir, "../../.."), + stdout: "pipe", + stderr: "inherit", + }) + const port = await withTimeout(readListeningPort(httpServer.stdout), "http fixture did not report a port in time") + const client = new Client({ name: "interop-probe", version: "0.0.0" }, { versionNegotiation: { mode: "auto" } }) + await withTimeout(client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`))), "http connect timed out") + try { + const result = await client.callTool({ name: "echo", arguments: { text: "http" } }) + return { era: client.getProtocolEra(), version: client.getNegotiatedProtocolVersion(), echo: firstText(result as { content: Array<{ type: string; text?: string }> }) } + } finally { + await client.close() + httpServer.kill() + await httpServer.exited + } +} + +const outcome = mode === "http-auto" ? await runHttp() : await runStdio(mode === "stdio-legacy") +console.log(JSON.stringify({ mode, ...outcome })) +process.exit(0) diff --git a/packages/opencode/test/mcp/fixtures/server-http.ts b/packages/opencode/test/mcp/fixtures/server-http.ts new file mode 100644 index 000000000..09aeb752d --- /dev/null +++ b/packages/opencode/test/mcp/fixtures/server-http.ts @@ -0,0 +1,29 @@ +import { z } from "zod" +import { McpServer, createMcpHandler } from "@modelcontextprotocol/server" + +const factory = () => { + const server = new McpServer({ name: "v2-fixture-http", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.registerTool( + "echo", + { + description: "echo the input back", + inputSchema: { text: z.string() }, + }, + async ({ text }) => ({ content: [{ type: "text" as const, text: `echo-http:${text}` }] }), + ) + return server +} + +const handler = createMcpHandler(factory) +const server = Bun.serve({ + port: 0, + fetch: async (req) => { + try { + return await handler.fetch(req) + } catch (e) { + console.error("[fixture]", e) + return new Response(JSON.stringify({ error: String(e) }), { status: 500 }) + } + }, +}) +console.log(`listening ${server.port}`) diff --git a/packages/opencode/test/mcp/fixtures/server-stdio.ts b/packages/opencode/test/mcp/fixtures/server-stdio.ts new file mode 100644 index 000000000..b54938541 --- /dev/null +++ b/packages/opencode/test/mcp/fixtures/server-stdio.ts @@ -0,0 +1,23 @@ +// Dual-era fixture: the SAME factory serves 2026-07-28 (server/discover) and +// legacy (initialize) clients — serveStdio owns the era decision per connection. +import { z } from "zod" +import { McpServer } from "@modelcontextprotocol/server" +import { serveStdio } from "@modelcontextprotocol/server/stdio" + +serveStdio( + () => { + const server = new McpServer({ name: "v2-fixture", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.registerTool( + "echo", + { + description: "echo the input back", + inputSchema: { text: z.string() }, + }, + async ({ text }) => ({ content: [{ type: "text", text: `echo:${text}` }] }), + ) + return server + }, + { + onerror: (e) => console.error("[fixture]", e.message), + }, +) diff --git a/packages/opencode/test/mcp/interop.test.ts b/packages/opencode/test/mcp/interop.test.ts new file mode 100644 index 000000000..85fa527fe --- /dev/null +++ b/packages/opencode/test/mcp/interop.test.ts @@ -0,0 +1,71 @@ +import path from "node:path" +import { afterEach, describe, expect, test } from "bun:test" + +// Local to this branch: the era helper from #450 is not available here. +const MODERN_VERSION = "2026-07-28" +const LEGACY_VERSION = "2025-11-25" +const PROBE_TIMEOUT_MS = 10_000 + +const children: Array> = [] + +afterEach(() => { + for (const child of children.splice(0)) child.kill() +}) + +function withTimeout(promise: Promise, message: string, ms = PROBE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +// The probe drives the real v2 client in a fresh process: sibling files in +// test/mcp mock.module the client SDK in the shared bun test process. +async function runProbe(mode: "stdio-auto" | "stdio-legacy" | "http-auto") { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixtures", "interop-probe.ts"), mode], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "inherit", + }) + children.push(child) + const [code, stdout] = await withTimeout( + Promise.all([child.exited, Bun.readableStreamToText(child.stdout)]), + `interop probe ${mode} timed out`, + ) + return { code, result: JSON.parse(stdout) as { era?: string; version?: string; echo?: string } } +} + +describe("mcp dual-era interop", () => { + test("stdio auto negotiates 2026-07-28 and round-trips a tool call", async () => { + const { code, result } = await runProbe("stdio-auto") + expect(code, JSON.stringify(result)).toBe(0) + expect(result.era).toBe("modern") + expect(result.version).toBe(MODERN_VERSION) + expect(result.echo).toBe("echo:hello") + }) + + test("stdio legacy pin negotiates 2025-11-25 against the same fixture", async () => { + const { code, result } = await runProbe("stdio-legacy") + expect(code, JSON.stringify(result)).toBe(0) + expect(result.era).toBe("legacy") + expect(result.version).toBe(LEGACY_VERSION) + expect(result.echo).toBe("echo:legacy") + }) + + test("streamable http auto negotiates 2026-07-28 and round-trips a tool call", async () => { + const { code, result } = await runProbe("http-auto") + expect(code, JSON.stringify(result)).toBe(0) + expect(result.era).toBe("modern") + expect(result.version).toBe(MODERN_VERSION) + expect(result.echo).toBe("echo-http:http") + }) +})