From 4afb6b79ae9cb5e8bea5a4a7ca6d516be3281160 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:34:26 +0800 Subject: [PATCH] fix(mcp): guide retained evidence input recovery (#8761) * fix(mcp): guide retained evidence input recovery * style(mcp): format catalog and server types * test(mcp): verify HTTP capacity recovery guidance * test(mcp): model catalog recorder imports in cleanup fixtures record-mcp-catalog.mjs loads tool contracts and the Evidence-input contract before it registers cleanup owners. The cleanup fixture still sentineled only the previous imports, so those loads failed before any owner wrote cleanup.jsonl. Track the script's read() calls and replace the new modules with the empty contract list this fault injection uses. --------- Co-authored-by: Cursor Agent --- scripts/record-mcp-catalog.mjs | 9 + .../evidenceInputConformance.test.ts | 28 +++ .../analysisView/analysisViewRequest.ts | 19 +- src/domain/evidenceInputContract.ts | 42 ++++- src/domain/evidenceReference.ts | 8 +- src/recordedMcpCatalog.ts | 2 + src/server/DeferredEvidenceMcpServer.test.ts | 26 ++- src/server/DeferredEvidenceMcpServer.ts | 2 + src/server/EvidenceMcpServerCore.ts | 15 +- src/server/evidenceInputRegistration.ts | 10 + src/server/toolRegistrationOptions.ts | 15 +- .../retainedComparisonAggregation.test.ts | 173 +++++++++++------- tests/boundary/mcp/deferredRuntime.test.ts | 48 +++-- ...investigationEvidenceReferencesMcp.test.ts | 5 +- .../mcp/toolInputCapacityHttp.test.ts | 4 + 15 files changed, 308 insertions(+), 98 deletions(-) create mode 100644 src/server/evidenceInputRegistration.ts diff --git a/scripts/record-mcp-catalog.mjs b/scripts/record-mcp-catalog.mjs index 9012b50633..c3da9bcfb9 100644 --- a/scripts/record-mcp-catalog.mjs +++ b/scripts/record-mcp-catalog.mjs @@ -15,12 +15,16 @@ const [ { parseConfig }, { silentLogger }, { PROMPT_CONTRACTS }, + { TOOL_CONTRACTS }, + { getEvidenceInputContract }, ] = await Promise.all([ read("server/createServer.js"), read("composition/binary.js"), read("config/parseConfig.js"), read("logger.js"), read("contracts/promptContracts.js"), + read("contracts/toolContracts.js"), + read("domain/evidenceInputContract.js"), ]); const recordCatalog = async (profile) => { // Catalog generation is independent of the host account and user configuration. @@ -71,6 +75,11 @@ const recordCatalog = async (profile) => { instructions: client.getInstructions(), identity: server.catalogIdentity, tools, + evidenceReferenceAlternatives: TOOL_CONTRACTS.filter( + (contract) => + getEvidenceInputContract(contract.inputSchema) + .hasEvidenceReferenceAlternative, + ).map((contract) => contract.name), prompts, }; const encoded = JSON.stringify(catalog); diff --git a/src/contracts/evidenceInputConformance.test.ts b/src/contracts/evidenceInputConformance.test.ts index 0950af031e..9dd1b8c900 100644 --- a/src/contracts/evidenceInputConformance.test.ts +++ b/src/contracts/evidenceInputConformance.test.ts @@ -230,6 +230,34 @@ describe("public Evidence input declarations", () => { expect(declarationIssues(schema)).toEqual([]); }); + it("distinguishes replaceable inline Evidence from reference-only sources", () => { + for (const schema of [ + evidenceInputSchema, + evidenceSourceInputSchema, + analysisViewSourceSchema, + z.array(evidenceInputSchema.optional()), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(true); + for (const schema of [ + retainedEvidenceReferenceSchema, + evidenceFileReferenceSchema, + exportEnvironmentBundleInputSchema.shape.source, + z.object({ address: z.string() }), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(false); + // Reference guidance must not change the separately owned source parser. + expect( + getEvidenceInputContract(evidenceSourceInputSchema).hasEvidence, + ).toBe(false); + expect(getEvidenceInputContract(analysisViewSourceSchema).hasEvidence).toBe( + false, + ); + }); + it("rejects skipping defaults above Evidence-bearing request unions", () => { const choice = z.union([ z.strictObject({ diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 143757355f..5a8920d4b7 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -32,6 +32,7 @@ import { desktopViewRequestSchema } from "./desktopApplicationView.js"; import { javascriptModuleReferencesRequestSchema } from "./javascriptModuleReferences.js"; import { javascriptFactsViewRequestSchema } from "./javascriptFactsView.js"; import { evidenceSchema } from "../evidence.js"; +import { declareEvidenceReferenceAlternative } from "../evidenceInputContract.js"; import { apiSurfaceViewSchema } from "./webNetworkApiSurfaceView.js"; import { evmViewRequestSchema } from "./evmInterfaceView.js"; import { artifactInventoryViewRequestSchema } from "./artifactInventoryView.js"; @@ -117,14 +118,16 @@ inlineEvidenceInputSchema._zod.processJSONSchema = evidenceSchema._zod.processJSONSchema; /** Unauthenticated inline input, an exact same-session retained reference, or one local Evidence file. */ -export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ - retainedEvidenceReferenceSchema, - z.strictObject({ - kind: z.literal("inline"), - evidence: inlineEvidenceInputSchema, - }), - evidenceFileReferenceSchema, -]); +export const analysisViewSourceSchema = declareEvidenceReferenceAlternative( + z.discriminatedUnion("kind", [ + retainedEvidenceReferenceSchema, + z.strictObject({ + kind: z.literal("inline"), + evidence: inlineEvidenceInputSchema, + }), + evidenceFileReferenceSchema, + ]), +); /** Public inspect_analysis_view input. */ export const inspectAnalysisViewInputSchema = z.strictObject({ diff --git a/src/domain/evidenceInputContract.ts b/src/domain/evidenceInputContract.ts index bbd6fc67d1..19440f23ce 100644 --- a/src/domain/evidenceInputContract.ts +++ b/src/domain/evidenceInputContract.ts @@ -11,6 +11,7 @@ type EvidenceInputDeclaration = // Zod registries carry owner metadata through describe/meta clones. This is a // schema declaration registry, never a store of caller Evidence or references. const declarations = z.registry(); +const referenceAlternatives = z.registry<{ readonly retained: true }>(); const borrowedDeclarations = new WeakMap(); /** Inspect authored declarations, including described clones, for catalog checks. */ @@ -25,6 +26,14 @@ export const declareEvidenceInput = ( return schema; }; +/** Mark a source union that accepts both inline Evidence and a retained reference. */ +export const declareEvidenceReferenceAlternative = ( + schema: Schema, +): Schema => { + referenceAlternatives.add(schema, { retained: true }); + return schema; +}; + /** Keep an SDK-borrowed selection associated with its owning declaration. */ export const declareBorrowedEvidenceInput = ( schema: Schema, @@ -94,6 +103,8 @@ type Visitor = ( export interface EvidenceInputContract { readonly frameSchema: z.ZodType; readonly hasEvidence: boolean; + /** A declared field can replace inline Evidence with a retained reference. */ + readonly hasEvidenceReferenceAlternative: boolean; resolve( input: unknown, visit: Visitor, @@ -102,9 +113,13 @@ export interface EvidenceInputContract { } const contracts = new WeakMap(); -const identity = (schema: z.ZodType): EvidenceInputContract => ({ +const identity = ( + schema: z.ZodType, + hasEvidenceReferenceAlternative = false, +): EvidenceInputContract => ({ frameSchema: schema, hasEvidence: false, + hasEvidenceReferenceAlternative, resolve: (input) => ok(input), }); @@ -129,11 +144,14 @@ export const getEvidenceInputContract = ( }; const compile = (schema: z.ZodType): EvidenceInputContract => { + if (referenceAlternatives.get(schema) !== undefined) + return identity(schema, true); const declared = declarations.get(schema); if (declared?.kind === "evidence") return { frameSchema: z.unknown().nonoptional(), hasEvidence: true, + hasEvidenceReferenceAlternative: true, resolve: (input, visit, path = []) => visit(input, path), }; if (declared?.kind === "borrowed") { @@ -156,9 +174,11 @@ const compile = (schema: z.ZodType): EvidenceInputContract => { schema instanceof z.ZodReadonly ) { const child = getEvidenceInputContract(schema.unwrap() as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: replaceWrappedSchema(schema, child.frameSchema), resolve: (input, visit, path = []) => input === undefined || @@ -185,9 +205,14 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { [key, getEvidenceInputContract(child as z.ZodType)] as const, ); const selected = children.filter(([, child]) => child.hasEvidence); - if (selected.length === 0) return identity(schema); + const hasEvidenceReferenceAlternative = children.some( + ([, child]) => child.hasEvidenceReferenceAlternative, + ); + if (selected.length === 0) + return identity(schema, hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, @@ -215,9 +240,11 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { const arrayContract = (schema: z.ZodArray): EvidenceInputContract => { const child = getEvidenceInputContract(schema.element as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, element: child.frameSchema }, { parent: true }, @@ -242,7 +269,11 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { const options = schema.options.map((option) => getEvidenceInputContract(option as z.ZodType), ); - if (!options.some((option) => option.hasEvidence)) return identity(schema); + const hasEvidenceReferenceAlternative = options.some( + (option) => option.hasEvidenceReferenceAlternative, + ); + if (!options.some((option) => option.hasEvidence)) + return identity(schema, hasEvidenceReferenceAlternative); // Capture the successful branch while parsing; choosing it again later would // repeat defaults, overwrites and refinements. Keys are only parser results. const branches = new WeakMap(); @@ -257,6 +288,7 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { ); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: frame.clone({ ...frame.def, ...(schema.def.error === undefined ? {} : { error: schema.def.error }), diff --git a/src/domain/evidenceReference.ts b/src/domain/evidenceReference.ts index 51aed239d7..c4efba9230 100644 --- a/src/domain/evidenceReference.ts +++ b/src/domain/evidenceReference.ts @@ -4,6 +4,7 @@ import { absoluteLocalPathStringSchema } from "./localPath.js"; import { declareBorrowedEvidenceInput, declareEvidenceInput, + declareEvidenceReferenceAlternative, } from "./evidenceInputContract.js"; /** Exact reference to Evidence already retained by the current session. */ @@ -49,8 +50,7 @@ export const borrowEvidenceArray = () => { }; /** Evidence input, or one record read from a local Evidence file or bundle. */ -export const evidenceSourceInputSchema = z.union([ - ...evidenceInputSchema.options, - evidenceFileReferenceSchema, -]); +export const evidenceSourceInputSchema = declareEvidenceReferenceAlternative( + z.union([...evidenceInputSchema.options, evidenceFileReferenceSchema]), +); export type EvidenceSourceInput = z.output; diff --git a/src/recordedMcpCatalog.ts b/src/recordedMcpCatalog.ts index 23595540fe..e53b064340 100644 --- a/src/recordedMcpCatalog.ts +++ b/src/recordedMcpCatalog.ts @@ -16,6 +16,8 @@ export interface RecordedMcpCatalog { readonly instructions: string; readonly identity: ReturnType; readonly tools: readonly Tool[]; + /** Tool names derived from canonical Evidence-input declarations at build time. */ + readonly evidenceReferenceAlternatives: readonly string[]; readonly prompts: readonly (Prompt & { readonly parameters: Readonly>; })[]; diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 6094693ff1..425ac88557 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -51,6 +51,7 @@ const catalog: RecordedMcpCatalog = { outputSchema: z.toJSONSchema(output), }, ], + evidenceReferenceAlternatives: [], prompts: [ { name: "explain", @@ -78,6 +79,7 @@ const fixture = async ( readonly invalidOutput?: boolean; readonly failAfterTool?: boolean; readonly maximumInputElements?: number; + readonly evidenceReferenceAlternative?: boolean; } = {}, ) => { const started = barrier(); @@ -85,7 +87,9 @@ const fixture = async ( const closed = vi.fn(); let loads = 0; const server = new DeferredEvidenceMcpServer( - catalog, + options.evidenceReferenceAlternative + ? { ...catalog, evidenceReferenceAlternatives: ["fixture"] } + : catalog, new ToolResultDelivery(1024 * 1024), async (target) => { loads++; @@ -188,6 +192,26 @@ it("refuses excess input before loading a deferred runtime, then admits a valid expect(f.calls).toHaveBeenCalledOnce(); }); +it("uses recorded Evidence-reference guidance without loading the deferred runtime", async () => { + const f = await fixture({ + maximumInputElements: 2, + evidenceReferenceAlternative: true, + }); + const result = await f.client.callTool({ + name: "fixture", + arguments: { n: 3, other: [0] }, + }); + const text = result.content.find((part) => part.type === "text")?.text; + expect(JSON.parse(text ?? "null")).toMatchObject({ + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, + }); + expect(f.loads()).toBe(0); + expect(f.calls).not.toHaveBeenCalled(); +}); + it("discovers without initialization, then shares one canonical runtime and preserves request metadata", async () => { const hold = barrier(); const f = await fixture({ hold }); diff --git a/src/server/DeferredEvidenceMcpServer.ts b/src/server/DeferredEvidenceMcpServer.ts index 31361fef69..6356a58d70 100644 --- a/src/server/DeferredEvidenceMcpServer.ts +++ b/src/server/DeferredEvidenceMcpServer.ts @@ -78,6 +78,8 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore { "full", catalog.identity, ); + for (const name of catalog.evidenceReferenceAlternatives) + this.registerEvidenceReferenceAlternative(name); for (const tool of catalog.tools) { const { name, inputSchema, outputSchema, ...metadata } = tool; let binding: DeferredTool; diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 80600d4ace..accf187fa8 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -62,6 +62,7 @@ import { } from "./toolResultEncoding.js"; import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { canReferenceInlineEvidence } from "./evidenceInputRegistration.js"; import { currentMcpToolRequest, McpToolRequest, @@ -344,6 +345,11 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { | CompactInputSchemaPresentation | undefined; private readonly maximumInputElements: number; + private readonly evidenceReferenceAlternatives = new Set(); + + protected registerEvidenceReferenceAlternative(name: string): void { + this.evidenceReferenceAlternatives.add(name); + } constructor( info: Implementation, @@ -392,6 +398,8 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { }, callback: ToolCallback, ): RegisteredTool { + if (canReferenceInlineEvidence(config.inputSchema)) + this.registerEvidenceReferenceAlternative(name); const budget = this.compactPresentation?.budgetBytes; const inputSchema = budget !== undefined && @@ -467,6 +475,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { () => this.withAdmittedAnalysis, this.outputSchemaProfile, this.maximumInputElements, + this.evidenceReferenceAlternatives, ), ); } @@ -505,6 +514,7 @@ class ErrorEvidenceTransport implements Transport { private readonly currentAdmission: () => WithAdmittedAnalysis | undefined, private readonly outputSchemaProfile: McpOutputSchemaProfile, private readonly maximumInputElements: number, + private readonly evidenceReferenceAlternatives: ReadonlySet, ) { if (transport.hasPerRequestStream !== undefined) this.hasPerRequestStream = transport.hasPerRequestStream; @@ -968,8 +978,9 @@ class ErrorEvidenceTransport implements Transport { "The MCP tool arguments exceed the configured input element allowance; schema validation and tool execution did not start.", { max_input_elements: this.maximumInputElements }, { - remediationAction: - "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", + remediationAction: this.evidenceReferenceAlternatives.has(tool) + ? 'If the source Evidence is retained, use {"kind":"retained-evidence","evidence_id":"ev_..."} for declared Evidence fields; use a summary\'s parent_evidence_id for its complete source. Otherwise import the originating Evidence bundle when available. Reduce the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.' + : "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", }, ); const result = this.delivery.toErrorToolResult(error, targetContent); diff --git a/src/server/evidenceInputRegistration.ts b/src/server/evidenceInputRegistration.ts new file mode 100644 index 0000000000..c46c1ed15b --- /dev/null +++ b/src/server/evidenceInputRegistration.ts @@ -0,0 +1,10 @@ +/** Internal registration facts derived from canonical Evidence input contracts. */ +const evidenceInputs = new WeakSet(); + +export const markEvidenceInput = (schema: T): T => { + evidenceInputs.add(schema); + return schema; +}; + +export const canReferenceInlineEvidence = (schema: unknown): boolean => + typeof schema === "object" && schema !== null && evidenceInputs.has(schema); diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts index 45723cef17..4062796304 100644 --- a/src/server/toolRegistrationOptions.ts +++ b/src/server/toolRegistrationOptions.ts @@ -9,6 +9,10 @@ import { toolInputSchemaWithMetadata, toolOutputSchemaWithMetadata, } from "../contracts/toolSchemaMetadata.js"; +import { + canReferenceInlineEvidence, + markEvidenceInput, +} from "./evidenceInputRegistration.js"; /** Project the canonical Zod contracts directly into SDK registration. */ export const toolRegistrationOptions = ( @@ -40,7 +44,9 @@ export const toolRegistrationOptions = ( return { title: contract.title, description: contract.description, - inputSchema, + inputSchema: evidenceInputs.hasEvidenceReferenceAlternative + ? markEvidenceInput(inputSchema) + : inputSchema, outputSchema: toolOutputSchemaWithMetadata(contract), annotations: contract.annotations, }; @@ -60,5 +66,10 @@ export const applicationToolRegistrationOptions = < validate: (value: unknown) => ({ value }), }, }; - return { ...registration, inputSchema }; + return { + ...registration, + inputSchema: canReferenceInlineEvidence(registration.inputSchema) + ? markEvidenceInput(inputSchema) + : inputSchema, + }; }; diff --git a/tests/acceptance/retainedComparisonAggregation.test.ts b/tests/acceptance/retainedComparisonAggregation.test.ts index 61a63170cf..0e7792e7a4 100644 --- a/tests/acceptance/retainedComparisonAggregation.test.ts +++ b/tests/acceptance/retainedComparisonAggregation.test.ts @@ -87,68 +87,113 @@ it("recovers a missing retained closure by importing the originating bundle", as expect(parseEvidence(retained.structuredContent)).toEqual(aggregate); }); -it("aggregates selected retained observations within a caller's input budget", async () => { - // Distinct authenticated observations may have identical derived contents. - const comparisons = Array.from({ length: 12 }, (_, observation) => - createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { - operation: "compare_functions", - predicateType: "rea.function-comparison", - parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, - result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, - confidence: "derived", - authority: "analyst-inference", - limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, - evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, - }), - ); - const directory = await createTestTempDirectory("rea-retained-comparisons-"); - const path = join(directory, "comparisons.json"); - await writeFile( - path, - JSON.stringify( - createEvidenceBundle([ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - ...comparisons, - ]), - ), - ); - const { call } = await connectLocalToolsMcp({ - REA_MCP_MAX_INPUT_ELEMENTS: "1024", - }); - const imported = await call("import_evidence_bundle", { path }); - expect(imported.isError, JSON.stringify(imported)).not.toBe(true); - const inline = await call("find_changed_behavior", { comparisons }); - expect(parseMcpToolError(inline).error).toMatchObject({ - code: "resource_constraint", - details: { reported_limits: { max_input_elements: 1024 } }, - }); - const response = await call("find_changed_behavior", { - comparisons: comparisons.map(({ evidence_id }) => ({ - kind: "retained-evidence", - evidence_id, - })), - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const aggregate = parseEvidence(response.structuredContent); - const result = changedBehaviorResultSchema.parse(aggregate.normalized_result); - expect(result.behavior_status).toBe("unknown"); - expect(result.summary.observed_changes).toBe(0); - expect(aggregate.parameters.comparison_evidence_ids).toEqual( - comparisons.map(({ evidence_id }) => evidence_id), - ); - expect( - new Set( - result.findings.items.map( - ({ source_comparison_id }) => source_comparison_id, +it.each(["compact", "full"] as const)( + "aggregates retained observations within the %s input profile budget", + async (profile) => { + // Distinct authenticated observations may have identical derived contents. + const comparisons = Array.from({ length: 12 }, (_, observation) => + createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { + operation: "compare_functions", + predicateType: "rea.function-comparison", + parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, + result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, + confidence: "derived", + authority: "analyst-inference", + limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, + evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, + }), + ); + const directory = await createTestTempDirectory( + "rea-retained-comparisons-", + ); + const path = join(directory, "comparisons.json"); + await writeFile( + path, + JSON.stringify( + createEvidenceBundle([ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + ...comparisons, + ]), ), - ), - ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); - expect(aggregate.evidence_links).toEqual( - expect.arrayContaining([ - FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, - FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, - ...comparisons.map(({ evidence_id }) => evidence_id), - ]), - ); -}); + ); + const { call } = await connectLocalToolsMcp({ + REA_MCP_MAX_INPUT_ELEMENTS: "1024", + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + // Both refusals happen before any call initializes the deferred runtime. + const inline = await call("find_changed_behavior", { comparisons }); + expect(parseMcpToolError(inline).error).toMatchObject({ + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + details: { reported_limits: { max_input_elements: 1024 } }, + }); + const unrelated = parseMcpToolError( + await call("read_bytes", { + address: "0x1000", + length: 1, + extra: Array.from({ length: 1024 }, () => 0), + }), + ).error; + expect(unrelated.code).toBe("resource_constraint"); + expect(unrelated.remediation.action).not.toContain("retained-evidence"); + const inlineView = parseMcpToolError( + await call("inspect_analysis_view", { + source: { + kind: "inline", + evidence: { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: Array.from({ length: 1024 }, () => "limit"), + }, + }, + view: { kind: "summary" }, + }), + ).error; + expect(inlineView.code).toBe("resource_constraint"); + expect(inlineView.remediation.action).toContain("retained-evidence"); + const referenceOnly = parseMcpToolError( + await call("export_environment_bundle", { + source: { + kind: "retained-evidence", + evidence_id: FUNCTION_COMPARISON_EVIDENCE.evidence_id, + }, + output_directory: "/unused", + template_rules: Array.from({ length: 1024 }, () => "unused"), + }), + ).error; + expect(referenceOnly.code).toBe("resource_constraint"); + expect(referenceOnly.remediation.action).not.toContain("retained-evidence"); + const imported = await call("import_evidence_bundle", { path }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + const response = await call("find_changed_behavior", { + comparisons: comparisons.map(({ evidence_id }) => ({ + kind: "retained-evidence", + evidence_id, + })), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const aggregate = parseEvidence(response.structuredContent); + const result = changedBehaviorResultSchema.parse( + aggregate.normalized_result, + ); + expect(result.behavior_status).toBe("unknown"); + expect(result.summary.observed_changes).toBe(0); + expect(aggregate.parameters.comparison_evidence_ids).toEqual( + comparisons.map(({ evidence_id }) => evidence_id), + ); + expect( + new Set( + result.findings.items.map( + ({ source_comparison_id }) => source_comparison_id, + ), + ), + ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); + expect(aggregate.evidence_links).toEqual( + expect.arrayContaining([ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ...comparisons.map(({ evidence_id }) => evidence_id), + ]), + ); + }, +); diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index f52e3f166b..3a5e7ba962 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -670,23 +670,41 @@ const copyCompiledPackage = async (prefix: string): Promise => { return directory; }; +/** + * Modules `record-mcp-catalog.mjs` loads before it owns cleanup. The fixture + * replaces every one of these; an import outside the list fails closed. + */ +const RECORDER_RUNTIME_MODULES = [ + "server/createServer.js", + "composition/binary.js", + "config/parseConfig.js", + "logger.js", + "contracts/promptContracts.js", + "contracts/toolContracts.js", + "domain/evidenceInputContract.js", +] as const; + // Recorder cleanup tests replace every runtime import through their loader // hook. Keep only resolvable sentinels so an unmodeled import fails closed. const createRecorderCleanupFixture = async (): Promise => { const directory = await createTestTempDirectory("rea-catalog-cleanup-"); await mkdir(join(directory, "scripts"), { recursive: true }); - await cp( - resolve("scripts/record-mcp-catalog.mjs"), - join(directory, "scripts/record-mcp-catalog.mjs"), - ); + const scriptPath = resolve("scripts/record-mcp-catalog.mjs"); + const imported = [ + ...((await readFile(scriptPath, "utf8")).matchAll(/read\("([^"]+)"\)/g) ?? + []), + ].map((match) => match[1]); + if ( + imported.length !== RECORDER_RUNTIME_MODULES.length || + imported.some((name, index) => name !== RECORDER_RUNTIME_MODULES[index]) + ) { + throw new Error( + `record-mcp-catalog.mjs runtime imports ${JSON.stringify(imported)} do not match the cleanup fixture ${JSON.stringify(RECORDER_RUNTIME_MODULES)}`, + ); + } + await cp(scriptPath, join(directory, "scripts/record-mcp-catalog.mjs")); await cp(resolve("package.json"), join(directory, "package.json")); - for (const path of [ - "server/createServer.js", - "composition/binary.js", - "config/parseConfig.js", - "logger.js", - "contracts/promptContracts.js", - ]) { + for (const path of RECORDER_RUNTIME_MODULES) { const file = join(directory, "dist", path); await mkdir(dirname(file), { recursive: true }); await writeFile( @@ -771,6 +789,11 @@ it.each([ "/dist/logger.js": "export const silentLogger = {};", "/dist/contracts/promptContracts.js": "export const PROMPT_CONTRACTS = [];", + // The recorder derives Evidence-input names from these contracts. This + // fault injection has no tools, so the derived list stays empty. + "/dist/contracts/toolContracts.js": "export const TOOL_CONTRACTS = [];", + "/dist/domain/evidenceInputContract.js": + "export const getEvidenceInputContract = () => ({ hasEvidenceReferenceAlternative: false });", }; const clientModule = `data:text/javascript,${encodeURIComponent(` export class Client { @@ -803,6 +826,9 @@ registerHooks({ }, }); `)}`; + expect( + Object.keys(replacements).map((path) => path.slice("/dist/".length)), + ).toEqual([...RECORDER_RUNTIME_MODULES]); const child = spawnSync( process.execPath, [`--import=${hook}`, join(directory, "scripts/record-mcp-catalog.mjs")], diff --git a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts index 0e95800cf4..162a21f606 100644 --- a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts +++ b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts @@ -108,7 +108,10 @@ it("keeps input capacity limits while composing dossiers that cannot be resent i arguments: { ...selection, functions: [source] }, }); expect(parseMcpToolError(inline)).toMatchObject({ - error: { code: "resource_constraint" }, + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, }); const retained = await client.callTool({ name: "build_call_path", diff --git a/tests/boundary/mcp/toolInputCapacityHttp.test.ts b/tests/boundary/mcp/toolInputCapacityHttp.test.ts index 9ac16a46e7..aa3619f865 100644 --- a/tests/boundary/mcp/toolInputCapacityHttp.test.ts +++ b/tests/boundary/mcp/toolInputCapacityHttp.test.ts @@ -136,6 +136,7 @@ processTest( }); } else { expect(error.code).toBe("resource_constraint"); + expect(error.remediation.action).toContain("retained-evidence"); expect(error.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 }, @@ -183,6 +184,9 @@ processTest( }); const calibratedError = parseMcpToolError(calibration).error; expect(calibratedError.code).toBe("resource_constraint"); + expect(calibratedError.remediation.action).not.toContain( + "retained-evidence", + ); expect(calibratedError.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 },