fix(runner): keep the OpenCode MCP bridge on keep-alive so OpenCode keeps its Paperclip tools (#20921)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native OpenCode agents call Paperclip tools through a local MCP
bridge in the runner's OpenCode proxy
> - #20886 made the bridge send `Connection: close` on every response,
as hardening for one stalled parallel tool call that was never
reproduced
> - After #20886 merged, many native OpenCode runs started without any
Paperclip MCP tools
> - Agents then said the Paperclip tools were missing, worked only with
bash and file tools, and never called paperclip_finish
> - This pull request removes the `Connection: close` header and keeps
the delivery diagnostics from #20886
> - The benefit is that OpenCode agents get their Paperclip tools again,
and a recurrence of the original stall still shows in the run log

## Linked Issues or Issue Description

Refs #20886.

**What happened**
In chat eval runs on CI, every OpenCode session before #20886 sent a
first model request of about 34k–36k input tokens (prompt plus the
Paperclip tool schemas). After #20886, about 9 of 25 sessions sent only
about 8k–15k tokens on the same prompts (for example 14,711 for
questions-and-approvals.necessary-question, 15,481 for
cases.cases-disabled, 14,096 for cases.update-case-facts). The prompt
envelope size did not change (about 32.7k characters). Those sessions
made no Paperclip MCP calls: zero tool-delivery records, no
paperclip_finish. The agents' own reasoning said "The paperclip tools
aren't in my toolset". Sessions on a commit without #20886 (8d2e34eac1)
all had the full size.

**Expected behavior**
Every native OpenCode session starts with the Paperclip MCP tools.

**Steps to reproduce**
1. Run several chat-features OpenCode cells on master after #20886.
2. Compare the first usage record of each run (inputTokens plus
cacheReadTokens) and the tool names in the run events.
3. See sessions with about 15k tokens and no paperclip_* tool calls.

## What Changed

- `mcp-bridge.ts`: removed `response.setHeader("Connection", "close")`.
The bridge uses Node's default keep-alive again, as before #20886.
- The per-call delivery diagnostics from #20886 stay, so a recurrence of
the stalled parallel call still shows socket reuse, idle time and
`written` in the run log.
- `mcp-bridge-delivery.test.ts`: the pooled-client test now asserts that
the bridge does not close connections, that a later call reuses a
socket, and that each call still gets a correct delivery record.

## Verification

- `pnpm exec vitest run` for mcp-bridge-delivery, mcp-bridge,
mcp-bridge-diagnostics, opencode-proxy-finish-diagnostics,
opencode-app-server-proxy and opencode-proxy-abandoned-tool-call tests:
45 passed.
- The exact mechanism inside OpenCode/Bun is not reproduced locally (a
macOS Bun client against a `Connection: close` server completed 300
MCP-shaped handshakes without error). The evidence is the before/after
correlation above. The next chat eval runs will show the first-request
token counts again.

## Risks

- The original five-minute stall (one occurrence, cause not proven) can
recur. The diagnostics from #20886 will then name the socket state.
- No persisted run, session or pack state changes.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 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 14:10:56 -06:00
1 parent c5e6ab5ca8
commit 64afd22d82
2 files changed
+12 -10

No files matched your search

@@ -42,7 +42,7 @@ const tools = [
{ 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 () => {
it("answers each tool call, including a parallel pair on a pooled keep-alive client, with its own delivery record", async () => {
const messages: string[] = [];
const bridge = await startOpenCodeMcpBridge({
tools,
@@ -69,8 +69,9 @@ it("answers every tool call on its own connection so a pooled socket can never s
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);
// OpenCode's MCP client loses its tools when the bridge closes every
// connection, so the bridge must keep the default keep-alive.
expect(answer.connection).not.toBe("close");
}
await expect.poll(() => messages.length).toBe(3);
@@ -78,12 +79,14 @@ it("answers every tool call on its own connection so a pooled socket can never s
expect(deliveries.map(delivery => delivery.callIdSha256).sort()).toEqual(
["2", "3", "4"].map(id => createHash("sha256").update(id).digest("hex")).sort(),
);
// The pooled client reuses a socket for at least one later call.
expect(deliveries.some(delivery => delivery.socketRequestIndex > 1)).toBe(true);
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,
socketRequestIndex: expect.any(Number),
socketIdleMs: delivery.socketRequestIndex === 1 ? null : expect.any(Number),
handlerMs: expect.any(Number),
outcome: "result",
written: true,
@@ -242,11 +242,10 @@ 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");
// Keep the default keep-alive. Forcing `Connection: close` here left
// OpenCode (Bun fetch) without the Paperclip MCP tools in many native runs:
// first model requests dropped from ~35k to ~15k input tokens (no tool
// schemas) and the agents reported that Paperclip tools were missing.
if (!authorized(request.headers.authorization, context.secret)) {
response.statusCode = 401;
response.end();