Preserve complete bounded OpenCode MCP tool results (#17825)

<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/34/changes/paperclip/34
<!-- entire-trail-link-end -->

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-26 22:23:05 -05:00
1 parent db32bdf98b
commit dcfc2acfa8
3 files changed
+109 -2

No files matched your search

+14
View File
@@ -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
@@ -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({
@@ -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 {