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:
parent
3e1e8cd3f8
commit
1712e6d821
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([{}]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}` }),
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue