From 968bd1a120b4c1ab70e9cb252e5533a2ca311760 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 26 May 2026 15:30:51 -0500 Subject: [PATCH 1/2] fix(opencode): disconnect dynamically added mcp servers --- packages/opencode/src/mcp/index.ts | 12 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 + packages/sdk/js/package.json | 3 +- .../sdk/js/script/mcp-disconnect-proof.ts | 211 ++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/js/script/mcp-disconnect-proof.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index efd72c0c1f71..262aec340156 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -234,6 +234,7 @@ interface AuthResult { // --- Effect Service --- interface State { + config: Record status: Record clients: Record defs: Record @@ -525,6 +526,7 @@ export const layer = Layer.effect( const bridge = yield* EffectBridge.make() const config = cfg.mcp ?? {} const s: State = { + config: {}, status: {}, clients: {}, defs: {}, @@ -619,6 +621,10 @@ export const layer = Layer.effect( result[key] = s.status[key] ?? { status: "disabled" } } + for (const key of Object.keys(s.config)) { + result[key] = s.status[key] ?? { status: "disabled" } + } + return result }) @@ -642,8 +648,9 @@ export const layer = Layer.effect( }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) { - yield* createAndStore(name, mcp) const s = yield* InstanceState.get(state) + s.config[name] = mcp + yield* createAndStore(name, mcp) return { status: s.status } }) @@ -756,6 +763,9 @@ export const layer = Layer.effect( }) const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { + const s = yield* InstanceState.get(state) + if (s.config[mcpName]) return s.config[mcpName] + const cfg = yield* cfgSvc.get() const mcpConfig = cfg.mcp?.[mcpName] if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 9985ced9f608..2cd5d2abba08 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -109,6 +109,10 @@ describe("mcp HttpApi", () => { expect(added.status).toBe(200) expect(yield* json(added)).toMatchObject({ added: { status: "disabled" } }) + const addedDisconnected = yield* request(handler, "/mcp/added/disconnect", tmp.directory, { method: "POST" }) + expect(addedDisconnected.status).toBe(200) + expect(yield* json(addedDisconnected)).toBe(true) + const connected = yield* request(handler, "/mcp/demo/connect", tmp.directory, { method: "POST" }) expect(connected.status).toBe(200) expect(yield* json(connected)).toBe(true) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f4080187cea5..ef269e561506 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -6,7 +6,8 @@ "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", - "build": "bun ./script/build.ts" + "build": "bun ./script/build.ts", + "prove:mcp-disconnect": "bun ./script/mcp-disconnect-proof.ts" }, "exports": { ".": "./src/index.ts", diff --git a/packages/sdk/js/script/mcp-disconnect-proof.ts b/packages/sdk/js/script/mcp-disconnect-proof.ts new file mode 100644 index 000000000000..0f4ce7a1991b --- /dev/null +++ b/packages/sdk/js/script/mcp-disconnect-proof.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env bun + +import { existsSync } from "node:fs" +import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { createOpencode } from "@opencode-ai/sdk/v2" + +const serverName = "sdk-disconnect-proof" +const repoRoot = path.resolve(fileURLToPath(new URL("../../../../", import.meta.url))) +const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-sdk-mcp-")) +const previousPath = process.env.PATH +const previousRepoRoot = process.env.OPENCODE_REPO_ROOT + +try { + const binDir = path.join(tmp, "bin") + const projectDir = path.join(tmp, "project") + const lifecyclePath = path.join(tmp, "mcp-lifecycle.jsonl") + const mcpServerPath = path.join(tmp, "proof-mcp-server.js") + + await mkdir(binDir) + await mkdir(projectDir) + await Bun.write( + path.join(binDir, "opencode"), + '#!/usr/bin/env sh\nexec bun --conditions=browser "$OPENCODE_REPO_ROOT/packages/opencode/src/index.ts" "$@"\n', + ) + await chmod(path.join(binDir, "opencode"), 0o755) + await Bun.write(mcpServerPath, proofMcpServerSource()) + + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + process.env.OPENCODE_REPO_ROOT = repoRoot + + const opencode = await createOpencode({ + port: 0, + timeout: 30_000, + config: { + logLevel: "ERROR", + formatter: false, + lsp: false, + }, + }) + + try { + console.log(`started opencode server: ${opencode.server.url}`) + + const added = await opencode.client.mcp.add( + { + directory: projectDir, + name: serverName, + config: { + type: "local", + command: ["bun", mcpServerPath], + environment: { + MCP_LIFECYCLE_LOG: lifecyclePath, + }, + timeout: 5_000, + }, + }, + { throwOnError: true }, + ) + requireStatus(added.data[serverName], "connected", "add response") + console.log(`registered MCP server: ${serverName}`) + + const afterAdd = await opencode.client.mcp.status({ directory: projectDir }, { throwOnError: true }) + requireStatus(afterAdd.data[serverName], "connected", "status after add") + console.log(`status after add: ${afterAdd.data[serverName]?.status}`) + + const disconnected = await opencode.client.mcp.disconnect( + { + directory: projectDir, + name: serverName, + }, + { throwOnError: true }, + ) + if (disconnected.data !== true) throw new Error(`Expected disconnect to return true, got ${disconnected.data}`) + + const afterDisconnect = await opencode.client.mcp.status({ directory: projectDir }, { throwOnError: true }) + requireStatus(afterDisconnect.data[serverName], "disabled", "status after disconnect") + console.log(`status after disconnect: ${afterDisconnect.data[serverName]?.status}`) + + const lifecycle = await waitForLifecycle(lifecyclePath, (events) => events.some((event) => event.event === "exit")) + const eventNames = lifecycle.map((event) => event.event) + for (const event of ["started", "request:initialize", "request:tools/list", "stdin-end", "exit"]) { + if (!eventNames.includes(event)) + throw new Error(`Expected lifecycle event ${event}; saw ${eventNames.join(", ")}`) + } + + console.log(`MCP lifecycle proof: ${eventNames.join(" -> ")}`) + console.log("disconnect proof passed") + } finally { + opencode.server.close() + } +} finally { + if (previousPath === undefined) delete process.env.PATH + if (previousPath !== undefined) process.env.PATH = previousPath + if (previousRepoRoot === undefined) delete process.env.OPENCODE_REPO_ROOT + if (previousRepoRoot !== undefined) process.env.OPENCODE_REPO_ROOT = previousRepoRoot + await rm(tmp, { recursive: true, force: true }) +} + +function requireStatus(status: { status: string } | undefined, expected: string, label: string) { + if (status?.status === expected) return + throw new Error(`Expected ${label} to be ${expected}, got ${JSON.stringify(status)}`) +} + +async function waitForLifecycle(file: string, predicate: (events: Array<{ event: string; pid: number }>) => boolean) { + const start = Date.now() + while (Date.now() - start < 5_000) { + const events = await readLifecycle(file) + if (predicate(events)) return events + await Bun.sleep(50) + } + throw new Error(`Timed out waiting for MCP lifecycle proof. Saw: ${JSON.stringify(await readLifecycle(file))}`) +} + +async function readLifecycle(file: string) { + if (!existsSync(file)) return [] + return (await readFile(file, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { event: string; pid: number }) +} + +function proofMcpServerSource() { + return String.raw`import fs from "node:fs" + +const lifecyclePath = process.env.MCP_LIFECYCLE_LOG +let input = "" +let closing = false + +function write(event) { + if (!lifecyclePath) return + fs.appendFileSync(lifecyclePath, JSON.stringify({ event, pid: process.pid }) + "\n") +} + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n") +} + +function close(event) { + if (closing) return + closing = true + write(event) + process.exit(0) +} + +function handle(message) { + write("request:" + (message.method ?? "unknown")) + if (!("id" in message)) return + + if (message.method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? "2025-11-25", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "opencode-sdk-disconnect-proof", version: "1.0.0" }, + }, + }) + return + } + + if (message.method === "tools/list") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "proof_tool", + description: "A no-op tool used by the SDK MCP disconnect proof.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + }, + }) + return + } + + if (message.method === "ping") { + send({ jsonrpc: "2.0", id: message.id, result: {} }) + return + } + + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "Method not found" }, + }) +} + +write("started") +process.stdin.setEncoding("utf8") +process.stdin.on("data", (chunk) => { + input += chunk + while (input.includes("\n")) { + const index = input.indexOf("\n") + const line = input.slice(0, index).trim() + input = input.slice(index + 1) + if (line) handle(JSON.parse(line)) + } +}) +process.stdin.on("end", () => close("stdin-end")) +process.stdin.on("close", () => close("stdin-close")) +process.on("SIGTERM", () => close("sigterm")) +process.on("SIGINT", () => close("sigint")) +process.on("exit", () => write("exit")) +` +} From cf28209e8a35a9a122f7c17eef216d27d5b81277 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 26 May 2026 15:37:12 -0500 Subject: [PATCH 2/2] fix(opencode): use runtime mcp config for tools --- packages/opencode/src/mcp/index.ts | 2 +- packages/sdk/js/package.json | 3 +- .../sdk/js/script/mcp-disconnect-proof.ts | 211 ------------------ 3 files changed, 2 insertions(+), 214 deletions(-) delete mode 100644 packages/sdk/js/script/mcp-disconnect-proof.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 262aec340156..c95394f3381b 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -684,7 +684,7 @@ export const layer = Layer.effect( ([clientName, client]) => Effect.gen(function* () { const mcpConfig = config[clientName] - const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined + const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : s.config[clientName] const listed = s.defs[clientName] if (!listed) { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index ef269e561506..f4080187cea5 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -6,8 +6,7 @@ "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", - "build": "bun ./script/build.ts", - "prove:mcp-disconnect": "bun ./script/mcp-disconnect-proof.ts" + "build": "bun ./script/build.ts" }, "exports": { ".": "./src/index.ts", diff --git a/packages/sdk/js/script/mcp-disconnect-proof.ts b/packages/sdk/js/script/mcp-disconnect-proof.ts deleted file mode 100644 index 0f4ce7a1991b..000000000000 --- a/packages/sdk/js/script/mcp-disconnect-proof.ts +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env bun - -import { existsSync } from "node:fs" -import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { fileURLToPath } from "node:url" -import { createOpencode } from "@opencode-ai/sdk/v2" - -const serverName = "sdk-disconnect-proof" -const repoRoot = path.resolve(fileURLToPath(new URL("../../../../", import.meta.url))) -const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-sdk-mcp-")) -const previousPath = process.env.PATH -const previousRepoRoot = process.env.OPENCODE_REPO_ROOT - -try { - const binDir = path.join(tmp, "bin") - const projectDir = path.join(tmp, "project") - const lifecyclePath = path.join(tmp, "mcp-lifecycle.jsonl") - const mcpServerPath = path.join(tmp, "proof-mcp-server.js") - - await mkdir(binDir) - await mkdir(projectDir) - await Bun.write( - path.join(binDir, "opencode"), - '#!/usr/bin/env sh\nexec bun --conditions=browser "$OPENCODE_REPO_ROOT/packages/opencode/src/index.ts" "$@"\n', - ) - await chmod(path.join(binDir, "opencode"), 0o755) - await Bun.write(mcpServerPath, proofMcpServerSource()) - - process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}` - process.env.OPENCODE_REPO_ROOT = repoRoot - - const opencode = await createOpencode({ - port: 0, - timeout: 30_000, - config: { - logLevel: "ERROR", - formatter: false, - lsp: false, - }, - }) - - try { - console.log(`started opencode server: ${opencode.server.url}`) - - const added = await opencode.client.mcp.add( - { - directory: projectDir, - name: serverName, - config: { - type: "local", - command: ["bun", mcpServerPath], - environment: { - MCP_LIFECYCLE_LOG: lifecyclePath, - }, - timeout: 5_000, - }, - }, - { throwOnError: true }, - ) - requireStatus(added.data[serverName], "connected", "add response") - console.log(`registered MCP server: ${serverName}`) - - const afterAdd = await opencode.client.mcp.status({ directory: projectDir }, { throwOnError: true }) - requireStatus(afterAdd.data[serverName], "connected", "status after add") - console.log(`status after add: ${afterAdd.data[serverName]?.status}`) - - const disconnected = await opencode.client.mcp.disconnect( - { - directory: projectDir, - name: serverName, - }, - { throwOnError: true }, - ) - if (disconnected.data !== true) throw new Error(`Expected disconnect to return true, got ${disconnected.data}`) - - const afterDisconnect = await opencode.client.mcp.status({ directory: projectDir }, { throwOnError: true }) - requireStatus(afterDisconnect.data[serverName], "disabled", "status after disconnect") - console.log(`status after disconnect: ${afterDisconnect.data[serverName]?.status}`) - - const lifecycle = await waitForLifecycle(lifecyclePath, (events) => events.some((event) => event.event === "exit")) - const eventNames = lifecycle.map((event) => event.event) - for (const event of ["started", "request:initialize", "request:tools/list", "stdin-end", "exit"]) { - if (!eventNames.includes(event)) - throw new Error(`Expected lifecycle event ${event}; saw ${eventNames.join(", ")}`) - } - - console.log(`MCP lifecycle proof: ${eventNames.join(" -> ")}`) - console.log("disconnect proof passed") - } finally { - opencode.server.close() - } -} finally { - if (previousPath === undefined) delete process.env.PATH - if (previousPath !== undefined) process.env.PATH = previousPath - if (previousRepoRoot === undefined) delete process.env.OPENCODE_REPO_ROOT - if (previousRepoRoot !== undefined) process.env.OPENCODE_REPO_ROOT = previousRepoRoot - await rm(tmp, { recursive: true, force: true }) -} - -function requireStatus(status: { status: string } | undefined, expected: string, label: string) { - if (status?.status === expected) return - throw new Error(`Expected ${label} to be ${expected}, got ${JSON.stringify(status)}`) -} - -async function waitForLifecycle(file: string, predicate: (events: Array<{ event: string; pid: number }>) => boolean) { - const start = Date.now() - while (Date.now() - start < 5_000) { - const events = await readLifecycle(file) - if (predicate(events)) return events - await Bun.sleep(50) - } - throw new Error(`Timed out waiting for MCP lifecycle proof. Saw: ${JSON.stringify(await readLifecycle(file))}`) -} - -async function readLifecycle(file: string) { - if (!existsSync(file)) return [] - return (await readFile(file, "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as { event: string; pid: number }) -} - -function proofMcpServerSource() { - return String.raw`import fs from "node:fs" - -const lifecyclePath = process.env.MCP_LIFECYCLE_LOG -let input = "" -let closing = false - -function write(event) { - if (!lifecyclePath) return - fs.appendFileSync(lifecyclePath, JSON.stringify({ event, pid: process.pid }) + "\n") -} - -function send(message) { - process.stdout.write(JSON.stringify(message) + "\n") -} - -function close(event) { - if (closing) return - closing = true - write(event) - process.exit(0) -} - -function handle(message) { - write("request:" + (message.method ?? "unknown")) - if (!("id" in message)) return - - if (message.method === "initialize") { - send({ - jsonrpc: "2.0", - id: message.id, - result: { - protocolVersion: message.params?.protocolVersion ?? "2025-11-25", - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "opencode-sdk-disconnect-proof", version: "1.0.0" }, - }, - }) - return - } - - if (message.method === "tools/list") { - send({ - jsonrpc: "2.0", - id: message.id, - result: { - tools: [ - { - name: "proof_tool", - description: "A no-op tool used by the SDK MCP disconnect proof.", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - }, - ], - }, - }) - return - } - - if (message.method === "ping") { - send({ jsonrpc: "2.0", id: message.id, result: {} }) - return - } - - send({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: "Method not found" }, - }) -} - -write("started") -process.stdin.setEncoding("utf8") -process.stdin.on("data", (chunk) => { - input += chunk - while (input.includes("\n")) { - const index = input.indexOf("\n") - const line = input.slice(0, index).trim() - input = input.slice(index + 1) - if (line) handle(JSON.parse(line)) - } -}) -process.stdin.on("end", () => close("stdin-end")) -process.stdin.on("close", () => close("stdin-close")) -process.on("SIGTERM", () => close("sigterm")) -process.on("SIGINT", () => close("sigint")) -process.on("exit", () => write("exit")) -` -}