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 {