Files
rea/src/server/toolResult.ts
T
1449e8e051 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 <133689405+cold-atom@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-12 04:26:05 +08:00

180 lines
6.7 KiB
TypeScript

import type { CallToolResult } from "@modelcontextprotocol/server";
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 { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import {
encodeToolResult,
MCP_RESULT_STRING_LIMIT,
} from "./toolResultEncoding.js";
/** Create one delivery policy from the already selected, parsed response budget. */
export const createToolResultDelivery = (
maximumResponseBytes: number | undefined,
): 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;
constructor(maximumResponseBytes: number) {
this.resultBudgetBytes = maximumResponseBytes - 1024;
Object.freeze(this);
}
/** Serialize a plain application result or its actionable error. */
toCallToolResult(
result: Result<JsonValue, AnalysisError>,
contract: ToolContract,
): CallToolResult {
return result.ok
? this.successResult(
contract.kind === "session" ? { result: result.value } : result.value,
contract,
)
: this.toErrorToolResult(result.error);
}
/** Deliver Evidence using the producer's explicit recording acknowledgment. */
toEvidenceToolResult(
evidence: Evidence,
contract: ToolContract,
recorded: Result<unknown, AnalysisError> | undefined,
): CallToolResult {
return recorded !== undefined && !recorded.ok
? this.toErrorToolResult(recorded.error)
: this.successResult(evidence, contract, {
evidence_id: evidence.evidence_id,
...(recorded === undefined
? {}
: {
evidence_reference: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
...nativeEvidenceContinuation(evidence),
}),
});
}
/** Project an error without allocating oversized repeated MCP text. */
toErrorToolResult(error: AnalysisError): CallToolResult {
return this.projectedErrorResult(projectAnalysisError(error));
}
/** Deliver the original failure with the producer-acknowledged partial Evidence reference. */
toRecordedPartialErrorToolResult(
error: AnalysisError,
evidenceId: string,
recorded: Result<unknown, AnalysisError>,
): CallToolResult {
if (!recorded.ok) return this.toErrorToolResult(error);
const projected = projectAnalysisError(error);
return this.projectedErrorResult({
...projected,
details: {
...projected.details,
partial_observation: {
kind: "retained-evidence",
evidence_id: evidenceId,
},
},
});
}
private projectedErrorResult(
projected: AnalysisErrorProjection,
): CallToolResult {
const structuredContent = { error: projected };
// Errors reach the wire as text only; the structured copy is a private
// carrier the transport removes after its own budget check.
const encoded = encodeToolResult(
structuredContent,
this.resultBudgetBytes,
"text",
);
// The transport retains an oversized projected error before replacing it
// with a recoverable delivery constraint, using this same budget.
return {
content: encoded.ok ? [{ type: "text", text: encoded.text }] : [],
structuredContent,
isError: true,
};
}
private successResult(
value: JsonValue,
contract: ToolContract,
recovery: Readonly<Record<string, JsonValue>> = {},
): CallToolResult {
const encoded = encodeToolResult(value, this.resultBudgetBytes);
if (encoded.ok)
return {
content: [{ type: "text", text: encoded.text }],
structuredContent: value,
};
const retainedEvidence = recovery.evidence_reference !== undefined;
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,
"transport",
encoded.constraint === "string-length"
? `The operation completed, but its complete MCP response exceeds Node's single-string representation limit. ${recoveryAdvice}`
: `The operation completed, but its complete MCP response exceeds the stdio response budget. The analysis result was not truncated. ${recoveryAdvice}`,
{
boundary: "mcp-response",
default_receive_buffer_bytes: STDIO_DEFAULT_MAX_BUFFER_SIZE,
result_budget_bytes: this.resultBudgetBytes,
max_string_code_units: MCP_RESULT_STRING_LIMIT,
response_bytes_at_least: encoded.bytesAtLeast,
response_code_units_at_least: encoded.codeUnitsAtLeast,
constraint: encoded.constraint,
...recovery,
},
recovery.continuation === undefined
? undefined
: { remediationAction: recoveryAdvice },
),
);
}
}