From e793fd680883b7f5cab04abf5c5391d28aebc365 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:22:08 +0800 Subject: [PATCH] feat(mcp): name the next operation when a completed result overflows the response budget (#11015) * feat(mcp): name the next operation when a completed result overflows the response budget * test(mcp): assert next_operation for every overflow recovery route --- docs/reference/mcp-contracts.md | 5 +- src/server/ToolResultDelivery.ts | 109 +++++++++++++++---- tests/boundary/mcp/serverUnknownsMcp.test.ts | 22 ++-- tests/boundary/mcp/toolResult.test.ts | 62 +++++++++++ 4 files changed, 163 insertions(+), 35 deletions(-) diff --git a/docs/reference/mcp-contracts.md b/docs/reference/mcp-contracts.md index a344554b1e..0dbe1a4823 100644 --- a/docs/reference/mcp-contracts.md +++ b/docs/reference/mcp-contracts.md @@ -537,7 +537,10 @@ to recover the result; do not repeat the committed mutation. A single unknown may be too large for a `list_unknowns` page. A delivery failure does not undo that revision. If a list response overflows, retry on the same connection with the same filters and offset and a smaller limit; export if one head still cannot -fit. +fit. When the recovery starts with one MCP operation, +`details.reported_limits.next_operation` names it: `export_evidence_bundle` for +a committed unknown or a one-row page, `list_unknowns` for a page that a smaller +limit can split. It is absent when the recovery is complete CLI JSON output. To find missing sources before a downstream analysis refuses them, read `missing_direct_links` from the summary, or call `get_evidence_bundle` with diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 7e2772e403..ba7ec93835 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -89,16 +89,90 @@ const evidenceBundleProjection = (value: JsonValue): JsonValue | undefined => { : undefined; }; -const evidenceBundleOverflowStep = (value: JsonValue): string => { +/** + * The recovery for an overflowing completed result: its prose and, when it + * names one MCP operation to call first, that operation. The CLI fallback + * names none. + */ +interface OverflowNextStep { + readonly text: string; + readonly operation?: string; +} + +const evidenceBundleOverflowStep = (value: JsonValue): OverflowNextStep => { const projection = evidenceBundleProjection(value); // A summary already used detail: summary; export writes outside one frame. if (projection === "evidence-bundle-summary") - return MCP_TRANSPORT_EVIDENCE_EXPORT; - if (projection === "evidence-link-inspection") - return MCP_TRANSPORT_EVIDENCE_LINKS; - return projection === "evidence-record-selection" - ? MCP_TRANSPORT_EVIDENCE_RECORDS - : MCP_TRANSPORT_EVIDENCE_DISCOVERY; + return { + text: MCP_TRANSPORT_EVIDENCE_EXPORT, + operation: "export_evidence_bundle", + }; + return { + text: + projection === "evidence-link-inspection" + ? MCP_TRANSPORT_EVIDENCE_LINKS + : projection === "evidence-record-selection" + ? MCP_TRANSPORT_EVIDENCE_RECORDS + : MCP_TRANSPORT_EVIDENCE_DISCOVERY, + operation: "get_evidence_bundle", + }; +}; + +/** + * Rows in an overflowing `list_unknowns` page, delivered as a session + * `{ result }` envelope; a smaller limit only helps above one. + */ +const unknownPageItems = (value: JsonValue): number => { + const page = + typeof value === "object" && value !== null && !Array.isArray(value) + ? value.result + : undefined; + return typeof page === "object" && + page !== null && + !Array.isArray(page) && + Array.isArray(page.items) + ? page.items.length + : Number.POSITIVE_INFINITY; +}; + +const overflowNextStep = ( + value: JsonValue, + contract: ToolContract, + recovery: Readonly>, +): OverflowNextStep => { + if (recovery.committed_unknown !== undefined) + return { + text: "Export this session with export_evidence_bundle to inspect the committed unknown and its mutation Evidence. Do not repeat the completed mutation; an individual unknown may be too large for a list_unknowns page.", + operation: "export_evidence_bundle", + }; + if (recovery.continuation !== undefined) + return { + text: "Call inspect_analysis_view with the supplied continuation arguments to read retained analysis. Follow coverage.next_offset for subsequent pages; reduce the page limit if needed, or export the complete retained Evidence.", + operation: "inspect_analysis_view", + }; + if (recovery.evidence_reference !== undefined) + return { + text: "Export retained evidence to consume the complete analysis, or use complete CLI JSON output.", + operation: "export_evidence_bundle", + }; + if (contract.name === "list_unknowns" && unknownPageItems(value) <= 1) + return { + text: "One unknown does not fit a list_unknowns response on this connection, so a smaller limit cannot help. Export the session with export_evidence_bundle to recover it.", + operation: "export_evidence_bundle", + }; + if (contract.name === "list_unknowns") + return { + text: "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it.", + operation: "list_unknowns", + }; + if (contract.name === "get_evidence_bundle") + return evidenceBundleOverflowStep(value); + if (contract.name === "inspect_address_context") + return { + text: "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ.", + operation: "get_evidence_bundle", + }; + return { text: "Use complete CLI JSON output to consume the analysis." }; }; /** Immutable encoding and recovery policy shared by one MCP server. */ @@ -445,26 +519,12 @@ export class ToolResultDelivery { ? { _meta: TARGET_CONTENT_RESULT_META } : {}), }; - const retainedEvidence = recovery.evidence_reference !== undefined; - const nextStep = - recovery.committed_unknown !== undefined - ? "Export this session with export_evidence_bundle to inspect the committed unknown and its mutation Evidence. Do not repeat the completed mutation; an individual unknown may be too large for a list_unknowns page." - : recovery.continuation !== undefined - ? "Call inspect_analysis_view with the supplied continuation arguments to read retained analysis. Follow coverage.next_offset for subsequent pages; reduce the page limit if needed, or export the complete retained Evidence." - : retainedEvidence - ? "Export retained evidence to consume the complete analysis, or use complete CLI JSON output." - : contract.name === "list_unknowns" - ? "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it." - : contract.name === "get_evidence_bundle" - ? evidenceBundleOverflowStep(value) - : contract.name === "inspect_address_context" - ? "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ." - : "Use complete CLI JSON output to consume the analysis."; + const nextStep = overflowNextStep(value, contract, recovery); const projected = mcpResponseOverflowProjection({ completed: true, constraint: encoded.constraint, effectiveResponseBytes: this.maximumResponseBytes, - nextStep, + nextStep: nextStep.text, }); return this.toErrorToolResult( new AnalysisResourceConstraintError( @@ -480,6 +540,9 @@ export class ToolResultDelivery { response_code_units_at_least: encoded.codeUnitsAtLeast, constraint: encoded.constraint, ...recovery, + ...(nextStep.operation === undefined + ? {} + : { next_operation: nextStep.operation }), }, { remediationAction: projected.remediationAction }, ), diff --git a/tests/boundary/mcp/serverUnknownsMcp.test.ts b/tests/boundary/mcp/serverUnknownsMcp.test.ts index 2daf70f625..1c7c0c928f 100644 --- a/tests/boundary/mcp/serverUnknownsMcp.test.ts +++ b/tests/boundary/mcp/serverUnknownsMcp.test.ts @@ -673,13 +673,12 @@ it("returns exact committed unknown receipts when creation and update replies ov kind: "retained-evidence", evidence_id: unknown.mutation_evidence_ids[0], }, + // Export, not a list_unknowns page: one head may not fit a page. + next_operation: "export_evidence_bundle", }, }, }, }); - const recovery = parseMcpToolError(response).error.remediation.action; - expect(recovery).toContain("export_evidence_bundle"); - expect(recovery).not.toContain("or page list_unknowns"); for (const id of unknown.mutation_evidence_ids) expect(session.evidenceById(id)).toBeDefined(); }; @@ -689,9 +688,10 @@ it("returns exact committed unknown receipts when creation and update replies ov arguments: { offset: 0, limit: 1 }, }); expect(singlePage.isError).toBe(true); - expect(parseMcpToolError(singlePage).error.remediation.action).toContain( - "export_evidence_bundle", - ); + // A one-row page cannot shrink; the route is export, not another page. + expect(parseMcpToolError(singlePage).error).toMatchObject({ + details: { reported_limits: { next_operation: "export_evidence_bundle" } }, + }); const directory = await createTestTempDirectory("rea-unknown-overflow-"); const path = join(directory, "bundle.json"); const exported = await client.callTool({ @@ -866,11 +866,11 @@ it("recovers an oversized list with same-session paging or export", async () => arguments: { domain: "overflow-list" }, }), ).error; - expect(overflow.code).toBe("resource_constraint"); - expect(overflow.remediation.action).toContain("same connection"); - expect(overflow.remediation.action).toContain("smaller limit"); - expect(overflow.remediation.action).toContain("export_evidence_bundle"); - expect(overflow.remediation.action).not.toContain("CLI JSON"); + // The list pages again on this connection rather than falling back to CLI. + expect(overflow).toMatchObject({ + code: "resource_constraint", + details: { reported_limits: { next_operation: "list_unknowns" } }, + }); const first = await client.callTool({ name: "list_unknowns", arguments: { domain: "overflow-list", offset: 0, limit: 1 }, diff --git a/tests/boundary/mcp/toolResult.test.ts b/tests/boundary/mcp/toolResult.test.ts index c2430f152b..f13f941c5b 100644 --- a/tests/boundary/mcp/toolResult.test.ts +++ b/tests/boundary/mcp/toolResult.test.ts @@ -590,3 +590,65 @@ describe("untrusted provenance on failed tool results", () => { expect(result._meta).toEqual(UNTRUSTED_TARGET_CONTENT); }); }); + +describe("response-budget recovery routes", () => { + it.each([ + ["CLI fallback", "procedure_pseudo_code", {}, {}, undefined], + [ + "retained Evidence", + "procedure_pseudo_code", + {}, + { + evidence_reference: { kind: "retained-evidence", evidence_id: "e" }, + }, + "export_evidence_bundle", + ], + [ + "view continuation", + "list_strings", + {}, + { continuation: { name: "inspect_analysis_view", arguments: {} } }, + "inspect_analysis_view", + ], + [ + "address context", + "inspect_address_context", + {}, + {}, + "get_evidence_bundle", + ], + [ + "bundle summary", + "get_evidence_bundle", + { kind: "evidence-bundle-summary" }, + {}, + "export_evidence_bundle", + ], + [ + "bundle records", + "get_evidence_bundle", + { kind: "evidence-record-selection" }, + {}, + "get_evidence_bundle", + ], + ] as const)( + "names the %s overflow recovery's first operation", + (_label, tool, shape, recovery, operation) => { + const result = createToolResultDelivery(4096).toCallToolResult( + { ok: true, value: { ...shape, text: "x".repeat(8192) } }, + toolContract(tool), + recovery, + ); + const limits = z + .object({ + error: z.object({ + details: z.object({ + reported_limits: z.record(z.string(), z.unknown()), + }), + }), + }) + .parse(result.structuredContent).error.details.reported_limits; + expect(limits.next_operation).toBe(operation); + }, + ); +});