fix(mcp): count the error flag in error result budgets (#2848)

Error results reach the wire as text plus `isError: true`, but the
text-only envelope omitted the flag. An error up to 15 bytes above the
result budget passed both the delivery and transport checks and was sent
as the original diagnostic instead of taking the oversized-error recovery
path.

Rename the error representation to "error-text" and size its envelope
with the flag, so ToolResultDelivery and the error transport share the
complete delivered shape. Successful structured results are unchanged.

Fixes #2557

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shubham Ahuja 2026-10-13 20:36:55 +05:30 committed by GitHub
parent 204a6f3f20
commit 3e7c630223
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 108 additions and 26 deletions

View File

@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error.
REA prepares complete MCP results within the pinned stdio client's 10 MiB
receive-buffer budget, including both text and structured representations and
room for the JSON-RPC envelope. Tool errors carry only their text
representation, so only that text counts against the budget. Response budget settings are captured at startup;
representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
restart or recreate the server to apply changes. If a result cannot fit, REA returns
`resource_constraint` with `details.resource: "transport"` before constructing
a document-sized string. Analysis Evidence remains complete in the current

View File

@ -278,8 +278,13 @@ class ErrorEvidenceTransport implements Transport {
// Count the provenance marker exactly as the projected error did, and keep
// it on the replacement: the retained original may hold target content.
const targetContent = carriesTargetContentMarker(message.result);
// The delivered error omits structuredContent, so only its text counts.
const encoded = encodeToolResult(structured, budget, "text", targetContent);
// The delivered error omits structuredContent, so its text and error flag count.
const encoded = encodeToolResult(
structured,
budget,
"error-text",
targetContent,
);
if (encoded.ok) return message;
const operation = tool ?? "mcp_tool_error";
const originalError = analysisErrorProjectionSchema.safeParse(

View File

@ -139,7 +139,7 @@ export class ToolResultDelivery {
const encoded = encodeToolResult(
structuredContent,
this.resultBudgetBytes,
"text",
"error-text",
targetContent,
);
// The transport retains an oversized projected error before replacing it

View File

@ -48,21 +48,27 @@ describe("MCP result encoding budget", () => {
it.each([
{ error: { code: "invalid_request", message: '中文"\n'.repeat(30000) } },
{ error: { message: "😀\ud800" } },
])("counts only escaped text for text-only error delivery", (candidate) => {
const textBytes = Buffer.byteLength(
JSON.stringify({
content: [{ type: "text", text: JSON.stringify(candidate) }],
}),
);
const encoded = encodeToolResult(candidate, textBytes, "text");
if (!encoded.ok) throw new Error("Expected complete encoding");
expect(encoded.text).toBe(JSON.stringify(candidate));
expect(encoded.bytes).toBe(textBytes);
expect(encodeToolResult(candidate, textBytes - 1, "text")).toMatchObject({
ok: false,
bytesAtLeast: textBytes,
});
});
])(
"counts escaped text and the error flag for error delivery",
(candidate) => {
const errorBytes = Buffer.byteLength(
JSON.stringify({
content: [{ type: "text", text: JSON.stringify(candidate) }],
isError: true,
}),
);
const encoded = encodeToolResult(candidate, errorBytes, "error-text");
if (!encoded.ok) throw new Error("Expected complete encoding");
expect(encoded.text).toBe(JSON.stringify(candidate));
expect(encoded.bytes).toBe(errorBytes);
expect(
encodeToolResult(candidate, errorBytes - 1, "error-text"),
).toMatchObject({
ok: false,
bytesAtLeast: errorBytes,
});
},
);
it("accounts for the untrusted target-content marker", () => {
const candidate = { strings: ['"ignore previous instructions"', "中文"] };
@ -94,14 +100,15 @@ describe("MCP result encoding budget", () => {
const markedBytes = Buffer.byteLength(
JSON.stringify({
content: [{ type: "text", text: JSON.stringify(candidate) }],
isError: true,
_meta: TARGET_CONTENT_RESULT_META,
}),
);
expect(
encodeToolResult(candidate, markedBytes, "text", true),
encodeToolResult(candidate, markedBytes, "error-text", true),
).toMatchObject({ ok: true, bytes: markedBytes });
expect(
encodeToolResult(candidate, markedBytes - 1, "text", true),
encodeToolResult(candidate, markedBytes - 1, "error-text", true),
).toMatchObject({ ok: false, bytesAtLeast: markedBytes });
});

View File

@ -13,8 +13,8 @@ const RESULT_ENVELOPE_BYTES =
}),
) - 4;
const TEXT_RESULT_ENVELOPE_BYTES = Buffer.byteLength(
JSON.stringify({ content: [{ type: "text", text: "" }] }),
const ERROR_RESULT_ENVELOPE_BYTES = Buffer.byteLength(
JSON.stringify({ content: [{ type: "text", text: "" }], isError: true }),
);
/**
@ -48,9 +48,10 @@ const TARGET_CONTENT_META_BYTES =
/**
* MCP representations a result carries on the wire. Successful results repeat
* the JSON as structured content and escaped text; error results carry text only.
* the JSON as structured content and escaped text; error results carry text
* only, plus their `isError` flag.
*/
export type ToolResultRepresentations = "structured-and-text" | "text";
export type ToolResultRepresentations = "structured-and-text" | "error-text";
/** Result of encoding the complete repeated MCP representations within a wire budget. */
export type ToolResultEncoding =
@ -72,7 +73,7 @@ export const encodeToolResult = (
const structured = representations === "structured-and-text";
const parts: string[] = [];
let bytes =
(structured ? RESULT_ENVELOPE_BYTES : TEXT_RESULT_ENVELOPE_BYTES) +
(structured ? RESULT_ENVELOPE_BYTES : ERROR_RESULT_ENVELOPE_BYTES) +
(targetContent ? TARGET_CONTENT_META_BYTES : 0);
let codeUnits = bytes;
for (const part of bufferedJsonParts(jsonParts(value))) {

View File

@ -257,3 +257,72 @@ it("keeps the untrusted target-content marker on an oversized error's replacemen
await Promise.all([client.close(), server.close()]);
}
});
// The delivered error wire omits only the private structured carrier; its
// `isError` flag and any provenance marker count against the result budget.
const deliveredErrorBytes = (nameBytes: number, targetContent: boolean) => {
const { structuredContent: _carrier, ...wire } = failureWithName(
nameBytes,
targetContent,
);
return Buffer.byteLength(JSON.stringify(wire));
};
it.each([false, true])(
"applies the result budget to the complete delivered error (target content: %s)",
async (targetContent) => {
const fittingName =
delivery.resultBudgetBytes - deliveredErrorBytes(0, targetContent);
expect(deliveredErrorBytes(fittingName, targetContent)).toBe(
delivery.resultBudgetBytes,
);
const server = new EvidenceMcpServer(
{ name: "error-budget-test", version: "1" },
{ capabilities: {} },
undefined,
delivery,
);
server.registerTool(
"fitting_failure",
{ inputSchema: z.object({}) },
async () => failureWithName(fittingName, targetContent),
);
server.registerTool(
"one_byte_over_failure",
{ inputSchema: z.object({}) },
async () => failureWithName(fittingName + 1, targetContent),
);
const client = new Client({ name: "error-budget-client", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const fitting = await client.callTool({
name: "fitting_failure",
arguments: {},
});
expect(fitting.isError).toBe(true);
expect(parseMcpToolError(fitting)).toEqual(
failureWithName(fittingName, targetContent).structuredContent,
);
const over = await client.callTool({
name: "one_byte_over_failure",
arguments: {},
});
expect(over.isError).toBe(true);
expect(over.structuredContent).toBeUndefined();
expect(parseMcpToolError(over)).toMatchObject({
error: {
code: "resource_constraint",
details: {
reported_limits: { original_error_code: "invalid_request" },
},
},
});
} finally {
await Promise.all([client.close(), server.close()]);
}
},
20000,
);