From 1712e6d821965ce5edc3880fe4ebab2e3cc02fd4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:36:40 +0000 Subject: [PATCH] feat!: make evidence bundles canonical MCP resources Add immutable session bundle snapshots, bounded resource discovery, paginated unknowns, mutable current analysis snapshots, and provider-neutral navigation/address context tools. BREAKING CHANGE: export_evidence_bundle now requires a filesystem path and no longer returns an inline bundle. --- src/application/AnalysisContextQueries.ts | 125 ++++++++++++ src/application/BinarySessionPort.ts | 13 ++ src/application/BinarySessionRecords.ts | 80 +++++++- src/catalogIdentity.ts | 6 +- src/contracts/toolContractExamples.ts | 2 + src/contracts/toolContracts.ts | 33 +++- src/contracts/toolEffects.ts | 3 + src/contracts/toolOutputSchemaGroups.ts | 69 ++++++- src/server/createServer.ts | 5 +- src/server/hydrateServerTools.ts | 5 +- src/server/registerEvidenceResources.ts | 178 +++++------------- src/server/registerInvestigationTools.ts | 3 +- ...isterJavaScriptApplicationGraphResource.ts | 5 - ...rReconstructionObligationLedgerResource.ts | 11 -- ...registerReconstructionReadinessResource.ts | 11 -- src/server/registerSessionRecordTools.ts | 112 +++++++++-- src/server/registerSessionTools.ts | 61 +++++- src/server/toolResult.ts | 21 +++ tests/analysisContextQueries.test.ts | 140 ++++++++++++++ tests/artifactMcp.test.ts | 30 ++- tests/contractSnapshot.test.ts | 5 +- tests/evidenceResources.test.ts | 78 +++++++- tests/functionComparisonMcp.test.ts | 24 ++- tests/ghidraMcp.test.ts | 36 ++-- tests/integration/serverIntegration.test.ts | 66 ++++--- tests/sessionMcp.test.ts | 67 ++++--- tests/toolContracts.test.ts | 3 + 27 files changed, 901 insertions(+), 291 deletions(-) create mode 100644 src/application/AnalysisContextQueries.ts create mode 100644 tests/analysisContextQueries.test.ts diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts new file mode 100644 index 000000000..a525c93de --- /dev/null +++ b/src/application/AnalysisContextQueries.ts @@ -0,0 +1,125 @@ +import type { + AnalysisExecution, + AnalysisOperation, + AnalysisOperationPort, +} from "./AnalysisProvider.js"; +import type { AnalysisError } from "../domain/errors.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { ok, type Result } from "../domain/result.js"; + +type Facet = + | { readonly state: "available"; readonly value: JsonValue } + | { + readonly state: "unavailable"; + readonly reason: string; + readonly remediation: string; + }; + +const parameters = ( + document: string | undefined, + address?: string, +): Readonly> => ({ + ...(document === undefined ? {} : { document }), + ...(address === undefined ? {} : { address }), +}); + +const executionOptions = ( + signal: AbortSignal | undefined, +): { readonly signal?: AbortSignal } => + signal === undefined ? {} : { signal }; + +/** Compose the provider's volatile selection state into one coherent query. */ +export const getNavigationContext = async ( + analysis: AnalysisOperationPort, + input: { readonly document?: string | undefined }, + signal?: AbortSignal, +): Promise> => { + const args = parameters(input.document); + const [document, address, procedure] = await Promise.all([ + input.document === undefined + ? analysis.execute("current_document", {}, executionOptions(signal)) + : Promise.resolve(input.document), + analysis.execute("current_address", args, executionOptions(signal)), + analysis.execute("current_procedure", args, executionOptions(signal)), + ]); + if (typeof document !== "string" && !document.ok) return document; + if (!address.ok) return address; + if (!procedure.ok && !isMissingCurrentProcedure(procedure.error)) + return procedure; + return ok({ + document: typeof document === "string" ? document : document.value.result, + address: address.value.result, + procedure: procedure.ok ? procedure.value.result : null, + }); +}; + +/** Inspect bounded provider-neutral facets at one explicit reproducible address. */ +export const inspectAddressContext = async ( + analysis: AnalysisOperationPort, + input: { readonly address: string; readonly document?: string | undefined }, + signal?: AbortSignal, +): Promise> => { + const args = parameters(input.document, input.address); + const operations = [ + "address_name", + "resolve_containing_procedure", + "comment", + "inline_comment", + "list_bookmarks", + ] as const satisfies readonly AnalysisOperation[]; + const results = await Promise.all( + operations.map((operation) => + analysis.execute( + operation, + operation === "list_bookmarks" ? parameters(input.document) : args, + executionOptions(signal), + ), + ), + ); + const name = facet(results[0], operations[0]); + const procedure = facet(results[1], operations[1]); + const comment = facet(results[2], operations[2]); + const inlineComment = facet(results[3], operations[3]); + const bookmarks = facet(results[4], operations[4]); + return ok({ + address: input.address, + document: input.document ?? null, + name, + procedure, + comment, + inline_comment: inlineComment, + bookmarks: + bookmarks.state === "available" + ? { + state: "available", + value: matchingBookmarks(bookmarks.value, input.address), + } + : bookmarks, + }); +}; + +const facet = ( + result: Result | undefined, + operation: AnalysisOperation, +): Facet => + result?.ok === true + ? { state: "available", value: result.value.result } + : { + state: "unavailable", + reason: result?.error.message ?? `${operation} returned no result`, + remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`, + }; + +const isMissingCurrentProcedure = (error: AnalysisError): boolean => + /(?:no procedure exists|not in a procedure)/iu.test(error.message); + +const matchingBookmarks = (value: JsonValue, address: string): JsonValue => { + if (!Array.isArray(value)) return []; + return value.filter( + (bookmark) => + typeof bookmark === "object" && + bookmark !== null && + !Array.isArray(bookmark) && + bookmark.address === address, + ); +}; diff --git a/src/application/BinarySessionPort.ts b/src/application/BinarySessionPort.ts index 66d391aae..29878fbc1 100644 --- a/src/application/BinarySessionPort.ts +++ b/src/application/BinarySessionPort.ts @@ -27,6 +27,16 @@ import type { ProviderIdentity, } from "./AnalysisProvider.js"; +/** Compact identity and summary for one retained immutable Evidence bundle. */ +export interface EvidenceBundleSnapshot { + readonly bundleDigest: string; + readonly bundleVersion: 2; + readonly bytes: number; + readonly records: number; + readonly unknowns: number; + readonly uri: string; +} + /** Target lifecycle used by CLI and MCP without exposing a concrete provider. */ export interface BinarySessionPort extends AnalysisOperationPort { open( @@ -49,6 +59,8 @@ export interface BinarySessionPort extends AnalysisOperationPort { hasEvidence(evidenceId: string): boolean; evidenceById(evidenceId: string): Evidence | undefined; exportEvidenceBundle(): EvidenceBundle; + snapshotEvidenceBundle(): Result; + retainedEvidenceBundle(digest: string): string | undefined; importEvidenceBundle( bundle: unknown, ): Result; @@ -101,4 +113,5 @@ export interface BinarySessionPort extends AnalysisOperationPort { ): AnalysisProfileCommitment | undefined; openCompatibility(): Readonly>; onAvailabilityChanged?(listener: () => void | Promise): () => void; + onAnalysisSnapshotChanged?(listener: () => void | Promise): () => void; } diff --git a/src/application/BinarySessionRecords.ts b/src/application/BinarySessionRecords.ts index c0a7be626..4b9e164a2 100644 --- a/src/application/BinarySessionRecords.ts +++ b/src/application/BinarySessionRecords.ts @@ -3,11 +3,14 @@ import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import type { EvidenceBundle } from "../domain/evidenceBundle.js"; -import { evidenceBundleForTarget } from "../domain/evidenceBundle.js"; +import { + evidenceBundleForTarget, + serializeEvidenceBundle, +} from "../domain/evidenceBundle.js"; import { EvidenceIntegrityError, + EvidenceLimitError, type AnalysisError, - type EvidenceLimitError, type UnknownRegistryError, } from "../domain/errors.js"; import { @@ -24,7 +27,8 @@ import type { UnknownStatus, UpdateUnknownInput, } from "../domain/residualUnknown.js"; -import { err, type Result } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { EvidenceBundleSnapshot } from "./BinarySessionPort.js"; import type { AnalysisExecution, AnalysisOperation, @@ -44,17 +48,28 @@ export interface ActiveAnalysisBinding { /** Owns session evidence, snapshots, workspaces, and residual unknowns. */ export abstract class BinarySessionRecords { + static readonly MAX_RETAINED_BUNDLES = 16; + static readonly MAX_RETAINED_BUNDLE_BYTES = 64 * 1024 * 1024; readonly #evidence = new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024, }); readonly #snapshot = new AnalysisSnapshotCache(); + readonly #retainedBundles = new Map(); + #retainedBundleBytes = 0; readonly #investigationWorkspaces = new Map(); readonly #coverageWorkspaces = new Map< string, ReconstructionCoverageWorkspace >(); #snapshotInvalidated = false; + readonly #snapshotListeners = new Set<() => void | Promise>(); + + /** Observe changes to the mutable current analysis snapshot resource. */ + onAnalysisSnapshotChanged(listener: () => void | Promise): () => void { + this.#snapshotListeners.add(listener); + return () => this.#snapshotListeners.delete(listener); + } recordEvidence( evidence: Evidence, @@ -77,6 +92,49 @@ export abstract class BinarySessionRecords { return this.#evidence.export(); } + snapshotEvidenceBundle(): Result { + const bundle = this.#evidence.export(); + const encoded = serializeEvidenceBundle(bundle); + const digest = createHash("sha256").update(encoded).digest("hex"); + const existing = this.#retainedBundles.get(digest); + if (existing === undefined) { + if ( + this.#retainedBundles.size >= BinarySessionRecords.MAX_RETAINED_BUNDLES + ) + return err( + new EvidenceLimitError( + "records", + BinarySessionRecords.MAX_RETAINED_BUNDLES, + ), + ); + const bytes = Buffer.byteLength(encoded); + if ( + this.#retainedBundleBytes + bytes > + BinarySessionRecords.MAX_RETAINED_BUNDLE_BYTES + ) + return err( + new EvidenceLimitError( + "bytes", + BinarySessionRecords.MAX_RETAINED_BUNDLE_BYTES, + ), + ); + this.#retainedBundles.set(digest, encoded); + this.#retainedBundleBytes += bytes; + } + return ok({ + bundleDigest: digest, + bundleVersion: 2, + bytes: Buffer.byteLength(existing ?? encoded), + records: bundle.records.length, + unknowns: bundle.unknowns.length, + uri: `rea://evidence-bundle/${digest}`, + }); + } + + retainedEvidenceBundle(digest: string): string | undefined { + return this.#retainedBundles.get(digest); + } + importEvidenceBundle( bundle: unknown, ): Result { @@ -120,13 +178,15 @@ export abstract class BinarySessionRecords { "Analysis snapshot profile_mismatch: the active target has no concrete analysis profile", ), ); - return this.#snapshot.import( + const imported = this.#snapshot.import( snapshot, active === undefined ? undefined : { target: active.target, profile: active.profile }, (bundle) => this.#evidence.import(bundle), ); + if (imported.ok) this.#emitSnapshotChanged(); + return imported; } protected matchesSnapshot( @@ -141,6 +201,7 @@ export abstract class BinarySessionRecords { profile: AnalysisProfileCommitment, ): void { this.#snapshot.select(target, profile); + this.#emitSnapshotChanged(); } protected lookupSnapshot( @@ -158,11 +219,13 @@ export abstract class BinarySessionRecords { input: Parameters[0], ): void { this.#snapshot.record(input); + this.#emitSnapshotChanged(); } protected invalidateSnapshot(): void { this.#snapshot.clear(); this.#snapshotInvalidated = true; + this.#emitSnapshotChanged(); } protected resetSnapshotInvalidation(): void { @@ -171,12 +234,20 @@ export abstract class BinarySessionRecords { protected clearSnapshot(): void { this.#snapshot.clear(); + this.#emitSnapshotChanged(); } protected clearSessionRecords(): void { this.#evidence.clear(); this.#snapshot.clear(); this.#snapshotInvalidated = false; + this.#retainedBundles.clear(); + this.#retainedBundleBytes = 0; + this.#emitSnapshotChanged(); + } + + #emitSnapshotChanged(): void { + for (const listener of this.#snapshotListeners) void listener(); } retainInvestigationWorkspace( @@ -307,3 +378,4 @@ const sortedWorkspaces = < left.revision - right.revision, ) .map((workspace) => structuredClone(workspace)); +import { createHash } from "node:crypto"; diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 289cb5fda..1a5971aea 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -35,7 +35,7 @@ const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({ /** Canonical fixed resources exposed by every target-free MCP session. */ export const MCP_RESOURCE_CATALOG = [ { name: "server-identity", uri: "rea://server/identity" }, - { name: "active-residual-unknowns", uri: "rea://unknowns/active" }, + { name: "analysis-snapshot", uri: "rea://snapshot/current" }, ] as const; /** Canonical resource templates exposed by every target-free MCP session. */ export const MCP_RESOURCE_TEMPLATE_CATALOG = [ @@ -46,8 +46,8 @@ export const MCP_RESOURCE_TEMPLATE_CATALOG = [ }, { name: "residual-unknown", uri_template: "rea://unknown/{unknownId}" }, { - name: "analysis-snapshot", - uri_template: "rea://snapshot/{snapshotDigest}", + name: "evidence-bundle", + uri_template: "rea://evidence-bundle/{bundleDigest}", }, { name: "artifact-page", diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 8d170c785..3bda8fb6b 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -40,6 +40,8 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< find_code_for_string: { query: "authorization failed" }, trace_call_path: { start: "0x1000", goal: "0x1100" }, open_binary: { path: "/tmp/fixture" }, + export_evidence_bundle: { path: "/tmp/evidence.json" }, + inspect_address_context: { address: "0x1000" }, import_evidence_bundle: { path: "evidence.json" }, capture_process_scenario: { approved: true, diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 449e38754..101cfe7ad 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -375,10 +375,24 @@ export const ENHANCED_TOOL_CONTRACTS = [ /** Session-owned Evidence bundle export options. */ export const exportEvidenceBundleInputSchema = z.strictObject({ - path: z.string().min(1).optional(), + path: z.string().min(1), overwrite: z.boolean().default(false), }); +/** Retain the current canonical Evidence bundle as a session resource. */ +export const snapshotEvidenceBundleInputSchema = z.strictObject({}); + +/** Optional document selection for volatile navigation context. */ +export const navigationContextInputSchema = z.strictObject({ + document: z.string().min(1).optional(), +}); + +/** Explicit reproducible address context query. */ +export const addressContextInputSchema = z.strictObject({ + address: z.string().min(1), + document: z.string().min(1).optional(), +}); + /** Session-owned Evidence bundle import options. */ export const importEvidenceBundleInputSchema = z.strictObject({ path: z.string().min(1), @@ -403,6 +417,8 @@ export const listUnknownsInputSchema = z.strictObject({ .optional(), severity: z.enum(["low", "medium", "high", "critical"]).optional(), domain: z.string().trim().min(1).max(100).optional(), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), }); /** Exact residual-unknown identity to revalidate. */ @@ -507,6 +523,21 @@ export const SESSION_TOOL_CONTRACTS = [ "Evaluate ordered HTTP and WebSocket events directly against one validated finite replay machine without opening sockets or launching a target. Returns every decision, a capture-value-free transition journal, one redacted action table entry per used transition, captured aliases, final state, and exact configured and consumed limits.", replayMachineRunInputSchema, ), + session( + "snapshot_evidence_bundle", + "Retain the current canonical Evidence v2 bundle as an immutable session resource. Returns a compact digest summary and exact opaque URI; copy that URI unchanged and call MCP resources/read (Codex: read_mcp_resource) for the full bundle. Repeating an unchanged snapshot is idempotent.", + snapshotEvidenceBundleInputSchema, + ), + session( + "get_navigation_context", + "Return the selected document, current address, and containing/current procedure in one coherent provider-neutral request. A cursor outside any procedure returns procedure: null. Scalar navigation getters remain available for evaluation and compatibility.", + navigationContextInputSchema, + ), + session( + "inspect_address_context", + "Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome; xrefs, assembly, and pseudocode remain separate bounded follow-ups.", + addressContextInputSchema, + ), ] as const satisfies readonly ToolContract[]; /** Complete ordered public inventory used by registration and verification. */ diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4d1fe0ff2..958ebf189 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -172,6 +172,9 @@ export const TOOL_EFFECTS: Readonly> = { writesFilesystem: true, mayDiscardData: true, }), + snapshot_evidence_bundle: effects({ mutatesSession: true }), + get_navigation_context: effects({ mutatesSession: true }), + inspect_address_context: effects({ mutatesSession: true }), import_evidence_bundle: effects({ mutatesSession: true }), capture_process_scenario: effects({ mutatesSession: true, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 5be141a74..1a5f4de55 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,10 +1,10 @@ import { z } from "zod"; +import { jsonValueSchema } from "../domain/jsonValue.js"; import { processCaptureComparisonSchema, processCaptureSchema, } from "../domain/processCapture.js"; -import { evidenceBundleSchema } from "../domain/evidenceBundle.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; import { functionInstructionWindowSchema, @@ -67,6 +67,15 @@ import { targetKindSchema, } from "./toolOutputSchemaPrimitives.js"; +const contextFacetSchema = z.discriminatedUnion("state", [ + z.object({ state: z.literal("available"), value: jsonValueSchema }), + z.object({ + state: z.literal("unavailable"), + reason: z.string(), + remediation: z.string(), + }), +]); + /** Exact structured-content schemas shared by direct analysis providers. */ export const officialOutputSchemas: Readonly> = { address_name: resultOf(nullableText), @@ -403,14 +412,40 @@ export const sessionOutputSchemas: Readonly> = { ]), ), export_evidence_bundle: lifecycleResultOf( - z.union([ - evidenceBundleSchema, - z.object({ - path: z.string(), - bytes: z.number().int().min(0), - records: z.number().int().min(0), - }), - ]), + z.object({ + path: z.string(), + bytes: z.number().int().min(0), + records: z.number().int().min(0), + unknowns: z.number().int().min(0), + }), + ), + snapshot_evidence_bundle: lifecycleResultOf( + z.object({ + bundle_digest: z.string().regex(/^[a-f0-9]{64}$/u), + bundle_version: z.literal(2), + bytes: z.number().int().min(0), + records: z.number().int().min(0), + unknowns: z.number().int().min(0), + bundle_uri: z.string().regex(/^rea:\/\/evidence-bundle\/[a-f0-9]{64}$/u), + }), + ), + get_navigation_context: lifecycleResultOf( + z.object({ + document: z.string(), + address: z.string(), + procedure: z.union([z.string(), z.null()]), + }), + ), + inspect_address_context: lifecycleResultOf( + z.object({ + address: z.string(), + document: z.string().nullable(), + name: contextFacetSchema, + procedure: contextFacetSchema, + comment: contextFacetSchema, + inline_comment: contextFacetSchema, + bookmarks: contextFacetSchema, + }), ), import_evidence_bundle: lifecycleResultOf( z.object({ @@ -427,7 +462,21 @@ export const sessionOutputSchemas: Readonly> = { build_call_path: resultOf(callPathResultSchema), correlate_static_and_runtime: resultOf(staticRuntimeCorrelationResultSchema), verify_reconstruction: resultOf(reconstructionVerificationResultSchema), - list_unknowns: lifecycleResultOf(z.array(residualUnknownSchema)), + list_unknowns: lifecycleResultOf( + z.object({ + items: z.array( + z.object({ + unknown: residualUnknownSchema, + uri: z.string().regex(/^rea:\/\/unknown\/unk_[a-f0-9]{64}$/u), + }), + ), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(500), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), + }), + ), record_unknown: lifecycleResultOf(residualUnknownSchema), update_unknown: lifecycleResultOf(residualUnknownSchema), verify_unknown_resolution: lifecycleResultOf( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 35cef4a31..ed4568399 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -123,7 +123,7 @@ export const createServer = ( { capabilities: { tools: { listChanged: true }, - resources: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, }, inputRequired: { maxRounds: 3, @@ -243,8 +243,6 @@ const createSessionRecorders = ( ? undefined : (evidence: Parameters[0]) => { const recorded = session.recordEvidence(evidence); - if (recorded.ok && recorded.value === "added") - server.sendResourceListChanged(); return recorded; }, recordEvidenceWithUnknown: @@ -255,7 +253,6 @@ const createSessionRecorders = ( input: Parameters[1], ) => { const recorded = session.recordEvidenceWithUnknown(evidence, input); - if (recorded.ok) server.sendResourceListChanged(); return recorded; }, }); diff --git a/src/server/hydrateServerTools.ts b/src/server/hydrateServerTools.ts index 2111d2960..94240fafc 100644 --- a/src/server/hydrateServerTools.ts +++ b/src/server/hydrateServerTools.ts @@ -276,10 +276,7 @@ const registerApplicationFamily = async ({ session === undefined ? undefined : (workspace) => { - const retained = - session.retainReconstructionCoverageWorkspace(workspace); - if (retained === "added") server.sendResourceListChanged(); - return retained; + return session.retainReconstructionCoverageWorkspace(workspace); }, replay: { policy: options.javascriptReplayPolicy ?? { diff --git a/src/server/registerEvidenceResources.ts b/src/server/registerEvidenceResources.ts index 4b33740d1..d02234e4f 100644 --- a/src/server/registerEvidenceResources.ts +++ b/src/server/registerEvidenceResources.ts @@ -6,16 +6,12 @@ import { import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; - import type { BinarySessionPort } from "../application/BinarySessionPort.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { registerJavaScriptApplicationGraphResource } from "./registerJavaScriptApplicationGraphResource.js"; import { registerReconstructionObligationLedgerResource } from "./registerReconstructionObligationLedgerResource.js"; import { registerReconstructionReadinessResource } from "./registerReconstructionReadinessResource.js"; -const evidenceUri = (evidenceId: string): string => - `rea://evidence/${evidenceId}`; - /** Expose immutable session-owned Evidence v2 records as MCP resources. */ export const registerEvidenceResources = ( server: McpServer, @@ -24,7 +20,7 @@ export const registerEvidenceResources = ( registerReconstructionCoverageResource(server, session); registerInvestigationWorkspaceResource(server, session); registerSessionEvidenceResource(server, session); - registerActiveResidualUnknownsResource(server, session); + registerEvidenceBundleResource(server, session); registerAnalysisSnapshotResource(server, session); registerArtifactPageResource(server, session); registerFunctionDossierResource(server, session); @@ -33,6 +29,11 @@ export const registerEvidenceResources = ( registerReconstructionReadinessResource(server, session); registerJavaScriptApplicationGraphResource(server, session); registerResidualUnknownResource(server, session); + session.onAnalysisSnapshotChanged?.(() => { + void server.server + .sendResourceUpdated({ uri: "rea://snapshot/current" }) + .catch(() => undefined); + }); }; const registerReconstructionCoverageResource = ( @@ -44,21 +45,7 @@ const registerReconstructionCoverageResource = ( new ResourceTemplate( "rea://reconstruction-coverage/{workspaceId}/revision/{revision}", { - list: () => ({ - resources: session - .reconstructionCoverageWorkspaces() - .map((workspace) => ({ - uri: coverageWorkspaceUri( - workspace.workspace_id, - workspace.revision, - ), - name: `${workspace.workspace_id} revision ${String(workspace.revision)}`, - title: `${workspace.name} coverage revision ${String(workspace.revision)}`, - description: - "Immutable evidence-backed reconstruction coverage workspace revision.", - mimeType: "application/json", - })), - }), + list: undefined, }, ), { @@ -89,34 +76,7 @@ const registerInvestigationWorkspaceResource = ( server.registerResource( "investigation-workspace-revision", new ResourceTemplate("rea://workspace/{workspaceId}/revision/{revision}", { - list: () => ({ - resources: session.investigationWorkspaces().map((workspace) => ({ - uri: workspaceUri(workspace.workspace_id, workspace.revision), - name: `${workspace.workspace_id} revision ${String(workspace.revision)}`, - title: `${workspace.name} revision ${String(workspace.revision)}`, - description: "Immutable CAS-linked investigation workspace revision.", - mimeType: "application/json", - })), - }), - complete: { - workspaceId: (prefix) => - [ - ...new Set( - session - .investigationWorkspaces() - .map(({ workspace_id }) => workspace_id), - ), - ].filter((workspaceId) => workspaceId.startsWith(prefix)), - revision: (prefix, context) => - session - .investigationWorkspaces() - .filter( - ({ workspace_id }) => - context?.arguments?.workspaceId === workspace_id, - ) - .map(({ revision }) => String(revision)) - .filter((revision) => revision.startsWith(prefix)), - }, + list: undefined, }), { title: "Investigation workspace revision", @@ -145,22 +105,7 @@ const registerSessionEvidenceResource = ( server.registerResource( "session-evidence", new ResourceTemplate("rea://evidence/{evidenceId}", { - list: () => ({ - resources: session.exportEvidenceBundle().records.map((evidence) => ({ - uri: evidenceUri(evidence.evidence_id), - name: evidence.evidence_id, - title: `${evidence.operation} evidence`, - description: `Session-owned Evidence v2 record for ${evidence.operation}.`, - mimeType: "application/json", - })), - }), - complete: { - evidenceId: (prefix) => - session - .exportEvidenceBundle() - .records.map(({ evidence_id }) => evidence_id) - .filter((evidenceId) => evidenceId.startsWith(prefix)), - }, + list: undefined, }), { title: "Session evidence", @@ -186,23 +131,36 @@ const registerSessionEvidenceResource = ( ); }; -const registerActiveResidualUnknownsResource = ( +const registerEvidenceBundleResource = ( server: McpServer, session: BinarySessionPort, ): void => { server.registerResource( - "active-residual-unknowns", - "rea://unknowns/active", + "evidence-bundle", + new ResourceTemplate("rea://evidence-bundle/{bundleDigest}", { + list: undefined, + }), { - title: "Active residual unknowns", - description: "Current unresolved session-owned residual unknown heads.", + title: "Evidence bundle", + description: "Immutable session-retained canonical Evidence v2 bundle.", mimeType: "application/json", }, - (uri) => - jsonResource( - uri.href, - session.listUnknowns().filter(({ status }) => status !== "resolved"), - ), + (uri, variables) => { + const digest = stringVariable(variables.bundleDigest, uri.href); + if (!/^[a-f0-9]{64}$/u.test(digest)) + throw new ResourceNotFoundError(uri.href); + const text = session.retainedEvidenceBundle(digest); + if (text === undefined) throw new ResourceNotFoundError(uri.href); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text, + }, + ], + }; + }, ); }; @@ -212,36 +170,30 @@ const registerAnalysisSnapshotResource = ( ): void => { server.registerResource( "analysis-snapshot", - new ResourceTemplate("rea://snapshot/{snapshotDigest}", { - list: () => { - const snapshot = session.exportAnalysisSnapshot(); - if (!snapshot.ok) return { resources: [] }; - const digest = contentDigest(snapshot.value); - return { - resources: [ - { - uri: `rea://snapshot/${digest}`, - name: digest, - title: "Current analysis snapshot", - description: "Immutable projection of the current target cache.", - mimeType: "application/json", - }, - ], - }; - }, - }), + "rea://snapshot/current", { title: "Analysis snapshot", description: - "Current provider-neutral analysis snapshot by content digest.", + "Mutable provider-neutral analysis snapshot for the current native target.", mimeType: "application/json", }, - (uri, variables) => { - const requested = stringVariable(variables.snapshotDigest, uri.href); + (uri) => { const snapshot = session.exportAnalysisSnapshot(); - if (!snapshot.ok || contentDigest(snapshot.value) !== requested) - throw new ResourceNotFoundError(uri.href); - return jsonResource(uri.href, snapshot.value); + if (!snapshot.ok) + return jsonResource(uri.href, { + state: "unavailable", + reason: snapshot.error.message, + remediation: + "Open a native target with a concrete analysis profile, or rerun analysis after metadata mutation.", + }); + return jsonResource(uri.href, { + state: "available", + target: snapshot.value.target, + profile: snapshot.value.binding, + content_digest: contentDigest(snapshot.value), + entry_count: snapshot.value.entries.length, + snapshot: snapshot.value, + }); }, ); }; @@ -333,7 +285,6 @@ const registerEvidenceSectionResource = ( new ResourceTemplate("rea://evidence/{evidenceId}/section/{section}", { list: undefined, complete: { - evidenceId: (prefix) => evidenceIds(session, prefix), section: (prefix) => EVIDENCE_SECTIONS.filter((section) => section.startsWith(prefix)), }, @@ -372,22 +323,7 @@ const registerResidualUnknownResource = ( server.registerResource( "residual-unknown", new ResourceTemplate("rea://unknown/{unknownId}", { - list: () => ({ - resources: session.listUnknowns().map((unknown) => ({ - uri: `rea://unknown/${unknown.unknown_id}`, - name: unknown.unknown_id, - title: unknown.question, - description: `Current ${unknown.status} residual unknown revision.`, - mimeType: "application/json", - })), - }), - complete: { - unknownId: (prefix) => - session - .listUnknowns() - .map(({ unknown_id }) => unknown_id) - .filter((unknownId) => unknownId.startsWith(prefix)), - }, + list: undefined, }), { title: "Residual unknown", @@ -408,12 +344,6 @@ const registerResidualUnknownResource = ( ); }; -const coverageWorkspaceUri = (workspaceId: string, revision: number): string => - `rea://reconstruction-coverage/${workspaceId}/revision/${String(revision)}`; - -const workspaceUri = (workspaceId: string, revision: number): string => - `rea://workspace/${workspaceId}/revision/${String(revision)}`; - const EVIDENCE_SECTIONS = [ "result", "terminal", @@ -425,12 +355,6 @@ const EVIDENCE_SECTIONS = [ "edges", ] as const; -const evidenceIds = (session: BinarySessionPort, prefix: string): string[] => - session - .exportEvidenceBundle() - .records.map(({ evidence_id }) => evidence_id) - .filter((evidenceId) => evidenceId.startsWith(prefix)); - const stringVariable = (value: unknown, uri: string): string => { if (typeof value !== "string") throw new ResourceNotFoundError(uri); return value; diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index e02fd1ded..7312f2043 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -209,8 +209,7 @@ const runAutomaticInvestigation = async (input: { ); if (!investigated.ok) return toCallToolResult(investigated, contract); const workspace = investigated.value.workspace; - if (session.retainInvestigationWorkspace(workspace) === "added") - input.server.sendResourceListChanged(); + session.retainInvestigationWorkspace(workspace); const result = changedBehaviorResultSchema.parse( investigated.value.evidence.normalized_result, ); diff --git a/src/server/registerJavaScriptApplicationGraphResource.ts b/src/server/registerJavaScriptApplicationGraphResource.ts index 3b19fb9b8..3cc3d7a67 100644 --- a/src/server/registerJavaScriptApplicationGraphResource.ts +++ b/src/server/registerJavaScriptApplicationGraphResource.ts @@ -19,11 +19,6 @@ export const registerJavaScriptApplicationGraphResource = ( { list: undefined, complete: { - evidenceId: (prefix) => - session - .exportEvidenceBundle() - .records.map(({ evidence_id }) => evidence_id) - .filter((evidenceId) => evidenceId.startsWith(prefix)), collection: (prefix) => ["nodes", "edges"].filter((name) => name.startsWith(prefix)), }, diff --git a/src/server/registerReconstructionObligationLedgerResource.ts b/src/server/registerReconstructionObligationLedgerResource.ts index 42114bd38..567a39d01 100644 --- a/src/server/registerReconstructionObligationLedgerResource.ts +++ b/src/server/registerReconstructionObligationLedgerResource.ts @@ -18,17 +18,6 @@ export const registerReconstructionObligationLedgerResource = ( "rea://evidence/{evidenceId}/reconstruction-obligation-ledger", { list: undefined, - complete: { - evidenceId: (prefix) => - session - .exportEvidenceBundle() - .records.filter( - ({ predicate_type: predicateType }) => - predicateType === "rea.reconstruction-obligation-ledger/v1", - ) - .map(({ evidence_id: evidenceId }) => evidenceId) - .filter((evidenceId) => evidenceId.startsWith(prefix)), - }, }, ), { diff --git a/src/server/registerReconstructionReadinessResource.ts b/src/server/registerReconstructionReadinessResource.ts index b965a6f85..3819d2684 100644 --- a/src/server/registerReconstructionReadinessResource.ts +++ b/src/server/registerReconstructionReadinessResource.ts @@ -18,17 +18,6 @@ export const registerReconstructionReadinessResource = ( "rea://evidence/{evidenceId}/reconstruction-readiness-report", { list: undefined, - complete: { - evidenceId: (prefix) => - session - .exportEvidenceBundle() - .records.filter( - ({ predicate_type: predicateType }) => - predicateType === "rea.reconstruction-readiness-report/v1", - ) - .map(({ evidence_id: evidenceId }) => evidenceId) - .filter((evidenceId) => evidenceId.startsWith(prefix)), - }, }, ), { diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index 1ce705c51..dab3388a3 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -10,6 +10,7 @@ import { exportEvidenceBundleInputSchema, importEvidenceBundleInputSchema, listUnknownsInputSchema, + snapshotEvidenceBundleInputSchema, SESSION_TOOL_CONTRACTS, verifyUnknownResolutionInputSchema, } from "../contracts/toolContracts.js"; @@ -33,6 +34,7 @@ interface EvidenceToolRegistration { readonly session: BinarySessionPort; readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3]; readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4]; + readonly snapshotContract: (typeof SESSION_TOOL_CONTRACTS)[19]; readonly filePolicy: EvidenceFilePolicy; readonly permissionAuthority?: PermissionAuthority; } @@ -43,6 +45,7 @@ export const registerEvidenceTools = ( ): void => { registerExportEvidenceTool(registration); registerImportEvidenceTool(registration); + registerSnapshotEvidenceTool(registration); }; const registerExportEvidenceTool = ({ @@ -64,8 +67,6 @@ const registerExportEvidenceTool = ({ if (!parsedInput.ok) return toCallToolResult(parsedInput, exportContract); const parsed = parsedInput.value; const bundle = session.exportEvidenceBundle(); - if (parsed.path === undefined) - return toCallToolResult(ok(bundle), exportContract); const denied = await authorizeEvidencePath({ authority: permissionAuthority, capability: "evidence_write", @@ -86,6 +87,7 @@ const registerExportEvidenceTool = ({ path: written.value.path, bytes: written.value.bytes, records: bundle.records.length, + unknowns: bundle.unknowns.length, }), exportContract, ) @@ -94,6 +96,44 @@ const registerExportEvidenceTool = ({ ); }; +const registerSnapshotEvidenceTool = ({ + server, + session, + snapshotContract, +}: EvidenceToolRegistration): void => { + server.registerTool( + snapshotContract.name, + toolRegistrationOptions(snapshotContract), + (input) => { + const parsed = safeParseToolInput( + snapshotEvidenceBundleInputSchema, + input, + snapshotContract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, snapshotContract); + const snapshot = session.snapshotEvidenceBundle(); + if (!snapshot.ok) return toCallToolResult(snapshot, snapshotContract); + const value = { + bundle_digest: snapshot.value.bundleDigest, + bundle_version: snapshot.value.bundleVersion, + bytes: snapshot.value.bytes, + records: snapshot.value.records, + unknowns: snapshot.value.unknowns, + bundle_uri: snapshot.value.uri, + } as const; + return toCallToolResult(ok(value), snapshotContract, { + resourceLinks: [ + { + uri: snapshot.value.uri, + name: snapshot.value.bundleDigest, + description: "Immutable session-retained Evidence v2 bundle", + }, + ], + }); + }, + ); +}; + const registerImportEvidenceTool = ({ server, session, @@ -123,7 +163,6 @@ const registerImportEvidenceTool = ({ const loaded = await readEvidenceBundle(path, filePolicy); if (!loaded.ok) return toCallToolResult(loaded, importContract); const imported = session.importEvidenceBundle(loaded.value); - if (imported.ok && imported.value > 0) server.sendResourceListChanged(); return imported.ok ? toCallToolResult( ok({ @@ -191,17 +230,40 @@ export const registerUnknownTools = ({ ); if (!parsed.ok) return toCallToolResult(parsed, listContract); const filters = parsed.value; + const all = session.listUnknowns({ + ...(filters.status === undefined ? {} : { status: filters.status }), + ...(filters.severity === undefined + ? {} + : { severity: filters.severity }), + ...(filters.domain === undefined ? {} : { domain: filters.domain }), + }); + const items = all + .slice(filters.offset, filters.offset + filters.limit) + .map((unknown) => ({ + unknown, + uri: `rea://unknown/${unknown.unknown_id}`, + })); + const nextOffset = + filters.offset + items.length < all.length + ? filters.offset + items.length + : null; return toCallToolResult( - ok( - session.listUnknowns({ - ...(filters.status === undefined ? {} : { status: filters.status }), - ...(filters.severity === undefined - ? {} - : { severity: filters.severity }), - ...(filters.domain === undefined ? {} : { domain: filters.domain }), - }), - ), + ok({ + items, + offset: filters.offset, + limit: filters.limit, + total: all.length, + next_offset: nextOffset, + has_more: nextOffset !== null, + }), listContract, + { + resourceLinks: items.map((unknown) => ({ + uri: unknown.uri, + name: unknown.unknown.unknown_id, + description: `Current ${unknown.unknown.status} residual unknown revision`, + })), + }, ); }, ); @@ -216,8 +278,17 @@ export const registerUnknownTools = ({ ); if (!parsed.ok) return toCallToolResult(parsed, recordContract); const result = session.recordUnknown(parsed.value); - if (result.ok) server.sendResourceListChanged(); - return toCallToolResult(result, recordContract); + return result.ok + ? toCallToolResult(result, recordContract, { + resourceLinks: [ + { + uri: `rea://unknown/${result.value.unknown_id}`, + name: result.value.unknown_id, + description: "Current residual unknown head", + }, + ], + }) + : toCallToolResult(result, recordContract); }, ); server.registerTool( @@ -231,8 +302,17 @@ export const registerUnknownTools = ({ ); if (!parsed.ok) return toCallToolResult(parsed, updateContract); const result = session.updateUnknown(parsed.value); - if (result.ok) server.sendResourceListChanged(); - return toCallToolResult(result, updateContract); + return result.ok + ? toCallToolResult(result, updateContract, { + resourceLinks: [ + { + uri: `rea://unknown/${result.value.unknown_id}`, + name: result.value.unknown_id, + description: "Updated residual unknown head", + }, + ], + }) + : toCallToolResult(result, updateContract); }, ); server.registerTool( diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 311e701d9..d1c10ceaa 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -2,7 +2,11 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import type { BinarySessionPort } from "../application/BinarySession.js"; -import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { + addressContextInputSchema, + navigationContextInputSchema, + SESSION_TOOL_CONTRACTS, +} from "../contracts/toolContracts.js"; import { toCallToolResult } from "./toolResult.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; @@ -52,6 +56,10 @@ import { } from "./sessionAvailabilityPolicy.js"; import { permissionFailure } from "./permissionFailure.js"; import { registerCloseLifecycleTool } from "./registerCloseLifecycleTool.js"; +import { + getNavigationContext, + inspectAddressContext, +} from "../application/AnalysisContextQueries.js"; const recordProcessResidualUnknowns = ( session: BinarySessionPort, @@ -317,6 +325,54 @@ const registerRecordAndReplayTools = ( registerReplayMachineTool(server, logger); }; +const registerContextTools = ( + server: McpServer, + session: BinarySessionPort, +): void => { + const navigationContract = SESSION_TOOL_CONTRACTS[20]; + const addressContract = SESSION_TOOL_CONTRACTS[21]; + server.registerTool( + navigationContract.name, + toolRegistrationOptions(navigationContract), + async (input, context) => { + const parsed = safeParseToolInput( + navigationContextInputSchema, + input, + navigationContract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, navigationContract); + return toCallToolResult( + await getNavigationContext( + session, + parsed.value, + context.mcpReq.signal, + ), + navigationContract, + ); + }, + ); + server.registerTool( + addressContract.name, + toolRegistrationOptions(addressContract), + async (input, context) => { + const parsed = safeParseToolInput( + addressContextInputSchema, + input, + addressContract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, addressContract); + return toCallToolResult( + await inspectAddressContext( + session, + parsed.value, + context.mcpReq.signal, + ), + addressContract, + ); + }, + ); +}; + export const registerSessionTools = ( server: McpServer, session: BinarySessionPort, @@ -344,6 +400,7 @@ export const registerSessionTools = ( staticRuntimeContract, reconstructionContract, ] = SESSION_TOOL_CONTRACTS; + const snapshotContract = SESSION_TOOL_CONTRACTS[19]; registerLifecycleTools({ server, session, @@ -365,6 +422,7 @@ export const registerSessionTools = ( session, exportContract, importContract, + snapshotContract, filePolicy: evidenceFilePolicy, ...(options.permissionAuthority === undefined ? {} @@ -411,4 +469,5 @@ export const registerSessionTools = ( : { permissionAuthority: options.permissionAuthority }), }); registerRecordAndReplayTools(server, session, logger); + registerContextTools(server, session); }; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 2ae6f63da..58182a135 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -89,6 +89,14 @@ const successResult = ( options.evidenceTextProjection), ), }, + ...(hasResourceLinks(value, contract, options) + ? [ + { + type: "text" as const, + text: "Full detail is available through MCP resources/read. Copy the opaque URI exactly. In Codex, call read_mcp_resource.", + }, + ] + : []), ...evidenceResourceLinks( value, contract.kind === "session" || @@ -104,6 +112,19 @@ const successResult = ( }; }; +const hasResourceLinks = ( + value: JsonValue, + contract: ToolContract, + options: ToolResultOptions, +): boolean => + options.resourceLinks !== undefined && options.resourceLinks.length > 0 + ? true + : evidenceResourceLinks( + value, + contract.kind === "session" || + options.evidenceResourcesAvailable === true, + ).length > 0; + const compactEvidence = ( value: JsonValue, resultProjection: JsonValue | undefined, diff --git a/tests/analysisContextQueries.test.ts b/tests/analysisContextQueries.test.ts new file mode 100644 index 000000000..9c8c5ec02 --- /dev/null +++ b/tests/analysisContextQueries.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { + getNavigationContext, + inspectAddressContext, +} from "../src/application/AnalysisContextQueries.js"; +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "../src/application/AnalysisProvider.js"; +import { AnalysisProtocolError } from "../src/domain/errors.js"; +import { err, ok } from "../src/domain/result.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +describe("analysis context queries", () => { + it("aggregates volatile navigation and treats a missing procedure as null", async () => { + const calls: { + operation: string; + parameters: Readonly>; + }[] = []; + const analysis: AnalysisOperationPort = { + execute: (operation, parameters) => { + calls.push({ operation, parameters }); + if (operation === "current_document") + return Promise.resolve( + ok(createAnalysisExecution("fixture", provider)), + ); + if (operation === "current_address") + return Promise.resolve( + ok(createAnalysisExecution("0x401000", provider)), + ); + if (operation === "current_procedure") + return Promise.resolve( + err(new AnalysisProtocolError("cursor is not in a procedure")), + ); + return Promise.resolve( + err(new AnalysisProtocolError(`unexpected ${operation}`)), + ); + }, + }; + + await expect(getNavigationContext(analysis, {})).resolves.toEqual( + ok({ + document: "fixture", + address: "0x401000", + procedure: null, + }), + ); + expect(calls).toContainEqual({ + operation: "current_document", + parameters: {}, + }); + }); + + it("uses an explicit document without passing it to current_document and preserves procedure failures", async () => { + const calls: string[] = []; + const failure = new AnalysisProtocolError("provider response malformed"); + const analysis: AnalysisOperationPort = { + execute: (operation) => { + calls.push(operation); + if (operation === "current_address") + return Promise.resolve( + ok(createAnalysisExecution("0x401000", provider)), + ); + return Promise.resolve(err(failure)); + }, + }; + + await expect( + getNavigationContext(analysis, { document: "fixture" }), + ).resolves.toEqual(err(failure)); + expect(calls).toEqual(["current_address", "current_procedure"]); + }); + + it("keeps unsupported address facets local and filters bookmarks", async () => { + const bookmarkInputs: Readonly>[] = []; + const analysis: AnalysisOperationPort = { + execute: (operation, input) => { + if (operation === "comment") + return Promise.resolve( + err(new AnalysisProtocolError("comments unavailable")), + ); + switch (operation) { + case "address_name": + return Promise.resolve( + ok(createAnalysisExecution("entry", provider)), + ); + case "resolve_containing_procedure": + return Promise.resolve( + ok( + createAnalysisExecution( + { address: "0x401000", name: "main" }, + provider, + ), + ), + ); + case "inline_comment": + return Promise.resolve(ok(createAnalysisExecution(null, provider))); + case "list_bookmarks": + bookmarkInputs.push(input); + return Promise.resolve( + ok( + createAnalysisExecution( + [ + { address: "0x401000", value: "review" }, + { address: "0x402000", value: "other" }, + ], + provider, + ), + ), + ); + default: + return Promise.resolve( + err(new AnalysisProtocolError(`unexpected ${operation}`)), + ); + } + }, + }; + + const result = await inspectAddressContext(analysis, { + address: "0x401000", + }); + expect(result).toMatchObject({ + ok: true, + value: { + name: { state: "available", value: "entry" }, + comment: { + state: "unavailable", + reason: "comments unavailable", + }, + bookmarks: { + state: "available", + value: [{ address: "0x401000", value: "review" }], + }, + }, + }); + expect(bookmarkInputs).toEqual([{}]); + }); +}); diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index 8a030ee84..69af32600 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -430,17 +430,31 @@ describe("artifact graph MCP integration", () => { arguments: { domain: "artifact-comparison" }, }); expect(unknowns.structuredContent).toMatchObject({ - result: [expect.objectContaining({ domain: "artifact-comparison" })], + result: { + items: [ + expect.objectContaining({ + unknown: expect.objectContaining({ + domain: "artifact-comparison", + }), + }), + ], + }, }); - const exported = await client.callTool({ - name: "export_evidence_bundle", + const snapshotted = await client.callTool({ + name: "snapshot_evidence_bundle", arguments: {}, }); - const envelope = z - .object({ result: evidenceBundleSchema }) - .parse(exported.structuredContent); - expect(envelope.result.records).toHaveLength(6); - expect(envelope.result.artifacts).toContainEqual({ + const bundleUri = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(snapshotted.structuredContent).result.bundle_uri; + const bundleResource = await client.readResource({ uri: bundleUri }); + const bundle = evidenceBundleSchema.parse( + JSON.parse( + z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, + ), + ); + expect(bundle.records).toHaveLength(6); + expect(bundle.artifacts).toContainEqual({ digest: { sha256: evidence.subject?.digest.sha256 }, format: "ipa", architecture: null, diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 300bc06ff..d4fda25f1 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -167,7 +167,7 @@ describe("tool contract surface", () => { expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(names.sort()); }); - it("keeps exactly nineteen additive session contracts", () => { + it("keeps exactly twenty-two additive session contracts", () => { expect( SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })), ).toEqual([ @@ -190,6 +190,9 @@ describe("tool contract surface", () => { { name: "update_unknown", kind: "session" }, { name: "verify_unknown_resolution", kind: "session" }, { name: "run_replay_machine", kind: "session" }, + { name: "snapshot_evidence_bundle", kind: "session" }, + { name: "get_navigation_context", kind: "session" }, + { name: "inspect_address_context", kind: "session" }, ]); expect( SESSION_TOOL_CONTRACTS.find( diff --git a/tests/evidenceResources.test.ts b/tests/evidenceResources.test.ts index 0096bf6b6..e859566ee 100644 --- a/tests/evidenceResources.test.ts +++ b/tests/evidenceResources.test.ts @@ -1,5 +1,6 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { BinarySession } from "../src/application/BinarySession.js"; import { createEvidence } from "../src/domain/evidence.js"; @@ -11,6 +12,41 @@ import { createInvestigationWorkspace } from "../src/domain/investigationWorkspa const provider = { id: "fixture", name: "Fixture", version: "1" }; describe("evidence MCP resources", () => { + it("retains bundles without eviction and rejects a seventeenth digest", () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const first = session.snapshotEvidenceBundle(); + expect(first.ok).toBe(true); + for (let index = 0; index < 15; index += 1) { + session.recordEvidence( + createEvidence(undefined, provider, { + operation: `retention_probe_${String(index)}`, + parameters: {}, + result: index, + }), + ); + expect(session.snapshotEvidenceBundle().ok).toBe(true); + } + session.recordEvidence( + createEvidence(undefined, provider, { + operation: "retention_probe_overflow", + parameters: {}, + result: true, + }), + ); + expect(session.snapshotEvidenceBundle()).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError", limit: "records", maximum: 16 }, + }); + if (first.ok) + expect(session.retainedEvidenceBundle(first.value.bundleDigest)).toEqual( + expect.any(String), + ); + }); + it("lists and reads only evidence owned by the current session", async () => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), @@ -71,7 +107,7 @@ describe("evidence MCP resources", () => { expect.arrayContaining([ "rea://artifact/{manifestId}/{collection}", "rea://function/{targetSha256}/{address}", - "rea://snapshot/{snapshotDigest}", + "rea://evidence-bundle/{bundleDigest}", "rea://workspace/{workspaceId}/revision/{revision}", ]), ); @@ -82,6 +118,11 @@ describe("evidence MCP resources", () => { ); const listed = await client.listResources(); expect(listed.resources).toContainEqual( + expect.objectContaining({ + uri: "rea://snapshot/current", + }), + ); + expect(listed.resources).not.toContainEqual( expect.objectContaining({ uri: `rea://evidence/${evidence.evidence_id}`, }), @@ -96,6 +137,33 @@ describe("evidence MCP resources", () => { text: expect.stringContaining(evidence.evidence_id), }), ]); + const firstSnapshot = await client.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + const secondSnapshot = await client.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + expect(secondSnapshot.structuredContent).toEqual( + firstSnapshot.structuredContent, + ); + expect(firstSnapshot.content).toContainEqual( + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Copy the opaque URI exactly"), + }), + ); + const bundleUri = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(firstSnapshot.structuredContent).result.bundle_uri; + const retainedBundle = await client.readResource({ uri: bundleUri }); + expect(retainedBundle.contents[0]).toEqual( + expect.objectContaining({ + uri: bundleUri, + text: expect.stringContaining(evidence.evidence_id), + }), + ); const terminal = await client.readResource({ uri: `rea://evidence/${capture.evidence_id}/section/terminal`, }); @@ -119,14 +187,6 @@ describe("evidence MCP resources", () => { text: expect.stringContaining(unknown.value.revision_digest), }), ); - const active = await client.readResource({ - uri: "rea://unknowns/active", - }); - expect(active.contents[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining(unknown.value.unknown_id), - }), - ); } await expect( client.readResource({ uri: `rea://evidence/ev_${"0".repeat(64)}` }), diff --git a/tests/functionComparisonMcp.test.ts b/tests/functionComparisonMcp.test.ts index c84a67feb..7fc5d7753 100644 --- a/tests/functionComparisonMcp.test.ts +++ b/tests/functionComparisonMcp.test.ts @@ -49,17 +49,21 @@ describe("function comparison MCP integration", () => { arguments: { domain: "function-comparison" }, }); expect(unknowns.structuredContent).toMatchObject({ - result: [ - expect.objectContaining({ - domain: "function-comparison", - recommended_probes: [ - expect.objectContaining({ - rationale: - "Capture complete dossiers for both functions under the same target context and analysis limits.", + result: { + items: [ + expect.objectContaining({ + unknown: expect.objectContaining({ + domain: "function-comparison", + recommended_probes: [ + expect.objectContaining({ + rationale: + "Capture complete dossiers for both functions under the same target context and analysis limits.", + }), + ], }), - ], - }), - ], + }), + ], + }, }); } finally { await Promise.allSettled([ diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts index 441b2cb34..b3df582c3 100644 --- a/tests/ghidraMcp.test.ts +++ b/tests/ghidraMcp.test.ts @@ -265,7 +265,7 @@ describe("Ghidra MCP and shared CLI composition", () => { arguments: {}, }) ).structuredContent, - ).toMatchObject({ result: [] }); + ).toMatchObject({ result: { items: [] } }); const approvedInspection = sessionEvidence( session, @@ -294,22 +294,26 @@ describe("Ghidra MCP and shared CLI composition", () => { }) ).structuredContent, ).toMatchObject({ - result: [ - { - status: "open", - domain: "native-api", - question: expect.stringContaining( - "additional data sources or targets", - ), - supporting_evidence_ids: [approvedInspection.evidence_id], - recommended_probes: [ - { - operation: "inspect_native_api", - rationale: expect.stringContaining("ABI probe"), + result: { + items: [ + { + unknown: { + status: "open", + domain: "native-api", + question: expect.stringContaining( + "additional data sources or targets", + ), + supporting_evidence_ids: [approvedInspection.evidence_id], + recommended_probes: [ + { + operation: "inspect_native_api", + rationale: expect.stringContaining("ABI probe"), + }, + ], }, - ], - }, - ], + }, + ], + }, }); } finally { await harness.close(); diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 345e80687..3015b2d75 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -352,13 +352,17 @@ describe("full MCP integration with multi-tool sequences", () => { await client.callTool({ name: "list_unknowns", arguments: {} }), ); expect(listed).toMatchObject({ - result: [ - { - status: "open", - domain: "control-flow", - question: "Investigation reached the operation budget.", - }, - ], + result: { + items: [ + { + unknown: { + status: "open", + domain: "control-flow", + question: "Investigation reached the operation budget.", + }, + }, + ], + }, }); }); @@ -412,19 +416,23 @@ describe("full MCP integration with multi-tool sequences", () => { await client.callTool({ name: "list_unknowns", arguments: {} }), ), ).toMatchObject({ - result: [ - { - domain: "analysis-capability", - question: - "The requested analysis is unavailable for the current target.", - recommended_probes: [ - { - rationale: - "Choose another analysis or target that can answer this question.", + result: { + items: [ + { + unknown: { + domain: "analysis-capability", + question: + "The requested analysis is unavailable for the current target.", + recommended_probes: [ + { + rationale: + "Choose another analysis or target that can answer this question.", + }, + ], }, - ], - }, - ], + }, + ], + }, }); }); @@ -510,14 +518,18 @@ describe("full MCP integration with multi-tool sequences", () => { await client.callTool({ name: "list_unknowns", arguments: {} }), ), ).toMatchObject({ - result: [ - { - status: "contradicted", - domain: "process-comparison", - question: "Process captures disagree across: interaction, shim", - contradicting_evidence_ids: [rightEvidence.evidence_id], - }, - ], + result: { + items: [ + { + unknown: { + status: "contradicted", + domain: "process-comparison", + question: "Process captures disagree across: interaction, shim", + contradicting_evidence_ids: [rightEvidence.evidence_id], + }, + }, + ], + }, }); }); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 70662b9de..44d57ce8f 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -175,11 +175,21 @@ describe("target-free MCP lifecycle", () => { expect( text(await mcp.callTool({ name: "current_document", arguments: {} })), ).toContain("first.hop"); - const exported = await mcp.callTool({ - name: "export_evidence_bundle", + const snapshotted = await mcp.callTool({ + name: "snapshot_evidence_bundle", arguments: {}, }); - const bundle = evidenceBundleSchema.parse(structured(exported).result); + const snapshotResult = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(structured(snapshotted)).result; + const bundleResource = await mcp.readResource({ + uri: snapshotResult.bundle_uri, + }); + const bundle = evidenceBundleSchema.parse( + JSON.parse( + z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, + ), + ); expect(bundle.records).toHaveLength(1); const evidencePath = join(directory, "evidence.json"); expect( @@ -221,7 +231,7 @@ describe("target-free MCP lifecycle", () => { ).result, ).toEqual({ imported: 1, total: 2 }); await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(changesBeforeMutation + 1); + expect(resourceListChanges).toBe(changesBeforeMutation); changesBeforeMutation = resourceListChanges; const recordedUnknown = z .object({ @@ -250,15 +260,18 @@ describe("target-free MCP lifecycle", () => { ).result; expect(recordedUnknown.revision).toBe(1); await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(changesBeforeMutation + 1); + expect(resourceListChanges).toBe(changesBeforeMutation); + const listedUnknowns = await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }); + expect(listedUnknowns.isError, text(listedUnknowns)).not.toBe(true); expect( z - .object({ result: z.array(z.unknown()) }) - .parse( - structured( - await mcp.callTool({ name: "list_unknowns", arguments: {} }), - ), - ).result, + .object({ + result: z.object({ items: z.array(z.unknown()) }), + }) + .parse(structured(listedUnknowns)).result.items, ).toHaveLength(1); const resolved = await mcp.callTool({ name: "update_unknown", @@ -284,7 +297,7 @@ describe("target-free MCP lifecycle", () => { }); expect(resolved.isError).not.toBe(true); await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(changesBeforeMutation + 2); + expect(resourceListChanges).toBe(changesBeforeMutation); expect( structured( await mcp.callTool({ @@ -324,7 +337,7 @@ describe("target-free MCP lifecycle", () => { expect( structured(await mcp.callTool({ name: "list_unknowns", arguments: {} })) .result, - ).toEqual([]); + ).toMatchObject({ items: [] }); expect( ( await mcp.callTool({ @@ -368,18 +381,30 @@ describe("target-free MCP lifecycle", () => { }, }); expect(captured.isError, text(captured)).not.toBe(true); + const listedUnknowns = await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }); + expect(listedUnknowns.isError, text(listedUnknowns)).not.toBe(true); const listed = z .object({ - result: z.array(z.object({ question: z.string(), domain: z.string() })), + result: z.object({ + items: z.array( + z.object({ + unknown: z.object({ + question: z.string(), + domain: z.string(), + }), + }), + ), + }), }) - .parse( - structured( - await mcp.callTool({ name: "list_unknowns", arguments: {} }), - ), - ).result; + .parse(structured(listedUnknowns)).result.items; expect(listed).toContainEqual({ - question: "Was network behavior fully observed during capture?", - domain: "process-network", + unknown: expect.objectContaining({ + question: "Was network behavior fully observed during capture?", + domain: "process-network", + }), }); }); }); diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index ac5a313b4..c90254d72 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -86,6 +86,9 @@ describe("tool contract inventory", () => { "update_unknown", "verify_unknown_resolution", "run_replay_machine", + "snapshot_evidence_bundle", + "get_navigation_context", + "inspect_address_context", ]); expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe( TOOL_CONTRACTS.length,