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
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
version: 1
delivery: expose-negotiated-protocol
delivery: add-mcp-dual
context:
kind: branch
branch: feat/450-expose-negotiated-protocol
branch: test/452-add-mcp-dual
issues:
- 450
pr: 451
- 452
pr: 453
89 changes: 89 additions & 0 deletions packages/opencode/test/mcp/fixtures/interop-probe.ts
Original file line number Diff line number Diff line change
@@ -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<T>(promise: Promise<T>, message: string): Promise<T> {
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<Uint8Array>): Promise<number> {
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)
29 changes: 29 additions & 0 deletions packages/opencode/test/mcp/fixtures/server-http.ts
Original file line number Diff line number Diff line change
@@ -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}`)
23 changes: 23 additions & 0 deletions packages/opencode/test/mcp/fixtures/server-stdio.ts
Original file line number Diff line number Diff line change
@@ -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),
},
)
71 changes: 71 additions & 0 deletions packages/opencode/test/mcp/interop.test.ts
Original file line number Diff line number Diff line change
@@ -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<Bun.Subprocess<"ignore", "pipe", "inherit">> = []

afterEach(() => {
for (const child of children.splice(0)) child.kill()
})

function withTimeout<T>(promise: Promise<T>, message: string, ms = PROBE_TIMEOUT_MS): Promise<T> {
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")
})
})
Loading