fix(runner): answer each OpenCode MCP tool call on its own connection (#20886)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 12:29:59 -06:00
1 parent 8eabb13ebd
commit e601f9d2fc
5 files changed
+241 -6

No files matched your search

@@ -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 }) } }]);
});
@@ -17,6 +17,11 @@ export function createOpenCodeFinishDiagnosticForwarder(send: (frame: Warning) =
if (message.length > 2048) return;
let value: Record<string, unknown>;
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<string, unknown>): 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";
}
@@ -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<Answer> {
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<void>(resolve => { release = resolve; });
let started!: () => void;
const handlerStarted = new Promise<void>(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,
});
});
@@ -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++) {
@@ -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<string, { fingerprint: string; promise: Promise<unknown>; waiting: Set<ServerResponse> }>();
const controllers = new Map<string, AbortController>();
const socketUses = new WeakMap<Socket, SocketUse>();
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<void> {
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) }] });
}
}