diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 297172c4d..cd1a75316 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index e1d4b775f..05ca5e457 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -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> => { + 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 }, ), ); } diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 8c800e5f9..ca72d76e3 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -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();