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.
This commit is contained in:
morluto 2026-07-29 17:36:40 +00:00
parent 3e1e8cd3f8
commit 1712e6d821
27 changed files with 901 additions and 291 deletions

View File

@ -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<Record<string, JsonValue>> => ({
...(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<Result<JsonValue, AnalysisError>> => {
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<Result<JsonValue, AnalysisError>> => {
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<AnalysisExecution, AnalysisError> | 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,
);
};

View File

@ -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<EvidenceBundleSnapshot, EvidenceLimitError>;
retainedEvidenceBundle(digest: string): string | undefined;
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
@ -101,4 +113,5 @@ export interface BinarySessionPort extends AnalysisOperationPort {
): AnalysisProfileCommitment | undefined;
openCompatibility(): Readonly<Record<string, JsonValue>>;
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
onAnalysisSnapshotChanged?(listener: () => void | Promise<void>): () => void;
}

View File

@ -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<string, string>();
#retainedBundleBytes = 0;
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
readonly #coverageWorkspaces = new Map<
string,
ReconstructionCoverageWorkspace
>();
#snapshotInvalidated = false;
readonly #snapshotListeners = new Set<() => void | Promise<void>>();
/** Observe changes to the mutable current analysis snapshot resource. */
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => 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<EvidenceBundleSnapshot, EvidenceLimitError> {
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<number, EvidenceIntegrityError | EvidenceLimitError> {
@ -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<AnalysisSnapshotCache["record"]>[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";

View File

@ -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",

View File

@ -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,

View File

@ -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. */

View File

@ -172,6 +172,9 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,

View File

@ -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<Record<string, z.ZodObject>> = {
address_name: resultOf(nullableText),
@ -403,14 +412,40 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
]),
),
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<Record<string, z.ZodObject>> = {
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(

View File

@ -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<typeof session.recordEvidence>[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<typeof session.recordEvidenceWithUnknown>[1],
) => {
const recorded = session.recordEvidenceWithUnknown(evidence, input);
if (recorded.ok) server.sendResourceListChanged();
return recorded;
},
});

View File

@ -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 ?? {

View File

@ -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;

View File

@ -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,
);

View File

@ -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)),
},

View File

@ -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)),
},
},
),
{

View File

@ -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)),
},
},
),
{

View File

@ -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(

View File

@ -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);
};

View File

@ -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,

View File

@ -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<Record<string, unknown>>;
}[] = [];
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<Record<string, unknown>>[] = [];
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([{}]);
});
});

View File

@ -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,

View File

@ -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(

View File

@ -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)}` }),

View File

@ -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([

View File

@ -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();

View File

@ -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],
},
},
],
},
});
});

View File

@ -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<void>((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<void>((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<void>((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",
}),
});
});
});

View File

@ -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,