fix(mcp): provide native evidence paging continuation

Supply schema-validated inspect_analysis_view arguments after completed native function Evidence is recorded and exceeds MCP delivery capacity. Preserve the full canonical result and its provenance. Verify exact source paging, export and following-call usability through real Ghidra stdio MCP.

Fixes #1849

Co-authored-by: Minghang Chamling <[email protected]>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
Minghang Chamling
2026-10-12 04:26:05 +08:00
committed by GitHub
co-authored by factory-droid[bot] Kaoru0822
parent 7cdb7981c3
commit 1449e8e051
3 changed files with 138 additions and 8 deletions
+12 -2
View File
@@ -328,8 +328,18 @@ session. Its exact reference is reported in
`details.reported_limits.evidence_reference`; call `inspect_analysis_view` with
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. A broad
follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
bundle exports stream canonical JSON into an atomically published file.
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
`normalized_result.coverage.next_offset` for subsequent pages. The suggested
limit is the view contract's default, not a guarantee that arbitrary source or
metadata fits; reduce the limit or export when needed. Continuations are
offered for completed `analyze_function` results only after session recording is
acknowledged; the complete result and its retention reference remain intact.
A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
preserves the complete session without sending it through a single MCP frame.
To discover existing records without their large payloads, call
`get_evidence_bundle` with `detail: "summary"`. Its `result.records` contain
+35 -6
View File
@@ -3,12 +3,16 @@ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { Evidence } from "../domain/evidence.js";
import {
INSPECT_ANALYSIS_VIEW_OPERATION,
inspectAnalysisViewInputSchema,
} from "../domain/analysisView/analysisView.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import {
@@ -22,6 +26,24 @@ export const createToolResultDelivery = (
): ToolResultDelivery =>
new ToolResultDelivery(maximumResponseBytes ?? STDIO_DEFAULT_MAX_BUFFER_SIZE);
/** Offer a named, schema-validated source page only for retained native analysis. */
const nativeEvidenceContinuation = (
evidence: Evidence,
): Readonly<Record<string, JsonValue>> => {
if (evidence.operation !== "analyze_function") return {};
const input = inspectAnalysisViewInputSchema.safeParse({
source: { kind: "retained-evidence", evidence_id: evidence.evidence_id },
view: { kind: "native", facet: "pseudocode", offset: 0 },
});
if (!input.success) return {};
return {
continuation: {
name: INSPECT_ANALYSIS_VIEW_OPERATION,
arguments: jsonValueSchema.parse(input.data),
},
};
};
/** Immutable encoding and recovery policy shared by one MCP server. */
export class ToolResultDelivery {
readonly resultBudgetBytes: number;
@@ -61,6 +83,7 @@ export class ToolResultDelivery {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
...nativeEvidenceContinuation(evidence),
}),
});
}
@@ -122,11 +145,14 @@ export class ToolResultDelivery {
structuredContent: value,
};
const retainedEvidence = recovery.evidence_reference !== undefined;
const recoveryAdvice = retainedEvidence
? "Export retained evidence to consume the complete analysis, or use complete CLI JSON output."
: encoded.constraint === "string-length"
? "Use complete CLI JSON output to consume the analysis."
: "Use a larger MCP response budget with a matching client receive buffer, or use complete CLI JSON output to consume the analysis.";
const recoveryAdvice =
recovery.continuation !== undefined
? "Call inspect_analysis_view with the supplied continuation arguments to read retained pseudocode. 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."
: encoded.constraint === "string-length"
? "Use complete CLI JSON output to consume the analysis."
: "Use a larger MCP response budget with a matching client receive buffer, or use complete CLI JSON output to consume the analysis.";
return this.toErrorToolResult(
new AnalysisResourceConstraintError(
contract.name,
@@ -144,6 +170,9 @@ export class ToolResultDelivery {
constraint: encoded.constraint,
...recovery,
},
recovery.continuation === undefined
? undefined
: { remediationAction: recoveryAdvice },
),
);
}
@@ -1,5 +1,6 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
@@ -17,6 +18,7 @@ import {
createJavaScriptApplicationNode,
} from "../../../src/domain/javascript/javascriptApplicationGraph.js";
import { createServer } from "../../../src/server/createServer.js";
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
import {
analysisViewJavaScriptEvidence,
analysisViewJavaScriptAnalysisWithSource,
@@ -455,6 +457,95 @@ it("retains an oversized native assembly row and permits a subsequent smaller vi
await client.ping();
});
it("reads successive native source pages directly from a completed-result transport error", async () => {
const pseudocode = "int fixture_add(int a, int b) { return a + b; }\n".repeat(
150_000,
);
const harness = await connectGhidraMcp(
"native-continuation",
async (operation) => {
if (operation !== "analyze_function")
throw new Error(`Unexpected provider operation: ${operation}`);
return ok({
value: {
...functionDossierSchema.parse(ghidraFunctionDossier()),
pseudocode,
},
limitations: [],
});
},
);
onTestFinished(() => harness.close());
const { mcp: client, session, calls } = harness;
const failed = await client.callTool({
name: "analyze_function",
arguments: { procedure: "0x401000" },
});
expect(failed.isError).toBe(true);
const limits = z
.object({
error: z.object({
details: z.object({ reported_limits: jsonObjectSchema }),
}),
})
.parse(parseMcpToolError(failed)).error.details.reported_limits;
const reference = z
.object({
kind: z.literal("retained-evidence"),
evidence_id: z.string(),
})
.parse(limits.evidence_reference);
const parent = parseEvidence(session.evidenceById(reference.evidence_id));
expect(parent.operation).toBe("analyze_function");
expect(parent.parameters).toMatchObject({ procedure: "0x401000" });
const call = z
.object({
name: z.literal("inspect_analysis_view"),
arguments: toolContract("inspect_analysis_view").inputSchema,
})
.parse(limits.continuation);
expect(call.arguments.source).toEqual({
kind: "retained-evidence",
evidence_id: parent.evidence_id,
});
const first = await client.callTool(call);
expect(first.isError).not.toBe(true);
const page = toolContract("inspect_analysis_view").outputSchema.parse(
first.structuredContent,
).normalized_result;
expect(page).toMatchObject({
parent_evidence_id: parent.evidence_id,
item: { text: pseudocode.slice(0, 64) },
coverage: { examined: 64, next_offset: 64, exhausted: false },
});
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThan(
STDIO_DEFAULT_MAX_BUFFER_SIZE,
);
const second = await client.callTool({
name: call.name,
arguments: {
...call.arguments,
view: {
kind: "native",
facet: "pseudocode",
offset: page.coverage.next_offset,
limit: 64,
},
},
});
expect(second.isError).not.toBe(true);
expect(
toolContract("inspect_analysis_view").outputSchema.parse(
second.structuredContent,
).normalized_result,
).toMatchObject({ item: { text: pseudocode.slice(64, 128) } });
expect(
session.evidenceById(parent.evidence_id)?.normalized_result,
).toMatchObject({ pseudocode });
await client.ping();
expect(calls).toEqual(["analyze_function"]);
});
it("returns a JavaScript summary without graph payloads from a retained reference", async () => {
const { client, session } = await connect();
const parent = analysisViewJavaScriptEvidence();