feat(mcp): expose progress resources and availability

This commit is contained in:
morluto
2026-07-14 01:12:50 +00:00
parent 190efcaf17
commit da59299a8a
25 changed files with 1999 additions and 217 deletions
+131 -7
View File
@@ -13,6 +13,8 @@ import { createBinarySession } from "./application/runtime.js";
import { createServer } from "./server/createServer.js";
import { createLogger } from "./logger.js";
import { projectAnalysisError } from "./domain/errors.js";
import { readProjectPermissionStore } from "./application/ProjectPermissionStore.js";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
const MCP_CONNECTION_LOST =
"REA lost its MCP client connection. Restart REA from your MCP client.";
@@ -30,6 +32,7 @@ interface RuntimeDependencies {
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void;
readonly registerReload?: (handler: () => void) => () => void;
}
const runtimeDependencies = (): RuntimeDependencies => ({
@@ -45,6 +48,10 @@ const runtimeDependencies = (): RuntimeDependencies => ({
process.stdin.once("end", handler);
process.stdin.once("close", handler);
},
registerReload: (handler) => {
process.on("SIGHUP", handler);
return () => process.off("SIGHUP", handler);
},
});
/**
@@ -62,6 +69,15 @@ export const run = async (
}
const logger = createLogger("mcp", config.value.logLevel);
const serverLogger = logger.child({ layer: "server" });
const permissionAuthority = await loadConfiguredPermissionAuthority(
config.value,
);
if (!permissionAuthority.ok) {
dependencies.writeStderr(
`${projectAnalysisError(permissionAuthority.error).message}\n`,
);
return 1;
}
const session = createBinarySession(config.value, logger);
if (config.value.hopperTargetPath !== undefined) {
@@ -80,17 +96,46 @@ export const run = async (
return 1;
}
}
let currentConfig = config.value;
const runtimeProcessPolicy = {
...config.value.processExecutionPolicy,
executableRoots: [...config.value.processExecutionPolicy.executableRoots],
workingRoots: [...config.value.processExecutionPolicy.workingRoots],
allowedEnvironment: [
...config.value.processExecutionPolicy.allowedEnvironment,
],
};
const runtimeEvidencePolicy = {
...config.value.evidenceFilePolicy,
roots: [...config.value.evidenceFilePolicy.roots],
};
const runtimeSnapshotPolicy = {
...config.value.analysisSnapshotFilePolicy,
roots: [...config.value.analysisSnapshotFilePolicy.roots],
};
const runtimeInvestigationRoots = [...config.value.investigationInputRoots];
const liveServers = new Set<ReturnType<typeof createServer>>();
let handle: StdioServerHandle;
try {
handle = dependencies.serve(
() =>
createServer(session, session, {
() => {
const server = createServer(session, session, {
logger,
processPolicy: config.value.processExecutionPolicy,
evidenceFilePolicy: config.value.evidenceFilePolicy,
investigationInputRoots: config.value.investigationInputRoots,
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
}),
processPolicy: runtimeProcessPolicy,
evidenceFilePolicy: runtimeEvidencePolicy,
investigationInputRoots: runtimeInvestigationRoots,
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
permissionAuthority: permissionAuthority.value,
artifactIntegrityContinueEnabled: () =>
currentConfig.artifactIntegrityContinueEnabled,
availabilityPolicy: () => ({
processCaptureEnabled: currentConfig.processExecutionPolicy.enabled,
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
}),
});
liveServers.add(server);
return server;
},
{
onerror: () => {
serverLogger.error(MCP_CONNECTION_LOST);
@@ -105,11 +150,90 @@ export const run = async (
return 1;
}
const unregisterReload =
dependencies.registerReload?.(() => {
void (async () => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
const reloaded = await permissionAuthority.value.reload(
refreshed.value.permissionCeilings,
);
if (!reloaded.ok) {
serverLogger.error(
"Reloaded permission ceiling could not be applied",
);
return;
}
const administrators =
await permissionAuthority.value.replaceAdministratorGrants(
refreshed.value.administratorPermissionGrants,
);
if (!administrators.ok) {
serverLogger.error(
"Reloaded administrator grants could not be applied",
);
return;
}
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await readProjectPermissionStore(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
return;
}
const projects = await permissionAuthority.value.replaceProjectGrants(
project.value?.grants ?? [],
);
if (!projects.ok) {
serverLogger.error("Reloaded project grants could not be applied");
return;
}
} else {
const projects = await permissionAuthority.value.replaceProjectGrants(
[],
);
if (!projects.ok) {
serverLogger.error("Reloaded project grants could not be cleared");
return;
}
}
currentConfig = refreshed.value;
Object.assign(
runtimeProcessPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeEvidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeSnapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeInvestigationRoots.splice(
0,
runtimeInvestigationRoots.length,
...refreshed.value.investigationInputRoots,
);
for (const server of liveServers) server.sendToolListChanged();
})();
}) ?? (() => undefined);
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterReload();
await handle.close();
await session.close();
permissionAuthority.value.clearSessionGrants();
})();
return shutdownPromise;
};
+69 -6
View File
@@ -12,6 +12,9 @@ import { silentLogger, type Logger } from "../logger.js";
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { registerGuidedPrompts } from "./registerPrompts.js";
import { registerEvidenceResources } from "./registerEvidenceResources.js";
import { createServerIdentity } from "../serverIdentity.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
export interface CreateServerOptions {
readonly logger?: Logger;
@@ -19,6 +22,12 @@ export interface CreateServerOptions {
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly investigationInputRoots?: readonly string[];
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
readonly artifactIntegrityContinueEnabled?: () => boolean;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
/**
@@ -31,25 +40,71 @@ export const createServer = (
session?: BinarySessionPort,
options: CreateServerOptions = {},
): McpServer => {
const startedAt = new Date().toISOString();
const logger = options.logger ?? silentLogger;
const server = new McpServer(
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
{
capabilities: { tools: {} },
name: PRODUCT_IDENTITY.mcpServerKey,
version: PRODUCT_IDENTITY.packageVersion,
},
{
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true },
},
instructions:
session === undefined
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
},
);
session?.onAvailabilityChanged?.(() => server.sendToolListChanged());
server.registerResource(
"server-identity",
"rea://server/identity",
{
title: "REA server identity",
description: "Live package, SDK, protocol, and catalog identity.",
mimeType: "application/json",
},
(uri) => {
const client = server.server.getClientVersion();
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(
createServerIdentity({
startedAt,
...(client === undefined ? {} : { client }),
...(server.server.getNegotiatedProtocolVersion() === undefined
? {}
: {
protocolVersion:
server.server.getNegotiatedProtocolVersion(),
}),
}),
null,
2,
),
},
],
};
},
);
const toolLogger = logger.child({ layer: "server" });
const activeTarget =
session === undefined ? undefined : () => session.activeTarget();
const recordEvidence =
session === undefined
? undefined
: (evidence: Parameters<typeof session.recordEvidence>[0]) =>
session.recordEvidence(evidence);
: (evidence: Parameters<typeof session.recordEvidence>[0]) => {
const recorded = session.recordEvidence(evidence);
if (recorded.ok && recorded.value === "added")
server.sendResourceListChanged();
return recorded;
};
registerOfficialTools(server, analysis, {
logger: toolLogger,
activeTarget,
@@ -77,9 +132,17 @@ export const createServer = (
logger: toolLogger,
activeTarget,
recordEvidence,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerGuidedPrompts(server, analysis, session);
if (session !== undefined)
registerSessionTools(server, session, toolLogger, options);
if (session !== undefined) {
registerEvidenceResources(server, session);
registerSessionTools(server, session, toolLogger, {
...options,
startedAt,
});
}
return server;
};
+46
View File
@@ -0,0 +1,46 @@
import type { ProgressReporter } from "../application/ProgressReporter.js";
import {
createProgressReporter,
silentProgressReporter,
} from "../application/ProgressReporter.js";
interface McpProgressContext {
readonly mcpReq: {
readonly _meta?: { readonly progressToken?: string | number | undefined };
notify(notification: {
readonly method: "notifications/progress";
readonly params: {
readonly progressToken: string | number;
readonly progress: number;
readonly total?: number;
readonly message?: string;
};
}): Promise<void>;
};
}
/** Adapt the request's negotiated MCP progress token to application progress. */
export const mcpProgressReporter = (
context: McpProgressContext,
): ProgressReporter => {
const token = context.mcpReq._meta?.progressToken;
if (token === undefined) return silentProgressReporter;
return createProgressReporter(
async (update) => {
try {
await context.mcpReq.notify({
method: "notifications/progress",
params: {
progressToken: token,
progress: update.completed,
...(update.total === null ? {} : { total: update.total }),
message: `${update.phase}: ${update.message}`,
},
});
} catch {
// Progress is observational; transport failure cannot change tool truth.
}
},
{ minimumIntervalMs: 100 },
);
};
+6 -6
View File
@@ -14,6 +14,7 @@ import { resolveSessionEvidence } from "./sessionEvidence.js";
import { ARTIFACT_COMPARISON_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register Evidence-backed deterministic artifact comparison. */
export const registerArtifactComparisonTool = (
@@ -24,18 +25,17 @@ export const registerArtifactComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = artifactComparisonInputSchema.parse(input);
const left = resolveSessionEvidence(session, parsed.left);
if (!left.ok) return toCallToolResult(left, contract);
const right = resolveSessionEvidence(session, parsed.right);
if (!right.ok) return toCallToolResult(right, contract);
const comparison = compareArtifacts(
left.value,
right.value,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareArtifacts(left.value, right.value, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const leftEvidenceIds = evidenceIds(parsed.left);
const rightEvidenceIds = evidenceIds(parsed.right);
const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
+2
View File
@@ -6,6 +6,7 @@ import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
/** Register deterministic artifact inventory and safe extraction operations. */
export const registerArtifactTools = (
@@ -15,6 +16,7 @@ export const registerArtifactTools = (
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly permissionAuthority?: PermissionAuthority;
},
): void => {
registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, options);
+12 -7
View File
@@ -14,6 +14,7 @@ import { BUNDLE_COMPARISON_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { isSessionEvidence } from "./sessionEvidence.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register canonical Evidence bundle comparison. */
export const registerBundleComparisonTool = (
@@ -24,7 +25,7 @@ export const registerBundleComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = bundleComparisonInputSchema.parse(input);
const sourceIds = [
...new Set([
@@ -63,13 +64,17 @@ export const registerBundleComparisonTool = (
),
contract,
);
const comparison = compareBundles(
parsed.left,
parsed.right,
parsed.record_pairs,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareBundles(
parsed.left,
parsed.right,
parsed.record_pairs,
parsed.offset,
parsed.limit,
),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const evidence = createEvidence(undefined, BUNDLE_COMPARISON_PROVIDER, {
predicateType: "rea.bundle-comparison/v1",
operation: contract.name,
+15
View File
@@ -16,6 +16,7 @@ import { createEvidence } from "../domain/evidence.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { UnknownRegistryError } from "../domain/errors.js";
import { mcpProgressReporter } from "./mcpProgress.js";
/** Optional session services used by enhanced tool registration. */
export interface EnhancedToolRegistration {
@@ -69,6 +70,13 @@ const registerEnhancedTool = (
annotations: contract.annotations,
},
async (input, context) => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: name,
completed: 0,
total: 1,
message: "started",
});
const parsedInput = jsonObjectSchema.parse(
enhancedInputSchemas[name].parse(input),
);
@@ -76,6 +84,13 @@ const registerEnhancedTool = (
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
);
await progress.report({
phase: name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
if (result.ok) {
const evidence = createEvidence(
registration.activeTarget?.(),
+398
View File
@@ -0,0 +1,398 @@
import {
ResourceNotFoundError,
ResourceTemplate,
type McpServer,
} from "@modelcontextprotocol/server";
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import type { BinarySessionPort } from "../application/BinarySessionPort.js";
import type { JsonValue } from "../domain/jsonValue.js";
const evidenceUri = (evidenceId: string): string =>
`rea://evidence/${evidenceId}`;
/** Expose immutable session-owned Evidence v2 records as MCP resources. */
export const registerEvidenceResources = (
server: McpServer,
session: BinarySessionPort,
): void => {
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)),
},
}),
{
title: "Investigation workspace revision",
description: "Immutable session-retained workspace with CAS linkage.",
mimeType: "application/json",
},
(uri, variables) => {
const workspaceId = stringVariable(variables.workspaceId, uri.href);
const revisionText = stringVariable(variables.revision, uri.href);
if (!/^\d+$/u.test(revisionText))
throw new ResourceNotFoundError(uri.href);
const workspace = session.investigationWorkspace(
workspaceId,
Number(revisionText),
);
if (workspace === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, workspace);
},
);
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)),
},
}),
{
title: "Session evidence",
description: "Immutable Evidence v2 records owned by this REA session.",
mimeType: "application/json",
},
(uri, variables) => {
const evidenceId = variables.evidenceId;
if (typeof evidenceId !== "string")
throw new ResourceNotFoundError(uri.href);
const evidence = session.evidenceById(evidenceId);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(evidence, null, 2),
},
],
};
},
);
server.registerResource(
"active-residual-unknowns",
"rea://unknowns/active",
{
title: "Active residual unknowns",
description: "Current unresolved session-owned residual unknown heads.",
mimeType: "application/json",
},
(uri) =>
jsonResource(
uri.href,
session.listUnknowns().filter(({ status }) => status !== "resolved"),
),
);
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",
},
],
};
},
}),
{
title: "Analysis snapshot",
description:
"Current provider-neutral analysis snapshot by content digest.",
mimeType: "application/json",
},
(uri, variables) => {
const requested = stringVariable(variables.snapshotDigest, uri.href);
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok || contentDigest(snapshot.value) !== requested)
throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, snapshot.value);
},
);
server.registerResource(
"artifact-page",
new ResourceTemplate("rea://artifact/{manifestId}/{collection}", {
list: undefined,
complete: {
collection: (prefix) =>
["nodes", "occurrences", "edges"].filter((value) =>
value.startsWith(prefix),
),
},
}),
{
title: "Artifact graph page",
description: "Deterministically paged artifact graph collection.",
mimeType: "application/json",
},
(uri, variables) => {
const manifestId = stringVariable(variables.manifestId, uri.href);
const collection = stringVariable(variables.collection, uri.href);
if (!ARTIFACT_COLLECTIONS.has(collection))
throw new ResourceNotFoundError(uri.href);
const evidence = session
.exportEvidenceBundle()
.records.find(
(record) =>
artifactManifestId(record.normalized_result) === manifestId,
);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
const result = objectValue(evidence.normalized_result);
const page = result?.[collection];
if (page === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, {
evidence_id: evidence.evidence_id,
manifest_id: manifestId,
collection,
page,
provider: evidence.provider,
limitations: evidence.limitations,
});
},
);
server.registerResource(
"function-dossier",
new ResourceTemplate("rea://function/{targetSha256}/{address}", {
list: undefined,
}),
{
title: "Function dossier",
description: "Session-owned analyze_function dossier with provenance.",
mimeType: "application/json",
},
(uri, variables) => {
const targetSha256 = stringVariable(variables.targetSha256, uri.href);
const address = stringVariable(variables.address, uri.href);
const evidence = session
.exportEvidenceBundle()
.records.find(
(record) =>
record.operation === "analyze_function" &&
record.subject?.digest.sha256 === targetSha256 &&
functionAddress(record.normalized_result) === address,
);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, evidence);
},
);
server.registerResource(
"evidence-section",
new ResourceTemplate("rea://evidence/{evidenceId}/section/{section}", {
list: undefined,
complete: {
evidenceId: (prefix) => evidenceIds(session, prefix),
section: (prefix) =>
EVIDENCE_SECTIONS.filter((section) => section.startsWith(prefix)),
},
}),
{
title: "Evidence section",
description:
"One independently useful bounded section of session Evidence v2.",
mimeType: "application/json",
},
(uri, variables) => {
const evidenceId = stringVariable(variables.evidenceId, uri.href);
const section = stringVariable(variables.section, uri.href);
const evidence = session.evidenceById(evidenceId);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
const value = evidenceSection(evidence.normalized_result, section);
if (value === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, {
evidence_id: evidence.evidence_id,
operation: evidence.operation,
authority: evidence.authority,
confidence: evidence.confidence,
provider: evidence.provider,
limitations: evidence.limitations,
section,
value,
});
},
);
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)),
},
}),
{
title: "Residual unknown",
description: "Current session-owned residual unknown head.",
mimeType: "application/json",
},
(uri, variables) => {
const unknownId = stringVariable(variables.unknownId, uri.href);
const unknown = session
.listUnknowns()
.find(({ unknown_id }) => unknown_id === unknownId);
if (unknown === undefined) throw new ResourceNotFoundError(uri.href);
const history = session
.exportEvidenceBundle()
.unknowns.filter(({ unknown_id }) => unknown_id === unknownId);
return jsonResource(uri.href, { current: unknown, history });
},
);
};
const workspaceUri = (workspaceId: string, revision: number): string =>
`rea://workspace/${workspaceId}/revision/${String(revision)}`;
const EVIDENCE_SECTIONS = [
"result",
"terminal",
"filesystem",
"process",
"protocol",
"nodes",
"occurrences",
"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;
};
const evidenceSection = (
result: JsonValue,
section: string,
): JsonValue | undefined => {
if (section === "result") return result;
if (typeof result !== "object" || result === null || Array.isArray(result))
return undefined;
if (section === "terminal")
return pick(result, ["frames", "rendered_frames", "interaction_events"]);
if (section === "filesystem")
return pick(result, [
"filesystem_checkpoints",
"filesystem_effects",
"files_before",
"files_after",
]);
if (section === "process")
return pick(result, ["process_samples", "exit", "settlement", "cleanup"]);
if (section === "protocol")
return pick(result, ["protocol_events", "shim_events"]);
const value = result[section];
return value === undefined ? undefined : value;
};
const pick = (
result: Readonly<Record<string, JsonValue>>,
names: readonly string[],
): JsonValue | undefined => {
const entries = names.flatMap((name) =>
result[name] === undefined ? [] : [[name, result[name]] as const],
);
return entries.length === 0 ? undefined : Object.fromEntries(entries);
};
const jsonResource = (uri: string, value: JsonValue) => ({
contents: [
{
uri,
mimeType: "application/json" as const,
text: JSON.stringify(value, null, 2),
},
],
});
const ARTIFACT_COLLECTIONS = new Set(["nodes", "occurrences", "edges"]);
const objectValue = (
value: JsonValue,
): Readonly<Record<string, JsonValue>> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value)
? value
: undefined;
const artifactManifestId = (value: JsonValue): string | undefined => {
const manifest = objectValue(objectValue(value)?.manifest ?? null);
return typeof manifest?.manifest_id === "string"
? manifest.manifest_id
: undefined;
};
const functionAddress = (value: JsonValue): string | undefined => {
const result = objectValue(value);
const procedure = objectValue(result?.procedure ?? null);
const candidate = procedure?.address ?? result?.address;
return typeof candidate === "string" ? candidate : undefined;
};
const contentDigest = (value: JsonValue): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Resource is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
+78
View File
@@ -13,11 +13,19 @@ import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
} from "../domain/errors.js";
import { err } from "../domain/result.js";
interface EvidenceToolRegistration {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly permissionAuthority?: PermissionAuthority;
}
/** Register provider-backed contracts that return atomic Evidence v2 observations. */
@@ -32,17 +40,52 @@ export const registerEvidenceTools = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const parameters = jsonObjectSchema.parse(
contract.inputSchema.parse(input),
);
if (options.permissionAuthority !== undefined) {
const request = permissionRequest(contract.name, parameters);
if (request !== undefined) {
const authorized = await options.permissionAuthority.authorize(
request,
contract.name === "extract_artifact" ? "write" : "read",
);
if (!authorized.ok)
return toCallToolResult(
err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
),
contract,
);
}
}
const execution = await logToolExecution(
options.logger,
contract.name,
() =>
analysis.execute(contract.name, parameters, {
signal: context.mcpReq.signal,
progress,
}),
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: execution.ok ? "completed" : "failed",
terminal: true,
});
if (!execution.ok) return toCallToolResult(execution, contract);
const evidence = createEvidence(
execution.value.subject ?? options.activeTarget?.(),
@@ -64,3 +107,38 @@ export const registerEvidenceTools = (
);
}
};
const permissionRequest = (
operation: string,
parameters: Readonly<
Record<string, import("../domain/jsonValue.js").JsonValue>
>,
) => {
if (
operation === "extract_artifact" &&
typeof parameters.output_root === "string"
)
return {
capability: "artifact_extract" as const,
roots: [parameters.output_root],
executables: [],
environment_names: [],
network: "none" as const,
mount: false,
operation_identity: `extract_artifact:${parameters.output_root}`,
};
if (
operation === "inventory_artifact" &&
parameters.native_mount_approved === true
)
return {
capability: "native_mount" as const,
roots: [],
executables: [],
environment_names: [],
network: "none" as const,
mount: true,
operation_identity: "inventory_artifact:native_mount",
};
return undefined;
};
+6 -6
View File
@@ -14,6 +14,7 @@ import { toCallToolResult } from "./toolResult.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import { resolveSessionEvidence } from "./sessionEvidence.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register explicit Evidence-backed function comparison. */
export const registerFunctionComparisonTool = (
@@ -24,7 +25,7 @@ export const registerFunctionComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = functionComparisonInputSchema.parse(input);
const left = resolveSessionEvidence(session, parsed.left);
if (!left.ok) return toCallToolResult(left, contract);
@@ -32,12 +33,11 @@ export const registerFunctionComparisonTool = (
if (!right.ok) return toCallToolResult(right, contract);
const leftIds = evidenceIds(left.value);
const rightIds = evidenceIds(right.value);
const comparison = compareFunctions(
left.value,
right.value,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareFunctions(left.value, right.value, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, {
predicateType: "rea.function-comparison/v1",
operation: contract.name,
+273 -166
View File
@@ -30,6 +30,14 @@ import {
} from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
} from "../domain/errors.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register Evidence-composed differential investigation workflows. */
export const registerInvestigationTools = (
@@ -52,13 +60,23 @@ export const registerInvestigationTools = (
interface InvestigationToolPolicies {
readonly evidenceFiles: EvidenceFilePolicy;
readonly inputRoots: readonly string[];
readonly permissionAuthority?: PermissionAuthority;
readonly integrityContinueEnabled?: () => boolean;
}
const investigationExecution = (
session: BinarySessionPort,
signal: AbortSignal,
inputRoots: readonly string[],
) => ({ session, signal, inputRoots });
integrityContinueEnabled: boolean,
progress?: ProgressReporter,
) => ({
session,
signal,
inputRoots,
integrityContinueEnabled,
...(progress === undefined ? {} : { progress }),
});
const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";
@@ -75,6 +93,50 @@ const registerChangedBehavior = (
async (input, context) => {
const parsed = changedBehaviorInputSchema.parse(input);
if (parsed.investigation_run !== undefined) {
const progress = mcpProgressReporter(context);
if (policies.permissionAuthority !== undefined) {
const scopes = [
{
capability: "investigation_workspace_read" as const,
roots: [parsed.investigation_run.workspace_path],
},
{
capability: "investigation_workspace_write" as const,
roots: [parsed.investigation_run.workspace_path],
},
{
capability: "investigation_input" as const,
roots: [
parsed.investigation_run.left_path,
parsed.investigation_run.right_path,
],
},
];
for (const scope of scopes) {
const authorized = await policies.permissionAuthority.authorize(
{
...scope,
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${contract.name}:${scope.capability}:${scope.roots.join(":")}`,
},
scope.capability === "investigation_input" ? "read" : "write",
);
if (!authorized.ok)
return toCallToolResult(
err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
),
contract,
);
}
}
const investigated = await runCrossVersionInvestigation(
parsed.investigation_run,
policies.evidenceFiles,
@@ -82,13 +144,18 @@ const registerChangedBehavior = (
session,
context.mcpReq.signal,
policies.inputRoots,
policies.integrityContinueEnabled?.() ?? false,
progress,
),
);
if (!investigated.ok) return toCallToolResult(investigated, contract);
const workspace = investigated.value.workspace;
if (session.retainInvestigationWorkspace(workspace) === "added")
server.sendResourceListChanged();
const result = changedBehaviorResultSchema.parse(
investigated.value.evidence.normalized_result,
);
return toCallToolResult(
const toolResult = toCallToolResult(
recordWorkflowEvidence(
session,
investigated.value.evidence,
@@ -110,6 +177,20 @@ const registerChangedBehavior = (
),
contract,
);
return {
...toolResult,
content: [
...toolResult.content,
{
type: "resource_link" as const,
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
name: `${workspace.workspace_id} revision ${String(workspace.revision)}`,
description:
"Immutable CAS-linked investigation workspace revision",
mimeType: "application/json",
},
],
};
}
const closure = evidenceClosure(
session,
@@ -117,11 +198,11 @@ const registerChangedBehavior = (
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = findChangedBehavior(
parsed.comparisons,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
findChangedBehavior(parsed.comparisons, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, {
predicateType: "rea.changed-behavior/v1",
operation: contract.name,
@@ -168,54 +249,62 @@ const registerCallPath = (
session: BinarySessionPort,
contract: ToolContract<"build_call_path">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = callPathInputSchema.parse(input);
const closure = evidenceClosure(
session,
functionEvidenceIds(parsed.functions),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = buildCallPath(parsed);
const evidence = createEvidence(undefined, CALL_PATH_PROVIDER, {
predicateType: "rea.call-path/v1",
operation: contract.name,
parameters: {
start: parsed.start.address,
goal: parsed.goal.address,
max_depth: parsed.max_depth,
max_paths: parsed.max_paths,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
const recorded = recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Can the requested call path be established from complete analysis?",
domain: "call-path",
requiredAuthority: "shipped-artifact",
requiredConfidence: "derived",
probes: [
{
operation: "analyze_function",
rationale:
"Collect complete callee dossiers for the reported frontier addresses.",
},
],
},
);
return toCallToolResult(recorded, contract);
});
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = callPathInputSchema.parse(input);
const closure = evidenceClosure(
session,
functionEvidenceIds(parsed.functions),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const computed = await runDerivedOperation(context, contract.name, () =>
buildCallPath(parsed),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, CALL_PATH_PROVIDER, {
predicateType: "rea.call-path/v1",
operation: contract.name,
parameters: {
start: parsed.start.address,
goal: parsed.goal.address,
max_depth: parsed.max_depth,
max_paths: parsed.max_paths,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
const recorded = recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Can the requested call path be established from complete analysis?",
domain: "call-path",
requiredAuthority: "shipped-artifact",
requiredConfidence: "derived",
probes: [
{
operation: "analyze_function",
rationale:
"Collect complete callee dossiers for the reported frontier addresses.",
},
],
},
);
return toCallToolResult(recorded, contract);
},
);
};
const registerStaticRuntime = (
@@ -223,56 +312,64 @@ const registerStaticRuntime = (
session: BinarySessionPort,
contract: ToolContract<"correlate_static_and_runtime">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = staticRuntimeCorrelationInputSchema.parse(input);
const closure = evidenceClosure(
session,
comparisonClosure([
...parsed.static_comparisons,
...parsed.runtime_comparisons,
]),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = correlateStaticAndRuntime(parsed);
const evidence = createEvidence(undefined, STATIC_RUNTIME_PROVIDER, {
predicateType: "rea.static-runtime-correlation/v1",
operation: contract.name,
parameters: {
mapping_count: parsed.mappings.length,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "inferred",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = staticRuntimeCorrelationInputSchema.parse(input);
const closure = evidenceClosure(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Does runtime behavior match the available static analysis?",
domain: "static-runtime-correlation",
requiredAuthority: null,
requiredConfidence: "derived",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Repeat runtime observations and complete the mapped static comparison Evidence.",
},
],
comparisonClosure([
...parsed.static_comparisons,
...parsed.runtime_comparisons,
]),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const computed = await runDerivedOperation(context, contract.name, () =>
correlateStaticAndRuntime(parsed),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, STATIC_RUNTIME_PROVIDER, {
predicateType: "rea.static-runtime-correlation/v1",
operation: contract.name,
parameters: {
mapping_count: parsed.mappings.length,
offset: parsed.offset,
limit: parsed.limit,
},
),
contract,
);
});
result: jsonValueSchema.parse(result),
confidence: "inferred",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Does runtime behavior match the available static analysis?",
domain: "static-runtime-correlation",
requiredAuthority: null,
requiredConfidence: "derived",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Repeat runtime observations and complete the mapped static comparison Evidence.",
},
],
},
),
contract,
);
},
);
};
const registerReconstruction = (
@@ -280,75 +377,85 @@ const registerReconstruction = (
session: BinarySessionPort,
contract: ToolContract<"verify_reconstruction">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = reconstructionVerificationInputSchema.parse(input);
const owned = session.exportEvidenceBundle();
const ownedUnknowns = new Set(
owned.unknowns.map(({ revision_digest: digest }) => digest),
);
const ownedEvidenceIds = new Set(
owned.records.map(({ evidence_id: id }) => id),
);
if (
parsed.evidence_bundle.records.some(
({ evidence_id: id }) => !ownedEvidenceIds.has(id),
) ||
parsed.evidence_bundle.unknowns.some(
({ revision_digest: digest }) => !ownedUnknowns.has(digest),
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = reconstructionVerificationInputSchema.parse(input);
const owned = session.exportEvidenceBundle();
const ownedUnknowns = new Set(
owned.unknowns.map(({ revision_digest: digest }) => digest),
);
const ownedEvidenceIds = new Set(
owned.records.map(({ evidence_id: id }) => id),
);
if (
parsed.evidence_bundle.records.some(
({ evidence_id: id }) => !ownedEvidenceIds.has(id),
) ||
parsed.evidence_bundle.unknowns.some(
({ revision_digest: digest }) => !ownedUnknowns.has(digest),
)
)
)
return toCallToolResult(
err(
new EvidenceIntegrityError(
"Reconstruction input unknown history is not present in this session",
return toCallToolResult(
err(
new EvidenceIntegrityError(
"Reconstruction input unknown history is not present in this session",
),
),
contract,
);
const computed = await runDerivedOperation(context, contract.name, () =>
verifyReconstruction(
parsed.specification,
owned,
parsed.offset,
parsed.limit,
),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const closure = evidenceClosure(session, result.evidence_links);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const evidence = createEvidence(undefined, RECONSTRUCTION_PROVIDER, {
predicateType: "rea.reconstruction-verification/v1",
operation: contract.name,
parameters: {
specification_sha256: result.specification_sha256,
claim_ids: parsed.specification.claims.map(({ claim_id: id }) => id),
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown",
{
question: "Does the reconstruction satisfy every declared claim?",
domain: "reconstruction-verification",
requiredAuthority: null,
requiredConfidence: "derived",
probes: result.recommended_probes.map(
({ operation, rationale }) => ({
operation,
rationale,
}),
),
},
),
contract,
);
const result = verifyReconstruction(
parsed.specification,
owned,
parsed.offset,
parsed.limit,
);
const closure = evidenceClosure(session, result.evidence_links);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const evidence = createEvidence(undefined, RECONSTRUCTION_PROVIDER, {
predicateType: "rea.reconstruction-verification/v1",
operation: contract.name,
parameters: {
specification_sha256: result.specification_sha256,
claim_ids: parsed.specification.claims.map(({ claim_id: id }) => id),
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown",
{
question: "Does the reconstruction satisfy every declared claim?",
domain: "reconstruction-verification",
requiredAuthority: null,
requiredConfidence: "derived",
probes: result.recommended_probes.map(({ operation, rationale }) => ({
operation,
rationale,
})),
},
),
contract,
);
});
},
);
};
const contractOptions = (contract: ToolContract) => ({
+16
View File
@@ -17,6 +17,7 @@ import {
AnalysisCapabilityUnavailableError,
UnknownRegistryError,
} from "../domain/errors.js";
import { mcpProgressReporter } from "./mcpProgress.js";
/** Optional session services used by direct tool registration. */
export interface OfficialToolRegistration {
@@ -63,14 +64,29 @@ const registerOfficialTool = (
},
async (input, context) => {
const arguments_ = projectOfficialArguments(contract, input);
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const result = await logToolExecution(
registration.logger,
contract.name,
() =>
analysis.execute(contract.name, arguments_, {
signal: context.mcpReq.signal,
progress,
}),
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
if (result.ok) {
const evidence = createEvidence(
result.value.subject ?? registration.activeTarget?.(),
+12 -7
View File
@@ -18,6 +18,7 @@ import { err } from "../domain/result.js";
import { PROCESS_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register deterministic process-capture comparison and contradiction tracking. */
export const registerProcessComparisonTool = (
@@ -29,7 +30,7 @@ export const registerProcessComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = z
.object({
left_evidence_id: z.string(),
@@ -44,12 +45,16 @@ export const registerProcessComparisonTool = (
if (!validated.ok) return toCallToolResult(validated, contract);
let comparison: ReturnType<typeof compareProcessCaptures>;
try {
comparison = compareProcessCaptures(parsed.left, parsed.right, {
...(parsed.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: parsed.max_capture_age_ms }),
now,
});
const computed = await runDerivedOperation(context, contract.name, () =>
compareProcessCaptures(parsed.left, parsed.right, {
...(parsed.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: parsed.max_capture_age_ms }),
now,
}),
);
if (!computed.ok) return toCallToolResult(computed, contract);
comparison = computed.value;
} catch (cause: unknown) {
return toCallToolResult(
err(
+60 -2
View File
@@ -1,14 +1,25 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import {
binarySessionInputSchema,
SESSION_TOOL_CONTRACTS,
} from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
import { buildCapabilityInventory } from "../application/CapabilityInventory.js";
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[2],
startedAt: string,
availabilityPolicy: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
): void => {
server.registerTool(
contract.name,
@@ -18,6 +29,53 @@ export const registerSessionStatusTool = (
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
() => toCallToolResult({ ok: true, value: session.status() }, contract),
(input) => {
const parsed = binarySessionInputSchema.parse(input);
const client = server.server.getClientVersion();
const clientCapabilities = server.server.getClientCapabilities();
const status = session.status();
return toCallToolResult(
{
ok: true,
value: {
...jsonObjectSchema.parse(status),
tool_availability: buildCapabilityInventory(
status,
availabilityPolicy(),
),
client_features: {
elicitation_form:
clientCapabilities?.elicitation?.form !== undefined,
elicitation_url:
clientCapabilities?.elicitation?.url !== undefined,
roots: clientCapabilities?.roots !== undefined,
sampling: clientCapabilities?.sampling !== undefined,
},
server_identity: createServerIdentity({
startedAt,
expected: {
...(parsed.expected_package_version === undefined
? {}
: { package_version: parsed.expected_package_version }),
...(parsed.expected_catalog_digest === undefined
? {}
: { catalog_digest: parsed.expected_catalog_digest }),
...(parsed.expected_server_path === undefined
? {}
: { server_path: parsed.expected_server_path }),
},
...(client === undefined ? {} : { client }),
...(server.server.getNegotiatedProtocolVersion() === undefined
? {}
: {
protocolVersion:
server.server.getNegotiatedProtocolVersion(),
}),
}),
},
},
contract,
);
},
);
};
+196 -8
View File
@@ -6,7 +6,7 @@ import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { ok, type Result } from "../domain/result.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
ProcessCapture,
ProcessExecutionPolicy,
@@ -29,7 +29,12 @@ import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../application/AnalysisSnapshotFiles.js";
import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
UnknownRegistryError,
type AnalysisError,
} from "../domain/errors.js";
import {
DENY_EVIDENCE_FILE_POLICY,
DENY_PROCESS_POLICY,
@@ -45,6 +50,22 @@ import {
openBinaryInputSchema,
} from "../contracts/sessionLifecycleInputs.js";
import { registerSessionStatusTool } from "./registerSessionStatusTool.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import { mcpProgressReporter } from "./mcpProgress.js";
const permissionFailure = (
failure: Awaited<ReturnType<PermissionAuthority["authorize"]>>,
): Result<never, AnalysisError> => {
if (failure.ok)
return err(new AnalysisProtocolError("Expected a denied permission"));
return err(
failure.error instanceof PermissionRequiredError
? failure.error
: new AnalysisProtocolError(failure.error.message, {
cause: failure.error,
}),
);
};
const recordProcessResidualUnknowns = (
session: BinarySessionPort,
@@ -91,6 +112,11 @@ interface ProcessToolRegistration {
readonly logger: Logger;
readonly processPolicy: ProcessExecutionPolicy;
readonly captureContract: (typeof SESSION_TOOL_CONTRACTS)[5];
readonly permissionAuthority?: PermissionAuthority;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
const registerProcessTools = ({
@@ -99,6 +125,7 @@ const registerProcessTools = ({
logger,
processPolicy,
captureContract,
permissionAuthority,
}: ProcessToolRegistration): void => {
server.registerTool(
captureContract.name,
@@ -110,6 +137,35 @@ const registerProcessTools = ({
},
async (input, context) => {
const scenario = processScenarioSchema.parse(input);
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "process_capture",
roots: [scenario.working_directory, ...scenario.filesystem_roots],
executables: [scenario.executable],
environment_names: [
...Object.keys(scenario.environment),
...scenario.inherit_environment,
],
network: scenario.network_access === "host" ? "external" : "none",
mount: false,
operation_identity: `capture_process_scenario:${scenario.executable}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
captureContract,
);
}
const progress = mcpProgressReporter(context);
await progress.report({
phase: captureContract.name,
completed: 0,
total: 1,
message: "started",
});
const captured = await logToolExecution(
logger,
captureContract.name,
@@ -120,6 +176,13 @@ const registerProcessTools = ({
context.mcpReq.signal,
),
);
await progress.report({
phase: captureContract.name,
completed: 1,
total: 1,
message: captured.ok ? "completed" : "failed",
terminal: true,
});
if (!captured.ok) return toCallToolResult(captured, captureContract);
const evidence = createProcessCaptureEvidence(scenario, captured.value);
const recorded = session.recordEvidence(evidence);
@@ -142,6 +205,7 @@ interface EvidenceToolRegistration {
readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3];
readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4];
readonly filePolicy: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
}
const registerEvidenceTools = ({
@@ -150,6 +214,7 @@ const registerEvidenceTools = ({
exportContract,
importContract,
filePolicy,
permissionAuthority,
}: EvidenceToolRegistration): void => {
server.registerTool(
exportContract.name,
@@ -169,6 +234,25 @@ const registerEvidenceTools = ({
const bundle = session.exportEvidenceBundle();
if (parsed.path === undefined)
return toCallToolResult(ok(bundle), exportContract);
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_write",
roots: [parsed.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `export_evidence:${parsed.path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
exportContract,
);
}
const written = await writeEvidenceBundle(
bundle,
parsed.path,
@@ -197,6 +281,25 @@ const registerEvidenceTools = ({
},
async (input) => {
const path = z.object({ path: z.string().min(1) }).parse(input).path;
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_read",
roots: [path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `import_evidence:${path}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
importContract,
);
}
const loaded = await readEvidenceBundle(path, filePolicy);
if (!loaded.ok) return toCallToolResult(loaded, importContract);
const imported = session.importEvidenceBundle(loaded.value);
@@ -325,6 +428,12 @@ interface LifecycleToolRegistration {
(typeof SESSION_TOOL_CONTRACTS)[2],
];
readonly snapshotFilePolicy: EvidenceFilePolicy;
readonly startedAt: string;
readonly availabilityPolicy: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
readonly permissionAuthority?: PermissionAuthority;
}
const registerLifecycleTools = ({
@@ -333,6 +442,9 @@ const registerLifecycleTools = ({
logger,
contracts,
snapshotFilePolicy,
startedAt,
availabilityPolicy,
permissionAuthority,
}: LifecycleToolRegistration): void => {
const [openContract, closeContract, statusContract] = contracts;
server.registerTool(
@@ -347,6 +459,25 @@ const registerLifecycleTools = ({
const parsed = openBinaryInputSchema.parse(input);
let snapshot: AnalysisSnapshot | undefined;
if (parsed.snapshot_path !== undefined) {
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_read",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `open_binary:snapshot:${parsed.snapshot_path}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
openContract,
);
}
const loaded = await readAnalysisSnapshot(
parsed.snapshot_path,
snapshotFilePolicy,
@@ -360,6 +491,7 @@ const registerLifecycleTools = ({
...(snapshot === undefined ? {} : { snapshot }),
}),
);
if (opened.ok) server.sendToolListChanged();
return opened.ok
? toCallToolResult(
{
@@ -389,6 +521,25 @@ const registerLifecycleTools = ({
async (input) => {
const parsed = closeBinaryInputSchema.parse(input);
if (parsed.snapshot_path !== undefined) {
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_write",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `close_binary:snapshot:${parsed.snapshot_path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
closeContract,
);
}
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return toCallToolResult(snapshot, closeContract);
const written = await writeAnalysisSnapshot(
@@ -401,6 +552,7 @@ const registerLifecycleTools = ({
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
if (closed.ok) server.sendToolListChanged();
return closed.ok
? toCallToolResult(
ok({
@@ -412,15 +564,20 @@ const registerLifecycleTools = ({
)
: toCallToolResult(closed, closeContract);
}
return toCallToolResult(
await logToolExecution(logger, closeContract.name, () =>
session.close(),
),
closeContract,
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
if (closed.ok) server.sendToolListChanged();
return toCallToolResult(closed, closeContract);
},
);
registerSessionStatusTool(server, session, statusContract);
registerSessionStatusTool(
server,
session,
statusContract,
startedAt,
availabilityPolicy,
);
};
/** Register MCP-only target lifecycle operations on a long-lived session. */
@@ -429,6 +586,13 @@ export interface SessionToolOptions {
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly investigationInputRoots?: readonly string[];
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
readonly startedAt?: string;
readonly permissionAuthority?: PermissionAuthority;
readonly artifactIntegrityContinueEnabled?: () => boolean;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
export const registerSessionTools = (
@@ -468,6 +632,16 @@ export const registerSessionTools = (
logger,
contracts: [openContract, closeContract, statusContract],
snapshotFilePolicy: analysisSnapshotFilePolicy,
startedAt: options.startedAt ?? new Date().toISOString(),
availabilityPolicy:
options.availabilityPolicy ??
(() => ({
processCaptureEnabled: processPolicy.enabled,
evidenceFileRoots: evidenceFilePolicy.roots.length,
})),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerEvidenceTools({
server,
@@ -475,6 +649,9 @@ export const registerSessionTools = (
exportContract,
importContract,
filePolicy: evidenceFilePolicy,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerProcessTools({
server,
@@ -482,6 +659,9 @@ export const registerSessionTools = (
logger,
processPolicy,
captureContract,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerProcessComparisonTool(server, session, compareContract);
registerArtifactComparisonTool(server, session, compareArtifactsContract);
@@ -496,6 +676,14 @@ export const registerSessionTools = (
registerInvestigationTools(server, session, investigationContracts, {
evidenceFiles: evidenceFilePolicy,
inputRoots: options.investigationInputRoots ?? [],
...(options.artifactIntegrityContinueEnabled === undefined
? {}
: {
integrityContinueEnabled: options.artifactIntegrityContinueEnabled,
}),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerUnknownTools({
server,
+57
View File
@@ -0,0 +1,57 @@
import { setImmediate } from "node:timers/promises";
import { AnalysisCancelledError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { mcpProgressReporter } from "./mcpProgress.js";
interface DerivedOperationContext {
readonly mcpReq: {
readonly signal: AbortSignal;
readonly _meta?: { readonly progressToken?: string | number | undefined };
notify(notification: {
readonly method: "notifications/progress";
readonly params: {
readonly progressToken: string | number;
readonly progress: number;
readonly total?: number;
readonly message?: string;
};
}): Promise<void>;
};
}
/** Run synchronous derived work with transport yields and a publication guard. */
export const runDerivedOperation = async <Value>(
context: DerivedOperationContext,
operation: string,
compute: () => Value,
): Promise<Result<Value, AnalysisCancelledError>> => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: "prepare",
completed: 0,
total: 2,
message: `Preparing ${operation}`,
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
const value = compute();
await progress.report({
phase: "compute",
completed: 1,
total: 2,
message: `Computed ${operation}`,
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
await progress.report({
phase: "complete",
completed: 2,
total: 2,
message: `Completed ${operation}`,
terminal: true,
});
return ok(value);
};
+24 -1
View File
@@ -60,11 +60,34 @@ const successResult = (
};
}
return {
content: [{ type: "text", text: formatValue(value) }],
content: [
{ type: "text", text: formatValue(value) },
...evidenceResourceLinks(value),
],
structuredContent: candidate,
};
};
const evidenceResourceLinks = (value: JsonValue): CallToolResult["content"] => {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
typeof value.evidence_id !== "string" ||
!/^ev_[a-f0-9]{64}$/u.test(value.evidence_id)
)
return [];
return [
{
type: "resource_link",
uri: `rea://evidence/${value.evidence_id}`,
name: value.evidence_id,
description: "Session-owned Evidence v2 record",
mimeType: "application/json",
},
];
};
const formatValue = (value: JsonValue): string => {
if (value === null) return "OK";
if (typeof value === "string") return value;
+145
View File
@@ -66,6 +66,7 @@ describe("artifact graph MCP integration", () => {
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: {
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
@@ -75,6 +76,12 @@ describe("artifact graph MCP integration", () => {
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
unpacked,
},
retryable: false,
remediation: {
action:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
restart_required: false,
},
},
});
} finally {
@@ -87,6 +94,144 @@ describe("artifact graph MCP integration", () => {
},
);
it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-asar-continue-mcp-"));
const source = join(root, "source");
await mkdir(source);
const original = "console.log('ok');\n";
const changed = "console.log('no');\n";
const secondOriginal = "console.log('up');\n";
const secondChanged = "console.log('dn');\n";
await writeFile(join(source, "main.js"), original);
await writeFile(join(source, "second.js"), secondOriginal);
await writeFile(join(source, "sibling.js"), "console.log('sibling');\n");
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, {});
const bytes = await readFile(archive);
const contentOffset = bytes.indexOf(original);
const secondOffset = bytes.indexOf(secondOriginal);
expect(contentOffset).toBeGreaterThanOrEqual(0);
expect(secondOffset).toBeGreaterThanOrEqual(0);
bytes.write(changed, contentOffset, "utf8");
bytes.write(secondChanged, secondOffset, "utf8");
await writeFile(archive, bytes);
const session = new BinarySession(new ArtifactProvider(false, true));
const server = createServer(session, session);
const client = new Client({ name: "asar-continue-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
const limited = await client.callTool({
name: "inventory_artifact",
arguments: {
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
max_integrity_mismatches: 1,
},
});
expect(limited).toMatchObject({
isError: true,
structuredContent: { error: { code: "truncated" } },
});
const result = await client.callTool({
name: "inventory_artifact",
arguments: {
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
},
});
expect(result.isError).not.toBe(true);
const evidence = parseEvidence(result.structuredContent);
const inventory = z
.object({
occurrences: z.object({
items: z.array(
z.object({ logical_path: z.string(), hash_status: z.string() }),
),
}),
integrity_contradictions: z.array(
z.object({
logical_path: z.string(),
declared_sha256: z.string(),
observed_sha256: z.string(),
trust: z.literal("observed-untrusted"),
}),
),
})
.parse(evidence.normalized_result);
expect(inventory.integrity_contradictions).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
trust: "observed-untrusted",
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
}),
expect.objectContaining({ logical_path: "second.js" }),
]),
);
expect(inventory.integrity_contradictions).toHaveLength(2);
expect(inventory.occurrences.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
hash_status: "mismatched",
}),
expect.objectContaining({
logical_path: "sibling.js",
hash_status: "verified",
}),
expect.objectContaining({
logical_path: "second.js",
hash_status: "mismatched",
}),
]),
);
const compared = await client.callTool({
name: "compare_artifacts",
arguments: {
left: evidence,
right: evidence,
},
});
expect(compared.isError).not.toBe(true);
const comparison = parseEvidence(
z.object({ result: z.unknown() }).parse(compared.structuredContent)
.result,
);
expect(comparison.normalized_result).toMatchObject({
status: "contradiction",
summary: { contradiction: 2 },
changes: {
items: expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
classification: "contradiction",
dimensions: ["integrity"],
}),
expect.objectContaining({
logical_path: "second.js",
classification: "contradiction",
}),
]),
},
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
it("rejects altered payloads that reuse session Evidence IDs", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { runDerivedOperation } from "../src/server/runDerivedOperation.js";
const context = (
controller: AbortController,
notifications: unknown[] = [],
) => ({
mcpReq: {
signal: controller.signal,
_meta: { progressToken: "derived-test" },
notify: (notification: unknown) => {
notifications.push(notification);
return Promise.resolve();
},
},
});
describe("derived MCP operation boundary", () => {
it("cancels before computation and never evaluates the operation", async () => {
const controller = new AbortController();
controller.abort();
let computed = false;
const result = await runDerivedOperation(
context(controller),
"compare",
() => {
computed = true;
return "impossible";
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(computed).toBe(false);
});
it("lets cancellation win immediately before result publication", async () => {
const controller = new AbortController();
const result = await runDerivedOperation(
context(controller),
"compare",
() => {
controller.abort();
return "must-not-publish";
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it("emits bounded monotonic progress with one terminal state", async () => {
const controller = new AbortController();
const notifications: Array<{
params?: { progress?: number; total?: number };
}> = [];
const result = await runDerivedOperation(
context(controller, notifications),
"compare",
() => "complete",
);
expect(result).toEqual({ ok: true, value: "complete" });
expect(notifications.map(({ params }) => params?.progress)).toEqual([0, 2]);
expect(notifications.every(({ params }) => params?.total === 2)).toBe(true);
});
});
+7
View File
@@ -311,9 +311,16 @@ describe("enhanced MCP tools", () => {
address: "0x2",
status: "error",
error: {
code: "unreadable_output",
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
retryable: false,
remediation: {
action:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
restart_required: false,
},
},
},
],
+141
View File
@@ -0,0 +1,141 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import { createEvidence } from "../src/domain/evidence.js";
import { observed } from "./fixtures/analysisExecution.js";
import { createServer } from "../src/server/createServer.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
describe("evidence MCP resources", () => {
it("lists and reads only evidence owned by the current session", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const evidence = createEvidence(undefined, provider, {
operation: "resource_probe",
parameters: {},
result: { observed: true },
});
session.recordEvidence(evidence);
const capture = createEvidence(undefined, provider, {
operation: "capture_process_scenario",
parameters: {},
result: {
frames: [{ sequence: 0, at_ms: 0, data: "ready" }],
protocol_events: [],
},
});
session.recordEvidence(capture);
const workspace = createInvestigationWorkspace(
"resource test",
createEvidenceBundle([]),
[],
);
session.retainInvestigationWorkspace(workspace);
const unknown = session.recordUnknown({
approved: true,
question: "Was every branch observed?",
severity: "medium",
domain: "control-flow",
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: "controlled-replay",
required_confidence: "observed",
required_environment: null,
recommended_probes: [],
relationships: [],
});
expect(unknown.ok).toBe(true);
const server = createServer(session, session);
const client = new Client({ name: "evidence-resource-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const templates = await client.listResourceTemplates();
expect(templates.resourceTemplates).toContainEqual(
expect.objectContaining({ uriTemplate: "rea://evidence/{evidenceId}" }),
);
expect(
templates.resourceTemplates.map(({ uriTemplate }) => uriTemplate),
).toEqual(
expect.arrayContaining([
"rea://artifact/{manifestId}/{collection}",
"rea://function/{targetSha256}/{address}",
"rea://snapshot/{snapshotDigest}",
"rea://workspace/{workspaceId}/revision/{revision}",
]),
);
expect(templates.resourceTemplates).toContainEqual(
expect.objectContaining({
uriTemplate: "rea://evidence/{evidenceId}/section/{section}",
}),
);
const listed = await client.listResources();
expect(listed.resources).toContainEqual(
expect.objectContaining({
uri: `rea://evidence/${evidence.evidence_id}`,
}),
);
const read = await client.readResource({
uri: `rea://evidence/${evidence.evidence_id}`,
});
expect(read.contents).toEqual([
expect.objectContaining({
uri: `rea://evidence/${evidence.evidence_id}`,
mimeType: "application/json",
text: expect.stringContaining(evidence.evidence_id),
}),
]);
const terminal = await client.readResource({
uri: `rea://evidence/${capture.evidence_id}/section/terminal`,
});
const workspaceResource = await client.readResource({
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
});
expect(workspaceResource.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(workspace.revision_digest),
}),
);
expect(terminal.contents[0]).toEqual(
expect.objectContaining({ text: expect.stringContaining("ready") }),
);
if (unknown.ok) {
const unknownResource = await client.readResource({
uri: `rea://unknown/${unknown.value.unknown_id}`,
});
expect(unknownResource.contents[0]).toEqual(
expect.objectContaining({
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)}` }),
).rejects.toThrow(/not found/iu);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
});
+63
View File
@@ -22,6 +22,49 @@ import { readInvestigationWorkspace } from "../src/application/InvestigationWork
import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
describe("investigation MCP workflows", () => {
it("never retains derived evidence after request cancellation", async () => {
const { session, server, client } = await connected();
const inputs = [
{
name: "find_changed_behavior",
arguments: INVESTIGATION_EXAMPLES.find_changed_behavior,
},
{
name: "build_call_path",
arguments: INVESTIGATION_EXAMPLES.build_call_path,
},
{
name: "correlate_static_and_runtime",
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
},
] as const;
for (const evidence of [
FUNCTION_COMPARISON_EXAMPLE.left,
FUNCTION_COMPARISON_EXAMPLE.right,
FUNCTION_COMPARISON_EVIDENCE,
PROCESS_CAPTURE_REFERENCE,
PROCESS_CAPTURE_RECONSTRUCTION,
PROCESS_COMPARISON_EVIDENCE,
])
expect(session.recordEvidence(evidence).ok).toBe(true);
const initialCount = session.exportEvidenceBundle().records.length;
try {
for (const input of inputs) {
const controller = new AbortController();
const request = client.callTool(input, {
signal: controller.signal,
onprogress: () => controller.abort(),
});
await expect(request).rejects.toThrow(/abort/iu);
}
await expect
.poll(() => session.exportEvidenceBundle().records.length)
.toBe(initialCount);
} finally {
await close(session, server, client);
}
});
it("runs and reuses a persistent cross-version workspace", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-"));
const left = join(directory, "left");
@@ -64,6 +107,26 @@ describe("investigation MCP workflows", () => {
ok: true,
value: { revision: 3, runs: [{ status: "complete" }] },
});
expect(first.content).toContainEqual(
expect.objectContaining({
type: "resource_link",
uri: expect.stringMatching(
/^rea:\/\/workspace\/ws_[a-f0-9]{64}\/revision\/3$/u,
),
}),
);
const retainedWorkspace = session.investigationWorkspaces()[0];
expect(retainedWorkspace).toBeDefined();
if (retainedWorkspace !== undefined) {
const resource = await client.readResource({
uri: `rea://workspace/${retainedWorkspace.workspace_id}/revision/${String(retainedWorkspace.revision)}`,
});
expect(resource.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(retainedWorkspace.revision_digest),
}),
);
}
const second = await client.callTool({
name: "find_changed_behavior",
+57
View File
@@ -0,0 +1,57 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { PermissionAuthority } from "../src/application/PermissionAuthority.js";
import { createPermissionPolicy } from "../src/domain/permissionPolicy.js";
import { createServer } from "../src/server/createServer.js";
describe("MCP permission preflight", () => {
it("denies extraction before dispatch and returns exact typed remediation", async () => {
let dispatches = 0;
const analysis: AnalysisOperationPort = {
execute: () => {
dispatches += 1;
throw new Error("provider must not run");
},
};
const server = createServer(analysis, undefined, {
permissionAuthority: new PermissionAuthority(createPermissionPolicy([])),
});
const client = new Client({ name: "permission-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: "extract_artifact",
arguments: {
approved: true,
output_root: "/tmp/rea-permission-denied",
occurrence_ids: [`occ_${"0".repeat(64)}`],
},
});
expect(dispatches).toBe(0);
expect(result).toMatchObject({
isError: true,
structuredContent: {
error: {
code: "permission_required",
details: {
capability: "artifact_extract",
missing: { roots: ["/tmp/rea-permission-denied"] },
},
remediation: {
restart_required: false,
elicitation_supported: false,
},
},
},
});
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../src/domain/errors.js";
import { err } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
describe("ordinary MCP progress and cancellation", () => {
it("emits bounded progress for a normal tool request", async () => {
const analysis: AnalysisOperationPort = {
execute: () => Promise.resolve(observed("0x1000")),
};
const server = createServer(analysis);
const client = new Client({ name: "progress-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const progress: number[] = [];
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool(
{ name: "current_address", arguments: {} },
{ onprogress: (update) => progress.push(update.progress) },
);
expect(result.isError).not.toBe(true);
expect(progress).toEqual([0, 1]);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
it("propagates client cancellation to the active provider request", async () => {
let providerObservedCancellation = false;
let markProviderStarted: (() => void) | undefined;
const providerStarted = new Promise<void>((resolve) => {
markProviderStarted = resolve;
});
const analysis: AnalysisOperationPort = {
execute: async (operation, _parameters, options) => {
markProviderStarted?.();
await new Promise<void>((resolve) => {
options?.signal?.addEventListener(
"abort",
() => {
providerObservedCancellation = true;
resolve();
},
{ once: true },
);
});
return err(new AnalysisCancelledError(operation));
},
};
const server = createServer(analysis);
const client = new Client({ name: "cancellation-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const controller = new AbortController();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const request = client.callTool(
{ name: "current_address", arguments: {} },
{ signal: controller.signal },
);
await providerStarted;
controller.abort();
await expect(request).rejects.toThrow(/abort/iu);
await expect.poll(() => providerObservedCancellation).toBe(true);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
});
+36 -1
View File
@@ -6,6 +6,7 @@ import { ok } from "../src/domain/result.js";
import { err } from "../src/domain/result.js";
import { HopperProcessError } from "../src/domain/errors.js";
import { toCallToolResult } from "../src/server/toolResult.js";
import { createEvidence, evidenceSchema } from "../src/domain/evidence.js";
const contract: ToolContract = {
name: "provider_neutral_fixture",
@@ -27,7 +28,8 @@ describe("tool result projection", () => {
const result = toCallToolResult(err(new HopperProcessError(76)), contract);
expect(result.structuredContent).toMatchObject({
error: {
code: "unsupported_demo_dialog",
code: "provider_unavailable",
details: { failure_code: "unsupported_demo_dialog" },
category: "execution_failure",
},
});
@@ -43,12 +45,45 @@ describe("tool result projection", () => {
],
structuredContent: {
error: {
code: "unreadable_output",
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
retryable: false,
remediation: {
action:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
restart_required: false,
},
},
},
isError: true,
});
});
it("links successful evidence to its session resource URI", () => {
const evidence = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "fixture",
parameters: {},
result: { value: "observed" },
limitations: [],
},
);
const evidenceContract: ToolContract = {
...contract,
outputSchema: evidenceSchema,
};
expect(
toCallToolResult(ok(evidence), evidenceContract).content,
).toContainEqual(
expect.objectContaining({
type: "resource_link",
uri: `rea://evidence/${evidence.evidence_id}`,
}),
);
});
});