From e601f9d2fcf349025391bab3783fb34b9b929b10 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:29:59 -0600 Subject: [PATCH] fix(runner): answer each OpenCode MCP tool call on its own connection (#20886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Paperclip runner runs native OpenCode agents. OpenCode calls Paperclip tools through a local MCP bridge in the OpenCode proxy. OpenCode runs on Bun and uses Bun `fetch` for each MCP request. > - In one chat eval, the model made two parallel tool calls. The bridge produced and wrote both results in about 0.55 s. OpenCode received only one of them. > - The other call waited until Bun's default fetch timeout fired, about five minutes later. The chat turn lost five minutes, and the eval missed its deadline. > - The most likely trigger is the reuse of an idle keep-alive socket. We could not reproduce the loss locally, so this pull request is hardening that is based on a hypothesis. > - This pull request makes the bridge answer every request on its own connection. It also records how each tool result left the bridge, so a recurrence can be attributed from the run log. > - The benefit is that a pooled socket can no longer strand a tool result. If a call is stranded again, the run log shows whether the bridge wrote the answer. ## Linked Issues or Issue Description No public issue exists. Related: Refs #18751 (draft native client acceptance diagnostic), Refs #19546 (abandoned queued calls), Refs #20862 (transport event delivery; it does not touch this path). **What happened** In CI run `gha-42925998380` (chat-features, runner-opencode, `cases.create-case`), the CEO chat run used OpenCode 1.18.34 with an OpenRouter DeepSeek model. At 00:01:12.16 the model issued `list_workflows` (MCP id 3) and `list_projects` (MCP id 4) in parallel. The runner emitted the dynamic tool results for id 4 at 00:01:12.468 and for id 3 at 00:01:12.714 (ok, HTTP 200). The bridge writes the HTTP response directly after that event. There is no queue between them. OpenCode completed `list_projects` at 00:01:13.113. It failed `list_workflows` only at 00:06:01.761 with `The operation timed out.` We read the bundled OpenCode source. Its MCP tool call has no timeout that produces that message: the MCP request timeout gives `-32001 Request timed out`, and the configured value is 1 h for durable calls. With the same binary in Bun mode (Bun 1.3.14), a fetch on a reused keep-alive socket that never gets an answer fails after 298 s with `TimeoutError: The operation timed out.` So Bun's default fetch timeout ended the call, and the client never consumed the written answer. The previous call (`list_agents`) answered at about 00:01:06.89, so its socket was idle for about 5.27 s when the parallel pair started. Our thesis is that call 3 reused that pooled socket, and call 4 opened a new one. **Expected behavior** When the bridge has the result of a tool call, the MCP client gets it. The client must not wait for a five-minute transport timeout. **Steps to reproduce** We did not reproduce it locally. This is the only occurrence in the retained evidence. A macOS stress test (Bun 1.3.14 client, Node 26 server with the bridge's default keep-alive, one call, then an idle time of 4.8–7.2 s, then two parallel calls, 8 pairs in parallel) completed 343 trials and about 1,029 requests with 0 lost results. The failing run was on Linux CI with Node 24. ## What Changed - `mcp-bridge.ts`: every bridge response sends `Connection: close`. The MCP client cannot reuse a pooled keep-alive socket for a later request. A loopback connect per request costs much less than the failure mode. - `mcp-bridge.ts`: each `tools/call` records one closed delivery diagnostic. This includes rejected calls (unsupported tool, invalid input, duplicate identity), which record the outcome `error` (`paperclip.opencode.tool-delivery-diagnostic/v1`) when its response closes. The fields are the call id SHA-256, the socket request index, the socket idle ms, the handler ms, the outcome (`result`, `error` or `unanswered`) and `written` (`writableFinished`). It holds no tool names, inputs or results. The limit is 256 records per bridge. - `opencode-proxy-finish-diagnostics.ts`: the proxy forwards these records to the run log as `warning` frames after session binding. It validates the exact key set and value types and drops anything else. - Tests: a new `mcp-bridge-delivery.test.ts` uses a pooling keep-alive client with the incident's call shape (one call, then two parallel calls). New forwarder cases cover the record validation. The existing finish diagnostic assertion now filters by its own schema. ## Verification - Red: with the `Connection: close` line removed, `mcp-bridge-delivery.test.ts` fails with `expected 'keep-alive' to be 'close'`. The pooling client then reuses the socket. - Green: `cd packages/paperclip-runner && pnpm exec vitest run src/drivers/opencode/mcp-bridge-delivery.test.ts src/drivers/opencode/mcp-bridge-diagnostics.test.ts src/drivers/opencode/mcp-bridge.test.ts src/cli/opencode-proxy-finish-diagnostics.test.ts src/cli/opencode-app-server-proxy.test.ts src/cli/opencode-proxy-abandoned-tool-call.test.ts` gives 6 files and 45 tests passed. - The new test asserts that all three results arrive with the correct JSON-RPC ids, that no socket is reused, and that each call records `socketRequestIndex: 1`, `outcome: "result"`, `written: true` without tool names or raw ids. A second test asserts that a client that leaves before its answer records `outcome: "unanswered"`, `written: false`. A third test asserts that a rejected call records `outcome: "error"`, `written: true`. - `tsc -p tsconfig.json --noEmit` for `packages/paperclip-runner` reports no errors in the changed files. Seven existing errors in `codex-runtime-adapter.ts` and `eval/workflow-harness.ts` come from this worktree's linked dependencies. Required CI typecheck is the source of truth. - This does not prove the production fix. A recurrence on CI will show socket reuse, idle time and `written` for the stranded call in the run log. ## Risks - Low risk. Each MCP request now opens a new loopback TCP connection. That adds well under 1 ms per call, and there is no TLS. - The `notifications/cancelled` handling and the abandoned-call abort do not change. The abort still reads `writableFinished` on response close. - One extra run-log warning for each tool call (at most 256 for each bridge). The record holds only a hash, counters and timings. - Previous-release story (upgrade-safe run state): no persisted run, session, launch or pack state changes. A bridge from the previous release keeps its behavior until the proxy restarts. - If the thesis is wrong, the five-minute stall can recur. The new records will then show `written: true` on a fresh socket and point the next investigation at OpenCode or Bun. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../opencode-proxy-finish-diagnostics.test.ts | 20 ++- .../cli/opencode-proxy-finish-diagnostics.ts | 18 +++ .../opencode/mcp-bridge-delivery.test.ts | 139 ++++++++++++++++++ .../opencode/mcp-bridge-diagnostics.test.ts | 4 +- .../src/drivers/opencode/mcp-bridge.ts | 66 ++++++++- 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge-delivery.test.ts diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts index 1ebd37fc4fb..f5631d9521c 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts @@ -91,10 +91,26 @@ it("retains the actual MCP producer's offered schema and rejected shape records" const report={reportedWorkDisposition:"done",summary:"Synthetic complete response",completionClaim:{contractRevision:"1",objectiveSatisfied:true,criteria:[],remainingWork:[]},evidence:[],verification:[]}; const result=await rpc(2,"tools/call",{name:"paperclip_finish",arguments:{...report,continuation:"SYNTHETIC_PRIVATE_INPUT"}}); expect(result.result.isError).toBe(true); - expect(frames).toHaveLength(2); - const [actualOffered,actualRejected]=frames.map(frame=>JSON.parse(frame.params.message)); + const finishFrames=frames.filter(frame=>frame.params.message.includes("finish-input-diagnostic")); + expect(finishFrames).toHaveLength(2); + const [actualOffered,actualRejected]=finishFrames.map(frame=>JSON.parse(frame.params.message)); expect(actualOffered).toEqual({...offered,schemaSha256:createHash("sha256").update(JSON.stringify(schema)).digest("hex")}); expect(actualRejected).toEqual({...actualOffered,stage:"input_rejected",callIdSha256:createHash("sha256").update("2").digest("hex"),validationCategory:"continuation",rootShape:"object",continuationShape:"string"}); expect(JSON.stringify(frames)).not.toContain("SYNTHETIC_PRIVATE_INPUT"); } finally {await bridge.close();} }); + +it("forwards closed tool delivery records after binding and drops any record that could carry tool data", () => { + const frames: unknown[] = [], forward = createOpenCodeFinishDiagnosticForwarder(frame => frames.push(frame)); + const delivery = { schema: "paperclip.opencode.tool-delivery-diagnostic/v1", callIdSha256: "d".repeat(64), + socketRequestIndex: 1, socketIdleMs: null, handlerMs: 512, outcome: "result", written: true }; + forward.diagnostic(JSON.stringify(delivery)); + expect(frames).toEqual([]); + expect(forward.bind("actual-session")).toBe(true); + for (const invalid of [{ ...delivery, result: "private" }, { ...delivery, callIdSha256: "3" }, { ...delivery, outcome: "private" }, { ...delivery, outcome: ["result"] }, + { ...delivery, socketIdleMs: -1 }, { ...delivery, handlerMs: "512" }, { ...delivery, written: "yes" }]) forward.diagnostic(JSON.stringify(invalid)); + expect(frames).toEqual([]); + forward.diagnostic(JSON.stringify({ ...delivery, socketRequestIndex: 2, socketIdleMs: 5270 })); + expect(frames).toEqual([{ method: "warning", params: { threadId: "actual-session", + message: JSON.stringify({ ...delivery, socketRequestIndex: 2, socketIdleMs: 5270 }) } }]); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts index c9790efbc8b..f7d9baa926a 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts @@ -17,6 +17,11 @@ export function createOpenCodeFinishDiagnosticForwarder(send: (frame: Warning) = if (message.length > 2048) return; let value: Record; try { value = JSON.parse(message); } catch { return; } + if (value && typeof value === "object" && !Array.isArray(value) + && value.schema === "paperclip.opencode.tool-delivery-diagnostic/v1") { + if (validToolDelivery(value)) emit(value); + return; + } if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== "paperclip.opencode.finish-input-diagnostic/v1" || typeof value.schemaSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.schemaSha256)) return; @@ -39,3 +44,16 @@ export function createOpenCodeFinishDiagnosticForwarder(send: (frame: Warning) = }, }; } + +const TOOL_DELIVERY_KEYS = "callIdSha256,handlerMs,outcome,schema,socketIdleMs,socketRequestIndex,written"; +// Delivery records carry only hashes, counters and timings. Reject anything +// else so this channel cannot forward tool inputs or results. +function validToolDelivery(value: Record): boolean { + const count = (entry: unknown) => typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0; + return Object.keys(value).sort().join(",") === TOOL_DELIVERY_KEYS + && typeof value.callIdSha256 === "string" && /^[a-f0-9]{64}$/.test(value.callIdSha256) + && count(value.socketRequestIndex) && count(value.handlerMs) + && (value.socketIdleMs === null || count(value.socketIdleMs)) + && typeof value.outcome === "string" && ["result", "error", "unanswered"].includes(value.outcome) + && typeof value.written === "boolean"; +} diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-delivery.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-delivery.test.ts new file mode 100644 index 00000000000..74a7742792c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-delivery.test.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { Agent, request } from "node:http"; +import { afterEach, expect, it } from "vitest"; +import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js"; + +const bridges: OpenCodeMcpBridge[] = []; +const agents: Agent[] = []; +afterEach(async () => { + for (const agent of agents.splice(0)) agent.destroy(); + await Promise.all(bridges.splice(0).map(bridge => bridge.close())); +}); + +type Answer = { status: number; connection: string | undefined; reusedSocket: boolean; body: { id: unknown; result?: { content?: Array<{ text: string }> } } }; + +// A pooling client like Bun's fetch: it reuses an idle keep-alive socket +// whenever the server leaves one open. +function call(bridge: OpenCodeMcpBridge, agent: Agent, id: number, name: string): Promise { + const body = JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: {} } }); + return new Promise((resolve, reject) => { + const outgoing = request(bridge.url, { + method: "POST", agent, + headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, + }, response => { + const chunks: Buffer[] = []; + response.on("data", chunk => chunks.push(chunk)); + response.on("end", () => resolve({ + status: response.statusCode ?? 0, + connection: response.headers.connection, + reusedSocket: outgoing.reusedSocket, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + })); + response.on("error", reject); + }); + outgoing.on("error", reject); + outgoing.end(body); + }); +} + +const tools = [ + { name: "list_agents", inputSchema: { type: "object" } }, + { name: "list_workflows", inputSchema: { type: "object" } }, + { name: "list_projects", inputSchema: { type: "object" } }, +]; + +it("answers every tool call on its own connection so a pooled socket can never strand a parallel result", async () => { + const messages: string[] = []; + const bridge = await startOpenCodeMcpBridge({ + tools, + handler: async ({ tool }) => { + await new Promise(resolve => setTimeout(resolve, tool === "list_workflows" ? 40 : 10)); + return { tool }; + }, + onDiagnostic: message => { if (message.includes("tool-delivery-diagnostic")) messages.push(message); }, + }); + bridges.push(bridge); + const agent = new Agent({ keepAlive: true, maxSockets: 8 }); + agents.push(agent); + + const first = await call(bridge, agent, 2, "list_agents"); + // Let the client finish handling the first response before the parallel + // pair, as OpenCode does while the model writes its next step. + await new Promise(resolve => setTimeout(resolve, 20)); + const [workflows, projects] = await Promise.all([ + call(bridge, agent, 3, "list_workflows"), + call(bridge, agent, 4, "list_projects"), + ]); + + for (const [answer, id, tool] of [[first, 2, "list_agents"], [workflows, 3, "list_workflows"], [projects, 4, "list_projects"]] as const) { + expect(answer.status).toBe(200); + expect(answer.body.id).toBe(id); + expect(JSON.parse(answer.body.result!.content![0]!.text)).toEqual({ tool }); + expect(answer.connection).toBe("close"); + expect(answer.reusedSocket).toBe(false); + } + + await expect.poll(() => messages.length).toBe(3); + const deliveries = messages.map(message => JSON.parse(message)); + expect(deliveries.map(delivery => delivery.callIdSha256).sort()).toEqual( + ["2", "3", "4"].map(id => createHash("sha256").update(id).digest("hex")).sort(), + ); + for (const delivery of deliveries) { + expect(delivery).toEqual({ + schema: "paperclip.opencode.tool-delivery-diagnostic/v1", + callIdSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + socketRequestIndex: 1, + socketIdleMs: null, + handlerMs: expect.any(Number), + outcome: "result", + written: true, + }); + } + expect(JSON.stringify(messages)).not.toMatch(/list_|"tool"/); +}); + +it("records a call whose client left before its result was written", async () => { + const messages: string[] = []; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const handlerStarted = new Promise(resolve => { started = resolve; }); + const bridge = await startOpenCodeMcpBridge({ + tools, + handler: async () => { started(); await held; return { ok: true }; }, + onDiagnostic: message => { if (message.includes("tool-delivery-diagnostic")) messages.push(message); }, + }); + bridges.push(bridge); + const controller = new AbortController(); + const abandoned = fetch(bridge.url, { + method: "POST", signal: controller.signal, + headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "list_agents", arguments: {} } }), + }).catch(() => undefined); + await handlerStarted; + controller.abort(); + await abandoned; + await expect.poll(() => messages.length).toBe(1); + release(); + expect(JSON.parse(messages[0]!)).toMatchObject({ outcome: "unanswered", written: false, socketRequestIndex: 1 }); +}); + +it("records a rejected call as an answered error", async () => { + const messages: string[] = []; + const bridge = await startOpenCodeMcpBridge({ + tools, + handler: async () => ({ ok: true }), + onDiagnostic: message => { if (message.includes("tool-delivery-diagnostic")) messages.push(message); }, + }); + bridges.push(bridge); + const answer = await (await fetch(bridge.url, { + method: "POST", + headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 11, method: "tools/call", params: { name: "not_declared", arguments: {} } }), + })).json() as { result: { isError: boolean } }; + expect(answer.result.isError).toBe(true); + await expect.poll(() => messages.length).toBe(1); + expect(JSON.parse(messages[0]!)).toMatchObject({ + callIdSha256: createHash("sha256").update("11").digest("hex"), outcome: "error", written: true, + }); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts index 59844a49dc7..b08fca1a0ef 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts @@ -44,7 +44,7 @@ it("binds closed rejection shapes to the actual offered finish schema without re }); expect(unrelated.result.isError).toBe(true); expect(handler).toHaveBeenCalledTimes(2); - expect(messages.map(message => JSON.parse(message))).toEqual([ + expect(messages.map(message => JSON.parse(message)).filter(message => message.schema === "paperclip.opencode.finish-input-diagnostic/v1")).toEqual([ { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest }, ...["string", "array", "number", "boolean"].map((continuationShape, index) => ({ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", @@ -63,7 +63,7 @@ it("reports the effective supplied schema and bounds diagnostics without admitti const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } }; const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_finish", inputSchema }], handler, - onDiagnostic: message => messages.push(message), + onDiagnostic: message => { if (message.includes("finish-input-diagnostic")) messages.push(message); }, }); bridges.push(bridge); for (let index = 0; index < 12; index++) { diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index f2428112f2b..2dc6c73cf05 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; import { toolInputError } from "../tool-input-error.js"; @@ -57,6 +58,16 @@ const DEFAULT_MAX_BODY_BYTES = 1_048_576; // Native app results/pages are capped at 500 KB before reaching this bridge. // Leave room for their delivery envelope, but never cut a completed JSON value. const MAX_RESULT_BYTES = 1_048_576; +const TOOL_DELIVERY_DIAGNOSTIC_SCHEMA = "paperclip.opencode.tool-delivery-diagnostic/v1"; +const MAX_TOOL_DELIVERY_DIAGNOSTICS = 256; + +type SocketUse = { requests: number; lastResponseAt: number | null }; +type ToolDelivery = { + socketRequestIndex: number; + socketIdleMs: number | null; + receivedAt: number; +}; +type ToolDeliveryOutcome = "result" | "error" | "unanswered"; export function canonicalOpenCodeMcpToolName(name: string): string { const trimmed = name.trim(); @@ -94,10 +105,41 @@ export async function startOpenCodeMcpBridge( }; try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ } }; + let deliveryDiagnostics = 0; + const diagnoseToolDelivery = ( + callId: string, + delivery: ToolDelivery, + outcome: ToolDeliveryOutcome, + written: boolean, + ) => { + if (!options.onDiagnostic || deliveryDiagnostics++ >= MAX_TOOL_DELIVERY_DIAGNOSTICS) return; + const diagnostic = { + schema: TOOL_DELIVERY_DIAGNOSTIC_SCHEMA, + callIdSha256: createHash("sha256").update(callId).digest("hex"), + socketRequestIndex: delivery.socketRequestIndex, + socketIdleMs: delivery.socketIdleMs, + handlerMs: Date.now() - delivery.receivedAt, + outcome, + written, + }; + try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change delivery. */ } + }; const calls = new Map; waiting: Set }>(); const controllers = new Map(); + const socketUses = new WeakMap(); const server = createServer((request, response) => { + const use = socketUses.get(request.socket) ?? { requests: 0, lastResponseAt: null }; + socketUses.set(request.socket, use); + const receivedAt = Date.now(); + const delivery: ToolDelivery = { + socketRequestIndex: ++use.requests, + socketIdleMs: use.lastResponseAt === null ? null : receivedAt - use.lastResponseAt, + receivedAt, + }; + response.once("finish", () => { use.lastResponseAt = Date.now(); }); void handleRequest(request, response, { + delivery, + diagnoseToolDelivery, secret, tools, admittedTools, @@ -182,6 +224,8 @@ async function handleRequest( request: IncomingMessage, response: ServerResponse, context: { + delivery: ToolDelivery; + diagnoseToolDelivery: (callId: string, delivery: ToolDelivery, outcome: ToolDeliveryOutcome, written: boolean) => void; secret: string; tools: OpenCodeMcpToolDefinition[]; admittedTools: OpenCodeMcpToolDefinition[]; @@ -198,6 +242,11 @@ async function handleRequest( }, ): Promise { setSecurityHeaders(response); + // Never let the MCP client reuse a pooled socket for a later request. A + // native OpenCode run (Bun fetch) once lost a written tools/call result on + // a parallel call and waited about five minutes for Bun's fetch timeout. + // A loopback connect per request costs far less than that failure mode. + response.setHeader("Connection", "close"); if (!authorized(request.headers.authorization, context.secret)) { response.statusCode = 401; response.end(); @@ -265,6 +314,14 @@ async function handleRequest( return; } const params = isRecord(message.params) ? message.params : {}; + const callId = typeof id === "string" || typeof id === "number" ? String(id) : randomBytes(12).toString("hex"); + // Record how each call's answer left the bridge, rejections included, so a + // client that never consumes a written answer can be told apart from a + // bridge that never answered it. + let outcome: ToolDeliveryOutcome = "unanswered"; + response.once("close", () => { + context.diagnoseToolDelivery(callId, context.delivery, outcome, response.writableFinished); + }); const rawName = typeof params.name === "string" ? params.name : ""; // A declared operation may itself start with paperclip_. Preserve that // authority identity before accepting an OpenCode transport prefix alias. @@ -273,10 +330,10 @@ async function handleRequest( ? exactName : canonicalOpenCodeMcpToolName(exactName); if (!context.admittedTools.some((entry) => entry.name === tool)) { + outcome = "error"; writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] }); return; } - const callId = typeof id === "string" || typeof id === "number" ? String(id) : randomBytes(12).toString("hex"); const args = params.arguments ?? {}; const validator = context.validators.get(tool); if (validator === undefined || !validator(args)) { @@ -288,6 +345,7 @@ async function handleRequest( } const error = toolInputError(validator); try { context.onRejectedCall?.({ tool, callId, arguments: args }, error); } catch {} + outcome = "error"; writeRpc(response, id, { isError: true, content: [{ type: "text", text: error }], @@ -298,6 +356,7 @@ async function handleRequest( const existing = context.calls.get(callId); if (existing && existing.fingerprint !== fingerprint) { try { context.onRejectedCall?.({ tool, callId, arguments: args }, "Duplicate call identity conflict."); } catch {} + outcome = "error"; writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] }); return; } @@ -328,8 +387,11 @@ async function handleRequest( } try { const result = await execution; - writeRpc(response, id, { content: [{ type: "text", text: safeJson(result) }] }); + const text = safeJson(result); + outcome = "result"; + writeRpc(response, id, { content: [{ type: "text", text }] }); } catch (error) { + outcome = "error"; writeRpc(response, id, { isError: true, content: [{ type: "text", text: safeError(error) }] }); } }