From b8f58fd7ca375f2dff2e5a62b409df976226611d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:54:01 +0800 Subject: [PATCH] refactor(mcp): bind summary delivery to tool contracts --- src/contracts/artifactToolContracts.ts | 4 + src/contracts/managed/managedToolContracts.ts | 2 + src/contracts/native/nativeToolContracts.ts | 3 + src/contracts/officialToolContracts.ts | 12 +++ src/contracts/toolContractTypes.ts | 4 + src/server/registerArtifactTools.ts | 21 +---- src/server/registerBinaryDiagnosticsTools.ts | 10 +-- src/server/registerByteEntropyTool.ts | 10 +-- src/server/registerEvidenceTools.ts | 52 ++++++------- src/server/registerManagedTools.ts | 22 ++---- src/server/registerNativeTools.ts | 16 +--- src/server/registerOfficialTools.ts | 22 +----- src/server/registerWebNetworkCaptureTool.ts | 6 +- src/server/retainedSummaryDelivery.ts | 33 ++++---- .../mcp/analysisSnapshotProvenanceMcp.test.ts | 7 -- .../mcp/nativeLoadImageViewsMcp.test.ts | 76 ++++++++++++++++++- 16 files changed, 158 insertions(+), 142 deletions(-) diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 6b299aad20..1ffaa007a7 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -58,6 +58,7 @@ const artifact = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const outputSchema = requireOutputSchema(artifactOutputSchemas, name); return { @@ -66,6 +67,7 @@ const artifact = < description, kind: "artifact-provider", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema, outputSchema, examples: [ @@ -83,6 +85,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "inspect_artifact", "Inspect an active archive or application package, including Node.js SEA carriers, in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. For PCK and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.", artifactInspectionInputSchema, + { evidenceParameters: "preserve-detail" }, ), artifact( "extract_artifact", @@ -103,6 +106,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.", ), }), + { evidenceParameters: "omit-detail" }, ), artifact( "inspect_keyed_archive", diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 2ae07e1042..63beee1f3d 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -60,6 +60,7 @@ const nativeBoundaryOutputSchema = requireOutputSchema( export const MANAGED_TOOL_CONTRACTS = [ { name: "inspect_managed_artifact", + retainedSummary: { evidenceParameters: "omit-detail" }, ...toolContractMetadata("inspect_managed_artifact"), description: 'Inspect an explicit PE/CLI path, or the selected managed target, without loading or executing target code. Omitted or complete detail returns complete metadata inventories with original coverage. Select detail: "summary" on the first call for complete identity, PE/CLI, classification with its evidence, module/MVID, assembly, frameworks, returned-collection counts, coverage/issues and limitations. It retains complete Evidence and returns a distinct linked inspect_analysis_view summary. Page original references/resources/attributes with view kind managed and normalized_result.parent_evidence_id. Counts cover producer-returned rows; partial, unavailable or malformed metadata remains explicit and tokens remain build-local.', @@ -80,6 +81,7 @@ export const MANAGED_TOOL_CONTRACTS = [ }, { name: "inspect_managed_members", + retainedSummary: { evidenceParameters: "omit-detail" }, ...toolContractMetadata("inspect_managed_members"), description: 'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.', diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index 0fde5e4420..dedfbc8f88 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -48,6 +48,7 @@ const native = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const outputSchema = requireOutputSchema(nativeOutputSchemas, name); const strictInputSchema = inputSchema.strict(); @@ -57,6 +58,7 @@ const native = < description, kind: "native-provider", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema: strictInputSchema, outputSchema, examples: [ @@ -108,6 +110,7 @@ export const NATIVE_TOOL_CONTRACTS = [ ), native_command_timeout_ms: nativeCommandTimeoutField, }), + { evidenceParameters: "omit-detail" }, ), native( "inspect_signature", diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index a81bc86bb5..7c4fbde59a 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -30,6 +30,7 @@ const official = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const strictInputSchema = inputSchema.strict(); return { @@ -38,6 +39,7 @@ const official = < description, kind: "official-proxy", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema: strictInputSchema, outputSchema: requireOutputSchema(officialOutputSchemas, name), examples: examplesFor(name, strictInputSchema), @@ -55,6 +57,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "inspect_native_load_image", "Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.", z.strictObject({ detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "inspect_native_data_type", @@ -124,11 +127,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ address: optionalAddress, detail: analysisDetailSchema, }), + { evidenceParameters: "omit-detail" }, ), official( "list_procedures", "List every analyzed procedure as address/value pairs after provider analysis. Ghidra metadata distinguishes thunks and external functions; use returned addresses in later function operations. Select detail summary for counts and retained native-inventory pages; complete remains the default.", z.object({ document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "list_segments", @@ -143,6 +148,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ address: optionalAddress, detail: analysisDetailSchema, }), + { evidenceParameters: "omit-detail" }, ), official( "next_address", @@ -158,6 +164,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "procedure_address", "Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_assembly", @@ -168,11 +175,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "procedure_callees", "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_callers", "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_info", @@ -205,16 +214,19 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "resolve_containing_procedure", "Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ address, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "search_procedures", `Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "search_strings", `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "set_address_name", diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index b329ad88da..023fa81e9c 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -57,6 +57,10 @@ export interface ToolContract< readonly kind: ToolKind; /** This handler dispatches through the active binary-analysis session. */ readonly requiresActiveTarget?: boolean; + /** Adapter delivery of a retained summary; detail never reaches analysis. */ + readonly retainedSummary?: { + readonly evidenceParameters: "omit-detail" | "preserve-detail"; + }; readonly inputSchema: InputSchema; readonly outputSchema: OutputSchema; readonly effects: ToolEffects; diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index a29497aa4c..80d39739df 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -3,22 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -/** Operations whose MCP delivery may be a retained summary view. */ -const SUMMARY_DELIVERY: ReadonlySet = new Set([ - "inspect_artifact", - "decode_interface_builder", -]); -const executionWithoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); -/** inspect_artifact keeps an explicit detail in its Evidence parameters. */ -const decodeWithoutDelivery = withoutDeliveryDetail( - new Set(["decode_interface_builder"]), -); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Register deterministic artifact inventory and safe extraction operations. */ export const registerArtifactTools = ( @@ -27,11 +12,9 @@ export const registerArtifactTools = ( ): void => { registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, { ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: (operation, parameters) => operation === "extract_artifact" ? { ...parameters, output_root: createArtifactExtractionDestination() } - : executionWithoutDelivery(operation, parameters), - parametersForEvidence: decodeWithoutDelivery, + : parameters, }); }; diff --git a/src/server/registerBinaryDiagnosticsTools.ts b/src/server/registerBinaryDiagnosticsTools.ts index 586411aa33..b97f910b99 100644 --- a/src/server/registerBinaryDiagnosticsTools.ts +++ b/src/server/registerBinaryDiagnosticsTools.ts @@ -35,7 +35,7 @@ export const registerBinaryDiagnosticsTools = ( withAdmittedAnalysis, ); const contract = toolContract("inspect_binary_layout"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -68,17 +68,13 @@ export const registerBinaryDiagnosticsTools = ( logger, contract.name, async () => { - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await service.inspect(request, { signal: context.mcpReq.signal, }); return inspected.ok - ? delivery.projectEvidence( - contract.name, - parameters, - inspected.value, - ) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, ); diff --git a/src/server/registerByteEntropyTool.ts b/src/server/registerByteEntropyTool.ts index 012aa3d59d..be22ec9a2f 100644 --- a/src/server/registerByteEntropyTool.ts +++ b/src/server/registerByteEntropyTool.ts @@ -22,7 +22,7 @@ export const registerByteEntropyTool = ( withAdmittedAnalysis: WithAdmittedAnalysis | undefined, ): void => { const contract = toolContract("inspect_byte_entropy"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -41,17 +41,13 @@ export const registerByteEntropyTool = ( contract.name, async () => { const parameters = { detail }; - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await entropyService.inspect(request, { signal: context.mcpReq.signal, }); return inspected.ok - ? delivery.projectEvidence( - contract.name, - parameters, - inspected.value, - ) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, ); diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 59f4910b85..85b7caf5a5 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -1,17 +1,20 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; import type { AnalysisOperation, AnalysisOperationPort, } from "../application/AnalysisProvider.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; +import { ok } from "../domain/result.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Evidence } from "../domain/evidence.js"; import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { ok, type Result } from "../domain/result.js"; import type { Logger } from "pino"; import { mcpProgressReporter } from "./mcpProgress.js"; import { logToolExecution } from "./toolLogging.js"; @@ -27,6 +30,7 @@ interface EvidenceToolRegistration { readonly logger: Logger; readonly activeTarget: (() => BinaryTarget | undefined) | undefined; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById?: EvidenceToolFamilyOptions["evidenceById"]; readonly withAdmittedAnalysis: WithAdmittedAnalysis; readonly analysisForAdmission?: ( analysis: AnalysisOperationPort, @@ -35,21 +39,6 @@ interface EvidenceToolRegistration { operation: Exclude, parameters: Readonly>, ) => Readonly>; - /** Exclude adapter-only selections, such as delivery detail, from Evidence identity. */ - readonly parametersForEvidence?: ( - operation: Exclude, - parameters: Readonly>, - ) => Readonly>; - /** Refuse an unsatisfiable delivery selection before any analysis runs. */ - readonly preflight?: ( - operation: Exclude, - parameters: Readonly>, - ) => Result; - readonly projectEvidence?: ( - operation: Exclude, - parameters: Readonly>, - evidence: Evidence, - ) => Result; } /** Register provider-backed contracts that return atomic Evidence observations. */ @@ -59,14 +48,17 @@ export const registerEvidenceTools = ( options: EvidenceToolRegistration, ): void => { for (const contract of contracts) { + const delivery = + contract.retainedSummary === undefined + ? undefined + : retainedSummaryDelivery(contract.name, options); server.registerTool( contract.name, toolRegistrationOptions(contract), async (input, context) => { const parameters = jsonObjectSchema.parse(input); - const ready = options.preflight?.(contract.name, parameters); - if (ready !== undefined && !ready.ok) - return server.delivery.toCallToolResult(ready, contract); + const ready = delivery?.preflight(parameters) ?? ok(null); + if (!ready.ok) return server.delivery.toCallToolResult(ready, contract); const admitted = await options.withAdmittedAnalysis( contract.name, context.mcpReq.signal, @@ -76,9 +68,15 @@ export const registerEvidenceTools = ( admittedAnalysis; const progress = mcpProgressReporter(context); await reportOperationStart(progress, contract.name); + const providerParameters = + contract.retainedSummary === undefined + ? parameters + : withoutDeliveryDetail(parameters); const executionParameters = - options.parametersForExecution?.(contract.name, parameters) ?? - parameters; + options.parametersForExecution?.( + contract.name, + providerParameters, + ) ?? providerParameters; const execution = await logToolExecution( options.logger, contract.name, @@ -94,8 +92,9 @@ export const registerEvidenceTools = ( const sourceEvidence = execution.value.sourceEvidence ?? []; const evidence = createAnalysisExecutionEvidence( contract.name, - options.parametersForEvidence?.(contract.name, parameters) ?? - parameters, + contract.retainedSummary?.evidenceParameters === "omit-detail" + ? providerParameters + : parameters, { ...execution.value, subject: @@ -112,8 +111,7 @@ export const registerEvidenceTools = ( if (!sourceRecorded.ok) return server.delivery.toCallToolResult(sourceRecorded, contract); const projected = - options.projectEvidence?.(contract.name, parameters, evidence) ?? - ok(evidence); + delivery?.projectEvidence(parameters, evidence) ?? ok(evidence); if (!projected.ok) return server.delivery.toCallToolResult(projected, contract); const recorded = options.recordEvidence?.(projected.value); diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index f5929243f8..01bcef8a64 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -1,22 +1,14 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; -import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js"; +import { + MANAGED_TOOL_CONTRACTS, + isManagedToolName, +} from "../contracts/managed/managedToolContracts.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { err } from "../domain/result.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; import { runManagedProviderExecution } from "../composition/directAnalysis.js"; -import { isManagedToolName } from "../contracts/managed/managedToolContracts.js"; - -/** Operations whose MCP delivery may be a retained summary view. */ -const SUMMARY_DELIVERY: ReadonlySet = new Set([ - "inspect_managed_artifact", - "inspect_managed_members", -]); /** Register execution-free managed PE/CLI inspection. */ export const registerManagedTools = ( @@ -24,7 +16,6 @@ export const registerManagedTools = ( options: EvidenceToolFamilyOptions, ): void => { let selectedManagedPath: string | undefined; - const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); const analysisForAdmission = ( analysis: AnalysisOperationPort, ): AnalysisOperationPort => ({ @@ -64,9 +55,6 @@ export const registerManagedTools = ( }); registerEvidenceTools(server, MANAGED_TOOL_CONTRACTS, { ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), - parametersForExecution: withoutDelivery, - parametersForEvidence: withoutDelivery, analysisForAdmission, }); }; diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts index 62668daa14..e22c297d50 100644 --- a/src/server/registerNativeTools.ts +++ b/src/server/registerNativeTools.ts @@ -2,24 +2,12 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -const SUMMARY_DELIVERY: ReadonlySet = new Set(["inspect_macho"]); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */ export const registerNativeTools = ( server: EvidenceMcpServer, options: EvidenceToolFamilyOptions, ): void => { - const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); - registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, { - ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), - parametersForExecution: withoutDelivery, - parametersForEvidence: withoutDelivery, - }); + registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, options); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 1f70a1237d..11e9904c33 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -1,30 +1,12 @@ -import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../domain/native/nativeObservation.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; -import { NATIVE_INVENTORY_OPERATIONS } from "../domain/native/nativeInventory.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -const SUMMARY_OPERATIONS: ReadonlySet = new Set([ - ...NATIVE_INVENTORY_OPERATIONS, - ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, - "inspect_native_load_image", -]); -const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Direct tools share Evidence recording/projection with the other provider-backed tools. */ export const registerOfficialTools = ( server: EvidenceMcpServer, options: EvidenceToolFamilyOptions, ): void => { - registerEvidenceTools(server, OFFICIAL_TOOL_CONTRACTS, { - ...options, - ...retainedSummaryDelivery(SUMMARY_OPERATIONS, options), - parametersForExecution: withoutDetail, - parametersForEvidence: withoutDetail, - }); + registerEvidenceTools(server, OFFICIAL_TOOL_CONTRACTS, options); }; diff --git a/src/server/registerWebNetworkCaptureTool.ts b/src/server/registerWebNetworkCaptureTool.ts index 83d82d6440..27c130dfd9 100644 --- a/src/server/registerWebNetworkCaptureTool.ts +++ b/src/server/registerWebNetworkCaptureTool.ts @@ -23,7 +23,7 @@ export const registerWebNetworkCaptureTool = ( evidenceById?: EvidenceLookup, ): void => { const contract = toolContract("inspect_web_network_capture"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -49,11 +49,11 @@ export const registerWebNetworkCaptureTool = ( ); const { detail, ...input } = parsed.data; const parameters = { detail }; - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await service.inspect(input, options); return inspected.ok - ? delivery.projectEvidence(contract.name, parameters, inspected.value) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, logger, diff --git a/src/server/retainedSummaryDelivery.ts b/src/server/retainedSummaryDelivery.ts index 0bc703a7c1..088eb329f0 100644 --- a/src/server/retainedSummaryDelivery.ts +++ b/src/server/retainedSummaryDelivery.ts @@ -23,40 +23,33 @@ export interface EvidenceToolFamilyOptions { type Parameters = Readonly>; /** - * Summary delivery for the named operations: refuse it before analysis when + * Summary delivery for one named operation: refuse it before analysis when * this server retains no Evidence, then return the retained summary view. */ export const retainedSummaryDelivery = ( - operations: ReadonlySet, + operation: string, options: Pick, ) => { const retention = { recordEvidence: options.recordEvidence, evidenceById: options.evidenceById, }; - const selected = (operation: string, parameters: Parameters): boolean => - operations.has(operation) && parameters.detail === "summary"; + const selected = (parameters: Parameters): boolean => + parameters.detail === "summary"; return { - preflight: (operation: string, parameters: Parameters) => - selected(operation, parameters) + preflight: (parameters: Parameters) => + selected(parameters) ? requireAnalysisViewRetention(operation, retention) : ok(null), - projectEvidence: ( - operation: string, - parameters: Parameters, - evidence: Evidence, - ) => - selected(operation, parameters) + projectEvidence: (parameters: Parameters, evidence: Evidence) => + selected(parameters) ? summarizeRetainedAnalysis(evidence, retention) : ok(evidence), }; }; -/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */ -export const withoutDeliveryDetail = - (operations: ReadonlySet) => - (operation: string, parameters: Parameters): Parameters => { - if (!operations.has(operation)) return parameters; - const { detail: _detail, ...selected } = parameters; - return selected; - }; +/** Delivery detail selects a response shape; it never reaches analysis. */ +export const withoutDeliveryDetail = (parameters: Parameters): Parameters => { + const { detail: _detail, ...selected } = parameters; + return selected; +}; diff --git a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts index 365e4ecea6..572088d229 100644 --- a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts +++ b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts @@ -16,7 +16,6 @@ import { silentLogger } from "../../../src/logger.js"; import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js"; import { registerEvidenceTools } from "../../../src/server/registerEvidenceTools.js"; -import { withoutDeliveryDetail } from "../../../src/server/retainedSummaryDelivery.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -152,12 +151,6 @@ for (const missingSource of [false, true]) kind: "session", session, }), - parametersForExecution: withoutDeliveryDetail( - new Set(["inspect_managed_artifact"]), - ), - parametersForEvidence: withoutDeliveryDetail( - new Set(["inspect_managed_artifact"]), - ), }, ); const client = await mcp.connect(server); diff --git a/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts index 998e2cf460..967564de28 100644 --- a/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts @@ -1,4 +1,10 @@ -import { expect, it } from "vitest"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { @@ -93,3 +99,71 @@ it("preserves raw and DOL verification facts and selected DOL sections through t }); } }, 30_000); + +it("delivers load-image summaries from the complete retained public-tool observation", async () => { + const report = nativeRawLoadImageFixture(); + const inputs: unknown[] = []; + const session = createTestBinarySession(() => ({ + execute: async (operation, parameters) => { + if (operation === "inspect_native_load_image") inputs.push(parameters); + return ok( + createAnalysisExecution(operation === "health" ? null : report, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + close: async () => ok(null), + })); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "load-image-summary", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const contract = toolContract("inspect_native_load_image"); + const completeReply = await client.callTool({ + name: contract.name, + arguments: {}, + }); + expect(completeReply.isError, JSON.stringify(completeReply.content)).not.toBe( + true, + ); + const complete = parseEvidence( + contract.outputSchema.parse(completeReply.structuredContent), + ); + const summaryReply = await client.callTool({ + name: contract.name, + arguments: { detail: "summary" }, + }); + expect(summaryReply.isError, JSON.stringify(summaryReply.content)).not.toBe( + true, + ); + const summary = parseEvidence( + contract.outputSchema.parse(summaryReply.structuredContent), + ); + expect(complete.parameters).toEqual({}); + expect(complete.normalized_result).toEqual(report); + expect(summary.normalized_result).toMatchObject({ + kind: "summary", + parent_operation: contract.name, + parent_evidence_id: complete.evidence_id, + source_parameters: {}, + }); + expect(session.evidenceById(complete.evidence_id)).toEqual(complete); + expect(inputs.length).toBeGreaterThan(0); + expect(inputs).not.toContainEqual( + expect.objectContaining({ detail: expect.anything() }), + ); +});