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
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 18:22:08 +08:00
1 parent b572dbaf25
commit e793fd6808
4 files changed
+163 -35

No files matched your search

+4 -1
View File
@@ -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
+86 -23
View File
@@ -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<Record<string, JsonValue>>,
): 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 },
),
+11 -11
View File
@@ -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 },
+62
View File
@@ -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);
},
);
});