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:
parent
204a6f3f20
commit
3e7c630223
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue