diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 7c219f6be..63ab516d5 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 2691ec422..f42fe9de6 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -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( diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index b503b5677..5d40afb5b 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -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 diff --git a/src/server/toolResultEncoding.test.ts b/src/server/toolResultEncoding.test.ts index b6969c306..1e9aee5ac 100644 --- a/src/server/toolResultEncoding.test.ts +++ b/src/server/toolResultEncoding.test.ts @@ -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 }); }); diff --git a/src/server/toolResultEncoding.ts b/src/server/toolResultEncoding.ts index 2a3692674..73ec2c717 100644 --- a/src/server/toolResultEncoding.ts +++ b/src/server/toolResultEncoding.ts @@ -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))) { diff --git a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts index f03d349b2..10d081d84 100644 --- a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts +++ b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts @@ -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, +);