From dcfc2acfa80f082fb59f527c9849bdb6eebb274a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:23:05 -0500 Subject: [PATCH] Preserve complete bounded OpenCode MCP tool results (#17825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://entire.com/gh/paperclipai/-/trails/34/changes/paperclip/34 ## Thinking Path > - Paperclip manages AI agents and their governed tools. > - Native OpenCode receives tool results through a Runner-owned MCP bridge. > - The bridge cut serialized results at 64 KiB, even inside a JSON string. > - A tool could finish while its saved OpenCode output lost files and receipts. > - Native app results already use bounded results and saved-result pages. > - This change preserves complete JSON within a bounded output limit. > - Agents can read the result without repeating the completed action. ## Linked Issues or Issue Description **What happened?** The OpenCode MCP bridge sliced serialized JSON at 65,536 characters. A large file-list result became invalid JSON in OpenCode's saved output. The tool execution itself had completed. **Expected behavior** The bridge must deliver complete bounded JSON. A delivery limit must produce an explicit error, rather than a partial success value. A repeated call identity must not execute the action again. **Steps to reproduce** 1. Return a structured result with 27 file entries and duplicated text/data representations larger than 64 KiB. 2. Call the tool through the actual HTTP MCP bridge. 3. Parse the returned text as JSON. The old source fails at position 65,536. Related: #17740 changed assigned-tool catalog delivery. This PR repairs result delivery and does not change the tool catalog or authority. ## What Changed - Replace JSON slicing with complete serialization and a 1 MiB UTF-8 output limit. - Bound serialization traversal, including escaped strings and many small fields. - Keep the existing native 500 KB result/page and saved-artifact contracts. - Report oversized delivery with a warning that the action may already have completed. Keep call-identity replay on the original execution. - Add real HTTP regressions and document the transport limit. ## Verification - Red before fix: the large-result HTTP regression fails with an unterminated JSON string at position 65,536. - 91 focused MCP, OpenCode driver and continuation assertions pass on Node 24.22.1. They cover escaped UTF-8 pages, oversized delivery, authentication, cancellation and duplicate-call execution. - Workspace typecheck and build pass. - Actual native Rust → fixed MCP bridge → OpenCode 1.18.34 → `openrouter/deepseek/deepseek-v4.1-flash-1017` passed with a public synthetic 27-file result. One assigned read produced a 281,375-byte saved JSON file. The model used Bash/Python to parse all filenames and both unpredictable receipt fields. One finish completed the turn. - The native probe used a synthetic gateway and a mocked completion acknowledgment. It does not prove hosted deployment, board finalization or a particular upstream model vendor. - The full default test attempt is failed/incomplete. It was stopped after repeated embedded PostgreSQL bootstrap failures and skipped database suites. Its 15 warm-workspace cases pass unchanged on isolated PostgreSQL 18. A controlled default-database rerun fails before the test body when embedded PostgreSQL cannot start after five attempts. The test source is byte-identical to the base commit. The failed attempt remains recorded. - The final test-only commit checks a 500 KB escaped producer page inside the actual controller text envelope. The production bridge hash still matches the native proof. Its 91 focused tests and package TypeScript check pass. - Current-head verification (`73d18ca2`): 53 CI checks passed and two conditional Storybook checks skipped. Greptile is 5/5 on this exact head with no review threads. The failed/incomplete full local attempt and the controlled passing suites above remain separate evidence. ## Risks - Larger complete results use more bounded serialization memory than the old truncated output. - A bridge result above 1 MiB fails delivery. Native app results and saved-result pages are bounded at 500 KB before this bridge. The existing artifact protocol handles larger results. - A failed delivery cannot prove whether an upstream action took effect. The error forbids repeating it, and identical call identities retain the original execution. - This change does not alter model routing, admitted tools, credentials or trust policy. Hosted rollout and incident recovery remain separate verification steps. ## Model Used - OpenAI `gpt-6.1-sol`, with reasoning, tool use and code execution. The session did not expose an authoritative context-window size. Native acceptance used the exact DeepSeek model named above. ## 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 the relevant local tests and they pass; the full local-suite environment limitation is documented above. - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/paperclip-runner/README.md | 14 +++++ .../src/drivers/opencode/mcp-bridge.test.ts | 59 +++++++++++++++++++ .../src/drivers/opencode/mcp-bridge.ts | 38 +++++++++++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 51823ae1d55..488c97584b3 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -797,6 +797,20 @@ fit the compacted bound produces a diagnostic; the event stream continues. Tool output and error fields have reserved retention space and size records, so large input values cannot consume their output tails or make excerpts look complete. A separator split between reads does not count against the frame limit. +### OpenCode MCP tool results + +The Runner-owned MCP bridge sends complete JSON tool results up to 1 MiB of +UTF-8 output. It does not slice JSON text. This includes the native server's +bounded 500 KB app-tool results and saved-result pages. OpenCode can put a long +result in its own output file; that file must still contain the complete JSON. +The existing `read_tool_result` artifact protocol handles larger app results. + +Serialization has a bounded traversal budget and checks escaped UTF-8 output. +If delivery exceeds the limit, the bridge reports an error without a partial +success value. The action may already have completed. Reconcile its saved +result or receipt instead of repeating it. Replaying the same MCP call identity +reuses the original handler execution, including when delivery failed. + ## Preconfigured home Set `homeMode: "preconfigured"` in a `paperclip_runner` agent's adapter config diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts index f281fd2a55a..40c2fc68fd7 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts @@ -50,6 +50,65 @@ describe("OpenCode MCP bridge", () => { }); }); + it("delivers a complete large assigned-tool result and replays its receipt without repeating the action", async () => { + const files = Array.from({ length: 27 }, (_, index) => ({ + filename: `src/public-fixture-${index}.ts`, + patch: `@@ synthetic public fixture ${index} @@\n${"+ documented change\n".repeat(220)}`, + })); + const data = { items: files, page: 1, hasMore: false, receipt: "LAST_FILE_AND_RECEIPT_INTACT" }; + const result = { content: [{ type: "text", text: JSON.stringify(data) }], data }; + expect(JSON.stringify(result).length).toBeGreaterThan(111_877); + const handler = vi.fn(async () => result); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "paperclip_call_assigned_tool", inputSchema: { type: "object" } }], handler, + }); + bridges.push(bridge); + const request = { id: "large-result", method: "tools/call", params: { name: "paperclip_call_assigned_tool", arguments: {} } }; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await (await rpc(bridge, request)).json() as { result: { content: Array<{ text: string }> } }; + expect(JSON.parse(response.result.content[0]!.text)).toEqual(result); + } + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("preserves producer-sized pages with UTF-8 and heavily escaped nested data", async () => { + const chunk = '\"\\\n\t\u0000😀'; + const result = { responseText: { nextOffsetBytes: 300_000, totalBytes: 600_000 }, data: chunk.repeat(25_000) }; + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(500_000); + expect(Buffer.byteLength(JSON.stringify(result))).toBeGreaterThan(64 * 1024); + // The driver returns a controller text item, not the raw app page. Its + // encoded JSON must survive a second layer of escaping at this boundary. + const controllerResult = { text: JSON.stringify(result), type: "inputText" }; + expect(Buffer.byteLength(JSON.stringify(controllerResult))).toBeGreaterThan(500_000); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "read_tool_result", inputSchema: { type: "object" } }], handler: async () => controllerResult, + }); + bridges.push(bridge); + const response = await (await rpc(bridge, { id: "page", method: "tools/call", params: { name: "read_tool_result", arguments: {} } })).json() as { result: { content: Array<{ text: string }> } }; + const delivered = JSON.parse(response.result.content[0]!.text) as typeof controllerResult; + expect(delivered).toEqual(controllerResult); + expect(JSON.parse(delivered.text)).toEqual(result); + }); + + it.each([ + ["UTF-8 scalar", () => ({ data: "😀".repeat(300_000) })], + ["escaped scalar", () => ({ data: "\u0000".repeat(200_000) })], + ["many fields", () => Array.from({ length: 600_000 }, () => 0)], + ])("rejects an oversized %s without a partial success or repeating the completed call", async (_name, makeResult) => { + const handler = vi.fn(async () => makeResult()); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "assigned.write", inputSchema: { type: "object" } }], handler, + }); + bridges.push(bridge); + const request = { id: "write-receipt", method: "tools/call", params: { name: "assigned.write", arguments: {} } }; + for (let attempt = 0; attempt < 2; attempt++) { + expect(await (await rpc(bridge, request)).json()).toMatchObject({ + result: { isError: true, content: [{ text: expect.stringContaining("may already have completed; do not repeat it") }] }, + }); + } + expect(handler).toHaveBeenCalledTimes(1); + }); + it("admits runner-private extension operations without advertising them to the model", async () => { const handler = vi.fn(async ({ tool }) => ({ decision: tool === "__paperclip_permission" ? "accept" : "decline" })); const bridge = await startOpenCodeMcpBridge({ diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index cb1efd91922..af847094c40 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -44,6 +44,9 @@ export interface OpenCodeMcpBridge { const DEFAULT_TIMEOUT_MS = 30_000; 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; export function canonicalOpenCodeMcpToolName(name: string): string { const trimmed = name.trim(); @@ -348,8 +351,39 @@ function safeError(error: unknown): string { } function safeJson(value: unknown): string { - const serialized = JSON.stringify(value); - return (serialized ?? "null").slice(0, 64 * 1024); + const tooLarge = () => new Error( + "Tool result delivery exceeded the 1 MiB MCP limit. The action may already have completed; do not repeat it. Reconcile its saved result or receipt instead.", + ); + // Separator accounting is conservative (it includes the first entry too). + // Twice the output limit admits every result within the exact byte cap. + let budget = 2 * MAX_RESULT_BYTES; + let root = true; + const charge = (text: string) => { + // Check length before measuring UTF-8 so even a huge scalar is rejected + // without copying it. JSON escaping can expand the final value further. + if (text.length > budget) throw tooLarge(); + budget -= Buffer.byteLength(text); + if (budget < 0) throw tooLarge(); + }; + const serialized = JSON.stringify(value, function (key, entry: unknown) { + const first = root; + root = false; + const scalar = entry === null || typeof entry !== "object"; + const omitted = entry === undefined || typeof entry === "function" || typeof entry === "symbol"; + if (omitted && !Array.isArray(this)) return entry; + if (!first) { + charge(","); + if (!Array.isArray(this)) { charge(key); charge('"":'); } + } + if (typeof entry === "string" || entry instanceof String) { charge(String(entry)); charge('""'); } + else if (!scalar) charge("{}"); + else charge(omitted ? "null" : JSON.stringify(entry) ?? "null"); + return entry; + }) ?? "null"; + // The traversal budget bounds allocation even for many small fields. This + // exact check also accounts for quotes, control characters and UTF-8 bytes. + if (Buffer.byteLength(serialized) > MAX_RESULT_BYTES) throw tooLarge(); + return serialized; } function canonicalJson(value: unknown): string {