mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(mcp): expose progress resources and availability
This commit is contained in:
+131
-7
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user