fix(mcp): retain protocol framing for oversized tool errors (#5371)

Preserve the SDK resultType when bounded error recovery replaces an oversized response. Verify both revisions through the public serving entry, retained Evidence and same-connection recovery.

Refs #5273

Co-authored-by: akushonkamen <akushonkamen@163.com>
This commit is contained in:
gensokyo-devandakushonkamen authored and GitHub committed 2026-10-20 07:31:02 +00:00
1 parent 0134732fcd
commit 4eaf3a117b
3 files changed
+152 -99

No files matched your search

+9 -2
View File
@@ -101,8 +101,9 @@ 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 and `isError` flag, so only those count against the budget. Response budget settings are captured at startup;
room for the JSON-RPC envelope. Tool errors omit `structuredContent` from the
wire; their text, `isError`, provenance marker and negotiated protocol framing
count toward the wire 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
@@ -112,6 +113,12 @@ that reference for a summary, one section/module, or a stable page, or call
`export_evidence_bundle` with a destination path. Complete
bundle exports stream canonical JSON into an atomically published file.
On protocol revision `2026-07-28`, completed replies retain the SDK's
`resultType: "complete"`, including the replacement for an oversized error.
This marks completion of the MCP exchange; Evidence coverage, partial facts
and unknowns retain their separate meaning. Legacy replies keep their existing
framing.
For oversized native function Evidence, `details.reported_limits.continuation`
contains a ready-to-call `inspect_analysis_view` request for the retained
pseudocode's first page. Use its `name` and `arguments` directly, then follow
+134 -96
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import {
isJSONRPCResultResponse,
type JSONRPCResultResponse,
} from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js";
@@ -43,104 +48,137 @@ describe("MCP request bookkeeping", () => {
});
describe("SDK validation error delivery", () => {
it("bounds oversized errors, retains their source, and preserves fitting and valid calls", async () => {
const delivery = new ToolResultDelivery(4096);
const records = new InvestigationRecords();
const server = new EvidenceMcpServer(
{ name: "sdk-validation-budget-test", version: "1" },
{ capabilities: {} },
(evidence) => records.recordEvidence(evidence),
delivery,
);
server.registerTool(
"procedure_pseudo_code",
{ inputSchema: z.strictObject({ required: z.string() }) },
async () => ({ content: [{ type: "text", text: "valid call" }] }),
);
const client = new Client({
name: "sdk-validation-budget-client",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const oversizedKey = `unexpected_${"x".repeat(6000)}`;
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const oversized = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present", [oversizedKey]: true },
});
expect(oversized.isError).toBe(true);
expect(oversized.structuredContent).toBeUndefined();
expect(oversized._meta).toEqual({
provenance: { untrusted_content: true, source: "analysis_target" },
});
expect(Buffer.byteLength(JSON.stringify(oversized))).toBeLessThan(4096);
const oversizedError = z
.object({
error: z.object({
code: z.string(),
details: z.object({
reported_limits: z.record(z.string(), z.unknown()),
}),
}),
})
.parse(JSON.parse(firstText(oversized) ?? "null"));
expect(oversizedError.error.code).toBe("resource_constraint");
const reportedLimits = oversizedError.error.details.reported_limits;
expect(reportedLimits).toMatchObject({
evidence_reference: {
kind: "retained-evidence",
evidence_id: expect.any(String),
},
diagnostic_truncated: true,
});
expect(reportedLimits).not.toHaveProperty("retention");
const bundle = records.exportEvidenceBundle();
expect(bundle.records).toHaveLength(1);
expect(bundle.records[0]?.operation).toBe("mcp_tool_error");
expect(bundle.records[0]?.raw_result).toMatchObject({
isError: true,
content: [
{ type: "text", text: expect.stringContaining(oversizedKey) },
],
});
const evidenceReference = z
.object({ evidence_id: z.string() })
.parse(reportedLimits.evidence_reference);
expect(bundle.records[0]?.evidence_id).toBe(
evidenceReference.evidence_id,
it.each(["2025-11-25", "2026-07-28"])(
"bounds and retains errors through the public stdio entry on %s",
async (revision) => {
const delivery = new ToolResultDelivery(4096);
const records = new InvestigationRecords();
const server = new EvidenceMcpServer(
{ name: "sdk-validation-budget-test", version: "1" },
{ capabilities: {} },
(evidence) => records.recordEvidence(evidence),
delivery,
);
server.registerTool(
"procedure_pseudo_code",
{ inputSchema: z.strictObject({ required: z.string() }) },
async () => ({ content: [{ type: "text", text: "valid call" }] }),
);
const client = new Client(
{
name: "sdk-validation-budget-client",
version: "1",
},
revision === "2026-07-28"
? { versionNegotiation: { mode: { pin: revision } } }
: { supportedProtocolVersions: [revision] },
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const oversizedKey = `unexpected_${"x".repeat(6000)}`;
const serving = serveStdio(() => server, { transport: serverTransport });
const replies: JSONRPCResultResponse[] = [];
try {
await client.connect(clientTransport);
expect(client.getNegotiatedProtocolVersion()).toBe(revision);
const receive = clientTransport.onmessage;
clientTransport.onmessage = (message) => {
if (isJSONRPCResultResponse(message)) replies.push(message);
receive?.(message);
};
const fitting = await client.callTool({
name: "procedure_pseudo_code",
arguments: { unexpected: true },
});
expect(fitting.isError).toBe(true);
expect(firstText(fitting)).toContain("Input validation error");
// The normal reply and SDK validation error already negotiate and decode
// correctly before oversized recovery replaces an SDK-encoded envelope.
const valid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present" },
});
expect(valid.content).toContainEqual({
type: "text",
text: "valid call",
});
const fitting = await client.callTool({
name: "procedure_pseudo_code",
arguments: { unexpected: true },
});
expect(fitting.isError).toBe(true);
expect(firstText(fitting)).toContain("Input validation error");
const quotedKey = '"\\'.repeat(350);
const quoted = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present", [quotedKey]: true },
});
expect(quoted.isError).toBe(true);
expect(firstText(quoted)).toContain("Input validation error");
expect(Buffer.byteLength(JSON.stringify(quoted))).toBeLessThan(4096);
expect(records.exportEvidenceBundle().records).toHaveLength(1);
const oversized = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present", [oversizedKey]: true },
});
expect(oversized.isError).toBe(true);
expect(oversized.structuredContent).toBeUndefined();
expect(oversized._meta).toEqual({
provenance: { untrusted_content: true, source: "analysis_target" },
});
expect(Buffer.byteLength(JSON.stringify(oversized))).toBeLessThan(4096);
const oversizedError = z
.object({
error: z.object({
code: z.string(),
details: z.object({
reported_limits: z.record(z.string(), z.unknown()),
}),
}),
})
.parse(JSON.parse(firstText(oversized) ?? "null"));
expect(oversizedError.error.code).toBe("resource_constraint");
const reportedLimits = oversizedError.error.details.reported_limits;
expect(reportedLimits).toMatchObject({
evidence_reference: {
kind: "retained-evidence",
evidence_id: expect.any(String),
},
diagnostic_truncated: true,
});
expect(reportedLimits).not.toHaveProperty("retention");
const bundle = records.exportEvidenceBundle();
expect(bundle.records).toHaveLength(1);
expect(bundle.records[0]?.operation).toBe("mcp_tool_error");
expect(bundle.records[0]?.raw_result).toMatchObject({
isError: true,
content: [
{ type: "text", text: expect.stringContaining(oversizedKey) },
],
});
const evidenceReference = z
.object({ evidence_id: z.string() })
.parse(reportedLimits.evidence_reference);
expect(bundle.records[0]?.evidence_id).toBe(
evidenceReference.evidence_id,
);
const valid = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present" },
});
expect(valid.content).toContainEqual({
type: "text",
text: "valid call",
});
} finally {
await Promise.all([client.close(), server.close()]);
}
});
const quotedKey = '"\\'.repeat(350);
const quoted = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present", [quotedKey]: true },
});
expect(quoted.isError).toBe(true);
expect(firstText(quoted)).toContain("Input validation error");
expect(Buffer.byteLength(JSON.stringify(quoted))).toBeLessThan(4096);
expect(records.exportEvidenceBundle().records).toHaveLength(1);
const recovered = await client.callTool({
name: "procedure_pseudo_code",
arguments: { required: "present" },
});
expect(recovered.content).toContainEqual({
type: "text",
text: "valid call",
});
for (const reply of replies) {
expect(reply.result.resultType).toBe(
revision === "2026-07-28" ? "complete" : undefined,
);
expect(
Buffer.byteLength(JSON.stringify(reply)) + 1,
).toBeLessThanOrEqual(4096);
}
} finally {
await Promise.all([client.close(), serving.close()]);
}
},
);
});
+9 -1
View File
@@ -451,7 +451,15 @@ class ErrorEvidenceTransport implements Transport {
);
return {
...message,
result: this.delivery.toErrorToolResult(failure, targetContent),
result: {
...this.delivery.toErrorToolResult(failure, targetContent),
// The SDK stamps `resultType` on modern-era results before this
// transport sees them; the replacement must keep the stamp or a
// 2026-07-28 client discards REA's structured error entirely.
...(typeof message.result.resultType === "string"
? { resultType: message.result.resultType }
: {}),
},
};
}
}