From da59299a8a83241e6912a062cf8d5744993cad9e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:12:50 +0000 Subject: [PATCH] feat(mcp): expose progress resources and availability --- src/main.ts | 138 +++++- src/server/createServer.ts | 75 +++- src/server/mcpProgress.ts | 46 ++ src/server/registerArtifactComparisonTool.ts | 12 +- src/server/registerArtifactTools.ts | 2 + src/server/registerBundleComparisonTool.ts | 19 +- src/server/registerEnhancedTools.ts | 15 + src/server/registerEvidenceResources.ts | 398 +++++++++++++++++ src/server/registerEvidenceTools.ts | 78 ++++ src/server/registerFunctionComparisonTool.ts | 12 +- src/server/registerInvestigationTools.ts | 439 ++++++++++++------- src/server/registerOfficialTools.ts | 16 + src/server/registerProcessComparisonTool.ts | 19 +- src/server/registerSessionStatusTool.ts | 62 ++- src/server/registerSessionTools.ts | 204 ++++++++- src/server/runDerivedOperation.ts | 57 +++ src/server/toolResult.ts | 25 +- tests/artifactMcp.test.ts | 145 ++++++ tests/derivedOperation.test.ts | 72 +++ tests/enhancedTools.test.ts | 7 + tests/evidenceResources.test.ts | 141 ++++++ tests/investigationMcp.test.ts | 63 +++ tests/permissionMcp.test.ts | 57 +++ tests/progressMcp.test.ts | 77 ++++ tests/toolResult.test.ts | 37 +- 25 files changed, 1999 insertions(+), 217 deletions(-) create mode 100644 src/server/mcpProgress.ts create mode 100644 src/server/registerEvidenceResources.ts create mode 100644 src/server/runDerivedOperation.ts create mode 100644 tests/derivedOperation.test.ts create mode 100644 tests/evidenceResources.test.ts create mode 100644 tests/permissionMcp.test.ts create mode 100644 tests/progressMcp.test.ts diff --git a/src/main.ts b/src/main.ts index 68f0c8085d..46d6c85fb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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>(); 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 | undefined; const shutdown = (): Promise => { shutdownPromise ??= (async () => { + unregisterReload(); await handle.close(); await session.close(); + permissionAuthority.value.clearSessionGrants(); })(); return shutdownPromise; }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 8cfb48454c..87d2f2e113 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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[0]) => - session.recordEvidence(evidence); + : (evidence: Parameters[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; }; diff --git a/src/server/mcpProgress.ts b/src/server/mcpProgress.ts new file mode 100644 index 0000000000..430fa8e37d --- /dev/null +++ b/src/server/mcpProgress.ts @@ -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; + }; +} + +/** 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 }, + ); +}; diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index f96ce8cd84..306f3f941c 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -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, { diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index f56069dce4..2cf7a5844f 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -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); diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index eca1d414d4..4ce0358440 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -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, diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index e767e50498..d1f2134f65 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -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?.(), diff --git a/src/server/registerEvidenceResources.ts b/src/server/registerEvidenceResources.ts new file mode 100644 index 0000000000..d67ece291d --- /dev/null +++ b/src/server/registerEvidenceResources.ts @@ -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>, + 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> | 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"); +}; diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 79d8e90c25..9b1ebdf677 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -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 + >, +) => { + 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; +}; diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 60a73028ff..7561cba067 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -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, diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 95f072d283..152480e73a 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -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) => ({ diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 9870e02f05..52d8cb0b8e 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -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?.(), diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index fdf99e64c6..b0209ba6f3 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -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; 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( diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index fc4c4f7632..ba57f36585 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -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, + ); + }, ); }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index b643c1b287..cc61a92232 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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>, +): Result => { + 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, diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts new file mode 100644 index 0000000000..eb58d734c3 --- /dev/null +++ b/src/server/runDerivedOperation.ts @@ -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; + }; +} + +/** Run synchronous derived work with transport yields and a publication guard. */ +export const runDerivedOperation = async ( + context: DerivedOperationContext, + operation: string, + compute: () => Value, +): Promise> => { + 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); +}; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 9205732da7..f452d916a2 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -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; diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index 07a1c94553..5259c95b2f 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -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(), diff --git a/tests/derivedOperation.test.ts b/tests/derivedOperation.test.ts new file mode 100644 index 0000000000..5d1fe9c9f6 --- /dev/null +++ b/tests/derivedOperation.test.ts @@ -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); + }); +}); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index c5cfe7f5a7..f7cceb4f55 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -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, + }, }, }, ], diff --git a/tests/evidenceResources.test.ts b/tests/evidenceResources.test.ts new file mode 100644 index 0000000000..c1ff2fdfb6 --- /dev/null +++ b/tests/evidenceResources.test.ts @@ -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(), + ]); + } + }); +}); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 4c9a128e9f..46a57d2dbc 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -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", diff --git a/tests/permissionMcp.test.ts b/tests/permissionMcp.test.ts new file mode 100644 index 0000000000..012de05d12 --- /dev/null +++ b/tests/permissionMcp.test.ts @@ -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()]); + } + }); +}); diff --git a/tests/progressMcp.test.ts b/tests/progressMcp.test.ts new file mode 100644 index 0000000000..2a7fadd989 --- /dev/null +++ b/tests/progressMcp.test.ts @@ -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((resolve) => { + markProviderStarted = resolve; + }); + const analysis: AnalysisOperationPort = { + execute: async (operation, _parameters, options) => { + markProviderStarted?.(); + await new Promise((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()]); + } + }); +}); diff --git a/tests/toolResult.test.ts b/tests/toolResult.test.ts index 4e423c8bdd..7429738696 100644 --- a/tests/toolResult.test.ts +++ b/tests/toolResult.test.ts @@ -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}`, + }), + ); + }); });