From ee1cd40405405097b9d5f9686cbcc765ab03d96a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:49:35 +0800 Subject: [PATCH] feat(mcp)!: harden contracts and evidence references BREAKING CHANGE: comparison tools now require session-owned Evidence IDs or approved bundle paths, and structured Evidence results use compact references. --- package.json | 11 +- scripts/generate-mcp-schema-report.mjs | 69 +++++++ scripts/rea.mjs | 2 +- scripts/verify-package.mjs | 19 +- scripts/verify-published-package.mjs | 6 +- scripts/verify-real-hopper.mjs | 4 +- src/application/CapabilityInventory.ts | 1 + .../JavaScriptApplicationWorkflowService.ts | 19 +- src/application/JavaScriptReplayService.ts | 9 +- .../JavaScriptRuntimeReconciliationService.ts | 10 +- src/catalogIdentity.ts | 5 +- src/cliJsonInput.ts | 16 +- src/contracts/applicationToolContracts.ts | 34 +--- src/contracts/artifactToolContracts.ts | 11 +- src/contracts/browserToolContracts.ts | 79 ++------ src/contracts/electronToolContracts.ts | 51 ++--- src/contracts/investigationExamples.ts | 6 - src/contracts/managedToolContracts.ts | 8 +- src/contracts/nativeToolContracts.ts | 8 +- src/contracts/toolContractExamples.ts | 35 +--- src/contracts/toolContractTypes.ts | 7 +- src/contracts/toolContracts.ts | 111 +++++----- src/contracts/toolEffects.ts | 181 +++++++++++++++++ src/contracts/toolOutputSchemas.ts | 77 +++---- src/domain/artifactComparison.ts | 7 +- src/domain/bundleComparison.ts | 7 +- src/domain/errors.ts | 49 +++++ src/domain/functionComparisonSchemas.ts | 12 +- src/domain/inputIssueProjection.ts | 83 ++++++++ .../reconstructionVerificationSchemas.ts | 5 +- src/server/registerApplicationTools.ts | 26 ++- src/server/registerArtifactComparisonTool.ts | 50 +++-- src/server/registerBrowserTools.ts | 71 ++++++- src/server/registerBundleComparisonTool.ts | 62 ++---- src/server/registerElectronTools.ts | 36 +++- src/server/registerEnhancedTools.ts | 17 +- src/server/registerEvidenceTools.ts | 9 +- src/server/registerFunctionComparisonTool.ts | 36 +++- src/server/registerInvestigationTools.ts | 63 +++--- src/server/registerOfficialTools.ts | 19 +- src/server/registerProcessComparisonTool.ts | 100 ++++----- src/server/registerSessionStatusTool.ts | 17 +- src/server/registerSessionTools.ts | 174 ++++++++-------- src/server/sessionEvidence.ts | 64 +++--- src/server/toolInputValidation.ts | 128 ++++++++++++ src/server/toolRegistrationOptions.ts | 191 +++++++++++++++++- src/server/toolResult.ts | 54 +++-- 47 files changed, 1387 insertions(+), 672 deletions(-) create mode 100644 scripts/generate-mcp-schema-report.mjs create mode 100644 src/contracts/toolEffects.ts create mode 100644 src/domain/inputIssueProjection.ts create mode 100644 src/server/toolInputValidation.ts diff --git a/package.json b/package.json index 1c2352228..761338fe2 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,11 @@ "LICENSE" ], "scripts": { - "prebuild": "node scripts/generate-package-metadata.mjs", + "deps:check": "node scripts/check-dependency-install.mjs", + "prebuild": "npm run deps:check && node scripts/generate-package-metadata.mjs", "build": "tsc -p tsconfig.build.json", "postbuild": "node scripts/record-build-cache.mjs", + "precheck": "npm run deps:check", "check": "npm run typecheck && npm run lint && npm run format:check && npm run docs:check && npm test", "format": "prettier --write .", "format:check": "prettier --check .", @@ -85,9 +87,12 @@ "verify:package": "npm run build && node scripts/verify-package.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", - "docs:generate": "npm run build && node scripts/generate-product-catalog.mjs && typedoc && node scripts/generate-error-schema.mjs", - "docs:check": "node scripts/generate-package-metadata.mjs --check && npm run build && node scripts/generate-product-catalog.mjs --check && node scripts/check-doc-facts.mjs && node scripts/check-typedoc.mjs && node scripts/generate-error-schema.mjs --check", + "docs:generate": "npm run build && node scripts/generate-skill-metadata.mjs && node scripts/generate-mcp-schema-report.mjs && node scripts/generate-product-catalog.mjs && typedoc && node scripts/generate-error-schema.mjs", + "docs:check": "node scripts/generate-package-metadata.mjs --check && npm run build && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-mcp-schema-report.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/check-doc-facts.mjs && node scripts/check-typedoc.mjs && node scripts/generate-error-schema.mjs --check", + "schemas:report": "npm run build && node scripts/generate-mcp-schema-report.mjs", "docs:error-schema": "npm run build && node scripts/generate-error-schema.mjs", + "prepack": "npm run deps:check && node scripts/generate-skill-metadata.mjs --check", + "prepublishOnly": "npm run deps:check && node scripts/generate-skill-metadata.mjs --check", "prepare": "npm run build && husky", "rebuild:native": "node scripts/rebuild-native.mjs" }, diff --git a/scripts/generate-mcp-schema-report.mjs b/scripts/generate-mcp-schema-report.mjs new file mode 100644 index 000000000..808b75710 --- /dev/null +++ b/scripts/generate-mcp-schema-report.mjs @@ -0,0 +1,69 @@ +import { Buffer } from "node:buffer"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { ensureGeneratedFile } from "./lib/generated-file.mjs"; + +const arguments_ = new Set(process.argv.slice(2)); +for (const argument of arguments_) + if (argument !== "--check") + throw new Error(`Unknown MCP schema report option: ${argument}`); + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const cachebuster = `?${String(Date.now())}`; +const { TOOL_CONTRACTS } = await import( + `${pathToFileURL(join(root, "dist/contracts/toolContracts.js")).href}${cachebuster}` +); +const { toolRegistrationOptions } = await import( + `${pathToFileURL(join(root, "dist/server/toolRegistrationOptions.js")).href}${cachebuster}` +); + +const bytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8"); +const tools = TOOL_CONTRACTS.map((contract) => { + const registration = toolRegistrationOptions(contract); + const inputSchema = registration.inputSchema["~standard"].jsonSchema.input(); + const outputSchema = + registration.outputSchema["~standard"].jsonSchema.output(); + const wire = { + name: contract.name, + title: registration.title, + description: registration.description, + inputSchema, + outputSchema, + annotations: registration.annotations, + }; + return { + name: contract.name, + bytes: bytes(wire), + input_schema_bytes: bytes(inputSchema), + output_schema_bytes: bytes(outputSchema), + wire, + }; +}).sort((left, right) => left.name.localeCompare(right.name)); + +const maximumToolBytes = 64 * 1024; +const maximumListBytes = 512 * 1024; +const listBytes = bytes({ tools: tools.map(({ wire }) => wire) }); +const oversized = tools.filter((tool) => tool.bytes > maximumToolBytes); +if (oversized.length > 0 || listBytes > maximumListBytes) + throw new Error( + `MCP schema budget exceeded: tools/list=${String(listBytes)}, oversized=${oversized.map(({ name, bytes: size }) => `${name}:${String(size)}`).join(",")}`, + ); + +const report = { + generated_from: "@modelcontextprotocol/server 2.0.0-beta.4 wire registration", + tool_count: tools.length, + tools_list_bytes: listBytes, + budgets: { + tools_list_bytes: maximumListBytes, + per_tool_bytes: maximumToolBytes, + }, + tools: tools.map(({ wire: _wire, ...tool }) => tool), +}; + +await ensureGeneratedFile({ + path: join(root, "docs/mcp-schema-report.json"), + source: `${JSON.stringify(report, null, 2)}\n`, + check: arguments_.has("--check"), + generateCommand: "npm run docs:generate", +}); diff --git a/scripts/rea.mjs b/scripts/rea.mjs index b01a7abce..167c13402 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // Route production MCP before importing Incur. Incur owns registration helpers -// such as `mcp add`, while only dist/main.js may serve the 80-tool stdio server. +// such as `mcp add`, while only dist/main.js may serve the stdio tool catalog. const args = process.argv.slice(2); const { default: packageJson } = await import("../package.json", { with: { type: "json" }, diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index b2feb15fe..8c008505a 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -692,6 +692,18 @@ try { (await readFile(siblingSkillPath, "utf8")) !== "unrelated skill\n" ) throw new Error("packaged stale-skill upgrade was not isolated"); + const alignedDoctor = json( + await run(cli, ["doctor", "--json"], environment), + ); + if ( + alignedDoctor.identity?.skill?.state !== "aligned" || + alignedDoctor.identity?.skill?.installed_tool_count !== + TOOL_CONTRACTS.length || + alignedDoctor.checks?.some(({ name }) => name === "skill:identity") + ) + throw new Error( + "packaged doctor did not report the upgraded skill aligned", + ); await writeFile(claudeConfig, "malformed"); await writeFile(cursorConfig, '{"existing":true}\n'); @@ -860,7 +872,7 @@ try { ); if (opened.isError === true) throw new Error("packaged MCP could not open a binary"); - const providerStatus = json( + const providerStatusEnvelope = json( prompts.mcpText( await client.callTool( { name: "binary_session", arguments: {} }, @@ -868,6 +880,7 @@ try { ), ), ); + const providerStatus = providerStatusEnvelope.result; if ( providerStatus.analysis_provider_binding?.provider?.id !== "hopper" || providerStatus.analysis_provider_binding?.selection_source !== "request" @@ -891,13 +904,13 @@ try { ) throw new Error("packaged Linux MCP did not reject an unpinned Hopper"); } else { - if (json(prompts.mcpText(current)).normalized_result !== "fixture") + if (json(prompts.mcpText(current)).result !== "fixture") throw new Error("packaged MCP bridge call failed"); const batch = await client.callTool( { name: "batch_decompile", arguments: { addresses: ["0x1000"] } }, mcpOptions, ); - const batchResult = json(prompts.mcpText(batch)).normalized_result; + const batchResult = json(prompts.mcpText(batch)).result; if ( batch.isError === true || batchResult?.total !== 1 || diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index c3b2b3d6f..ffdc56ced 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -1,6 +1,8 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js"; + const version = process.argv[2]; if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version ?? "")) throw new Error("Usage: node scripts/verify-published-package.mjs "); @@ -22,7 +24,7 @@ transport.stderr?.on("data", (chunk) => { try { await client.connect(transport); const tools = await client.listTools(); - if (tools.tools.length !== 80) + if (tools.tools.length !== CATALOG_IDENTITY.counts.mcp_tools) throw new Error( `Published MCP exposed ${String(tools.tools.length)} tools`, ); @@ -40,5 +42,5 @@ try { } process.stdout.write( - `${JSON.stringify({ package: "rea-agents", version, mcpTools: 80 })}\n`, + `${JSON.stringify({ package: "rea-agents", version, mcpTools: CATALOG_IDENTITY.counts.mcp_tools })}\n`, ); diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index a52688ef8..de009ba2f 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -362,7 +362,9 @@ try { const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort(); const actualNames = listed.tools.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { - throw new Error("The real server did not expose the intended 80 tools"); + throw new Error( + "The real server tool inventory did not match its contracts", + ); } const options = { timeout }; diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 911d427c2..1ec2b05e3 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -79,6 +79,7 @@ export const buildCapabilityInventory = ( available: availability.reason === "available", reason: availability.reason, remediation: availability.remediation, + effects: { ...contract.effects }, annotations: { read_only: contract.annotations.readOnlyHint ?? false, destructive: contract.annotations.destructiveHint ?? false, diff --git a/src/application/JavaScriptApplicationWorkflowService.ts b/src/application/JavaScriptApplicationWorkflowService.ts index 1c94a3447..f1e6d36cd 100644 --- a/src/application/JavaScriptApplicationWorkflowService.ts +++ b/src/application/JavaScriptApplicationWorkflowService.ts @@ -6,6 +6,7 @@ import { type AnalysisError, } from "../domain/errors.js"; import type { Evidence } from "../domain/evidence.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { compareJavaScriptApplicationVersions } from "../domain/javascriptApplicationVersionComparison.js"; import { compareApplicationVersionsInputSchema } from "../domain/javascriptApplicationVersionComparisonSchemas.js"; import { traceApplicationFeature } from "../domain/javascriptFeatureTrace.js"; @@ -26,7 +27,14 @@ export const traceApplicationFeatureEvidence = ( ): Result => { const operation = "trace_application_feature"; const parsed = traceApplicationFeatureInputSchema.safeParse(rawInput); - if (!parsed.success) return err(new AnalysisInputError(operation)); + if (!parsed.success) + return err( + new AnalysisInputError( + operation, + undefined, + projectInputIssues(parsed.error.issues, rawInput), + ), + ); try { const source = parseApplicationGraphEvidence(parsed.data.application); const nativeEvidence = parseNativeApplicationEvidence( @@ -63,7 +71,14 @@ export const compareApplicationVersionsEvidence = ( ): Result => { const operation = "compare_application_versions"; const parsed = compareApplicationVersionsInputSchema.safeParse(rawInput); - if (!parsed.success) return err(new AnalysisInputError(operation)); + if (!parsed.success) + return err( + new AnalysisInputError( + operation, + undefined, + projectInputIssues(parsed.error.issues, rawInput), + ), + ); try { const left = parseApplicationGraphEvidence(parsed.data.left); const right = parseApplicationGraphEvidence(parsed.data.right); diff --git a/src/application/JavaScriptReplayService.ts b/src/application/JavaScriptReplayService.ts index d89992032..1bbc342e5 100644 --- a/src/application/JavaScriptReplayService.ts +++ b/src/application/JavaScriptReplayService.ts @@ -17,6 +17,7 @@ import { type AnalysisError, } from "../domain/errors.js"; import { createEvidence } from "../domain/evidence.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ExecutionOptions } from "./AnalysisProvider.js"; @@ -48,7 +49,13 @@ export const runControlledReplay = async ( ): Promise> => { const parsed = controlledReplayInputSchema.safeParse(rawInput); if (!parsed.success) - return err(new AnalysisInputError(OPERATION, { cause: parsed.error })); + return err( + new AnalysisInputError( + OPERATION, + { cause: parsed.error }, + projectInputIssues(parsed.error.issues, rawInput), + ), + ); if (!dependencies.policy.enabled) return err( new AnalysisCapabilityUnavailableError( diff --git a/src/application/JavaScriptRuntimeReconciliationService.ts b/src/application/JavaScriptRuntimeReconciliationService.ts index 8676d4aaf..46bcb08f9 100644 --- a/src/application/JavaScriptRuntimeReconciliationService.ts +++ b/src/application/JavaScriptRuntimeReconciliationService.ts @@ -6,6 +6,7 @@ import { type AnalysisError, } from "../domain/errors.js"; import type { Evidence } from "../domain/evidence.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { reconcileJavaScriptRuntime } from "../domain/javascriptRuntimeReconciliation.js"; import { reconcileJavaScriptRuntimeInputSchema } from "../domain/javascriptRuntimeReconciliationSchemas.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -18,7 +19,14 @@ export const reconcileJavaScriptRuntimeEvidence = ( rawInput: unknown, ): Result => { const parsed = reconcileJavaScriptRuntimeInputSchema.safeParse(rawInput); - if (!parsed.success) return err(new AnalysisInputError(OPERATION)); + if (!parsed.success) + return err( + new AnalysisInputError( + OPERATION, + undefined, + projectInputIssues(parsed.error.issues, rawInput), + ), + ); try { const result = reconcileJavaScriptRuntime(parsed.data); return ok( diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index a0b4914b6..229764837 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -11,8 +11,10 @@ export { CLI_COMMAND_NAMES } from "./cliCommandNames.js"; const toolCatalog = TOOL_CONTRACTS.map((contract) => ({ name: contract.name, + title: contract.title, surface: contract.kind, description: contract.description, + effects: { ...contract.effects }, annotations: contract.annotations, input_schema: z.toJSONSchema(contract.inputSchema, { unrepresentable: "any", @@ -87,9 +89,10 @@ export const CATALOG_IDENTITY = { resource_templates: resourceTemplateCatalog, }), }, - tools: toolCatalog.map(({ name, surface, annotations }) => ({ + tools: toolCatalog.map(({ name, surface, effects, annotations }) => ({ name, surface, + effects, annotations: { read_only: annotations.readOnlyHint ?? false, destructive: annotations.destructiveHint ?? false, diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index deca5b7bc..7c9ee3663 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -44,7 +44,11 @@ const parseJson = (value: string): unknown => { const inputError = (operation: string): JsonValue => ({ error: "Application workflow failed", - ...projectAnalysisError(new AnalysisInputError(operation)), + ...projectAnalysisError( + new AnalysisInputError(operation, undefined, [ + { path: [], reason: "invalid_format", expected: "JSON" }, + ]), + ), }); const jsonFileError = ( @@ -55,7 +59,15 @@ const jsonFileError = ( ok: false as const, error: { error: "Application workflow failed", - ...projectAnalysisError(new AnalysisInputError(operation)), + ...projectAnalysisError( + new AnalysisInputError( + operation, + undefined, + reason === "invalid-json" + ? [{ path: [], reason: "invalid_format", expected: "JSON" }] + : [], + ), + ), input_path: path, input_reason: reason, maximum_input_bytes: MAX_JSON_INPUT_BYTES, diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 612b11513..24f236c5a 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -6,39 +6,33 @@ import { applicationFeatureTraceResultSchema, traceApplicationFeatureInputSchema, } from "../domain/javascriptFeatureTraceSchemas.js"; -import { evidenceEnvelopeSchema } from "../domain/evidence.js"; import { controlledReplayInputSchema, controlledReplayOutputSchema, } from "../domain/javascriptReplay.js"; import type { ToolContract } from "./toolContracts.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; import { JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, JAVASCRIPT_FEATURE_TRACE_EXAMPLE, } from "./javascriptApplicationWorkflowExamples.js"; -const traceOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: applicationFeatureTraceResultSchema }); -const comparisonOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: applicationVersionComparisonResultSchema }); +const traceOutputSchema = evidenceResultOf(applicationFeatureTraceResultSchema); +const comparisonOutputSchema = evidenceResultOf( + applicationVersionComparisonResultSchema, +); /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ { name: "trace_application_feature", + ...toolContractMetadata("trace_application_feature"), description: "Trace a typed literal seed through an authenticated JavaScript Application Graph with explicit direction, depth, node, edge, and path bounds. Original static, native, passive-runtime, inferred, and unknown authorities remain distinct; native addon handoffs never open a provider or execute the application.", kind: "application", inputSchema: traceApplicationFeatureInputSchema, outputSchema: traceOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Trace one module seed through a retained application graph", @@ -48,17 +42,12 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, { name: "compare_application_versions", + ...toolContractMetadata("compare_application_versions"), description: "Compare two authenticated JavaScript Application Graph versions using unique-only exact digest, module source digest, source-map identity, structural fingerprint, and non-module semantic-key tiers. Reports added, removed, changed, ambiguous, and unknown entities plus a bounded changed_from graph without fuzzy or module-ordinal pairing.", kind: "application", inputSchema: compareApplicationVersionsInputSchema, outputSchema: comparisonOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Compare authenticated static and reconciled application graphs", @@ -68,17 +57,12 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, { name: "run_controlled_replay", + ...toolContractMetadata("run_controlled_replay"), description: "Plan or execute a content-bound extracted-module JavaScript replay inside the Linux Bubblewrap, seccomp, and cgroup boundary. Execution requires approved: true and the exact plan digest. Supports deterministic boundary cases and optional left/right differential comparison; observations have controlled-replay authority and do not claim real application runtime behavior.", kind: "application", inputSchema: controlledReplayInputSchema, outputSchema: controlledReplayOutputSchema, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Plan one extracted ESM parser replay", diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index ecaf77771..7ada8bcdb 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContracts.js"; import { artifactOutputSchemas } from "./toolOutputSchemas.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { toolContractMetadata } from "./toolEffects.js"; const pageInput = { node_offset: z.number().int().min(0).default(0), @@ -81,23 +82,17 @@ const artifact = ( name: Name, description: string, inputSchema: z.ZodObject, - mutatesFilesystem: boolean, ): ToolContract => { const outputSchema = artifactOutputSchemas[name]; if (outputSchema === undefined) throw new Error(`Missing artifact output schema for ${name}`); return { name, + ...toolContractMetadata(name), description, kind: "artifact-provider", inputSchema, outputSchema, - annotations: { - readOnlyHint: !mutatesFilesystem, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: `Example ${name.replaceAll("_", " ")} request`, @@ -113,13 +108,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "inventory_artifact", "Inventory the active application or package as a deterministic, content-addressed artifact graph. Returns bounded node and edge pages without extracting or mounting by default.", artifactInventoryInputSchema, - false, ), artifact( "extract_artifact", "Extract selected graph artifacts beneath an explicit output root. Requires approval, rejects traversal and symlink escapes, never overwrites, enforces bomb limits, and verifies cleanup.", artifactExtractionInputSchema, - true, ), ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 8bc479fbf..7bf708b20 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -1,6 +1,4 @@ import type { ToolContract } from "./toolContracts.js"; -import { z } from "zod"; -import { evidenceEnvelopeSchema } from "../domain/evidence.js"; import { browserTargetListSchema, inspectWebPageInputSchema, @@ -29,21 +27,16 @@ import { webScreenshotDiffSchema, webScreenshotSchema, } from "../domain/webScreenshot.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; -const evidenceResult = (schema: Schema) => - evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: schema }); +const evidenceResult = evidenceResultOf; const listOutputSchema = evidenceResult(browserTargetListSchema); -const inspectionOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: webPageInspectionSchema }); -const bundleOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: webBundleAnalysisSchema }); -const observationSessionOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: webObservationSessionSchema }); +const inspectionOutputSchema = evidenceResult(webPageInspectionSchema); +const bundleOutputSchema = evidenceResult(webBundleAnalysisSchema); +const observationSessionOutputSchema = evidenceResult( + webObservationSessionSchema, +); const webMcpOutputSchema = evidenceResult(webMcpDiscoverySchema); const captureDiffOutputSchema = evidenceResult(webCaptureDiffSchema); const screenshotOutputSchema = evidenceResult(webScreenshotSchema); @@ -56,17 +49,12 @@ const origin = "https://app.example.test"; export const BROWSER_TOOL_CONTRACTS = [ { name: "list_browser_targets", + ...toolContractMetadata("list_browser_targets"), description: "List bounded page targets from an approved user-owned loopback Chrome DevTools Protocol endpoint. Only targets whose current URL matches an approved exact origin are returned; URL credentials, query values, and fragments are redacted.", kind: "browser-provider", inputSchema: listBrowserTargetsInputSchema, outputSchema: listOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "List approved browser page targets", @@ -82,17 +70,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "inspect_web_page", + ...toolContractMetadata("inspect_web_page"), description: "Passively inspect one approved page target through CDP without evaluating JavaScript, navigating, clicking, closing, or mutating the page. Returns bounded DOM structure, accessibility, scripts, resources, attach-window network and console metadata, workers, and redacted storage inventory as Evidence v2.", kind: "browser-provider", inputSchema: inspectWebPageInputSchema, outputSchema: inspectionOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "Inspect one approved browser page", @@ -141,17 +124,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "analyze_web_bundle", + ...toolContractMetadata("analyze_web_bundle"), description: "Capture explicitly approved JavaScript source from one approved CDP page and statically derive a bounded chunk graph, route and endpoint candidates, vendor fingerprints, page-declared WebMCP metadata, and optional separately approved source-map evidence. JavaScript is parsed but never executed.", kind: "browser-provider", inputSchema: analyzeWebBundleInputSchema, outputSchema: bundleOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "Analyze an approved page bundle", @@ -182,17 +160,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "observe_web_session", + ...toolContractMetadata("observe_web_session"), description: "Arm a bounded CDP observation window while the user operates the page. Allows approved same-origin reload and SPA navigation, records ordered navigation, redirect, lifecycle, and failure metadata, and stops before retaining an out-of-policy destination.", kind: "browser-provider", inputSchema: observeWebSessionInputSchema, outputSchema: observationSessionOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, examples: [ { title: "Observe one external user action", @@ -209,17 +182,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "discover_webmcp_tools", + ...toolContractMetadata("discover_webmcp_tools"), description: "Passively inventory page-registered WebMCP tools using the experimental CDP WebMCP domain. Metadata is bounded and page-declared-untrusted; REA never registers or invokes discovered tools.", kind: "browser-provider", inputSchema: discoverWebMcpToolsInputSchema, outputSchema: webMcpOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "Discover current WebMCP declarations", @@ -239,17 +207,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "compare_web_captures", + ...toolContractMetadata("compare_web_captures"), description: "Compare two normalized web captures across DOM, scripts, resources, network, safe metadata, and optional WebMCP inventories. Stable observed changes are distinguished from unknown absence caused by incomplete capture coverage.", kind: "browser-provider", inputSchema: compareWebCapturesInputSchema, outputSchema: captureDiffOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Compare two normalized captures", @@ -263,17 +226,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "capture_web_screenshot", + ...toolContractMetadata("capture_web_screenshot"), description: "Capture the current visible viewport of one approved page as a bounded, content-addressed PNG artifact. Screenshot capture requires separate explicit approval and never scrolls, navigates, or evaluates page JavaScript.", kind: "browser-provider", inputSchema: captureWebScreenshotInputSchema, outputSchema: screenshotOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, examples: [ { title: "Capture an approved viewport", @@ -290,17 +248,12 @@ export const BROWSER_TOOL_CONTRACTS = [ }, { name: "compare_web_screenshots", + ...toolContractMetadata("compare_web_screenshots"), description: "Compare two self-verifying PNG screenshot artifacts with bounded local pixel metrics. Returns exact changed-pixel ratios and channel deltas without OCR or external services.", kind: "browser-provider", inputSchema: compareWebScreenshotsInputSchema, outputSchema: screenshotDiffOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Compare two screenshot artifacts", diff --git a/src/contracts/electronToolContracts.ts b/src/contracts/electronToolContracts.ts index c46de2773..1299e5a0d 100644 --- a/src/contracts/electronToolContracts.ts +++ b/src/contracts/electronToolContracts.ts @@ -1,5 +1,6 @@ import type { ToolContract } from "./toolContracts.js"; -import { evidenceEnvelopeSchema } from "../domain/evidence.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; import { electronPageInspectionSchema, electronTargetListSchema, @@ -16,18 +17,14 @@ import { } from "../domain/javascriptRuntimeReconciliationSchemas.js"; import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "./javascriptRuntimeReconciliationExample.js"; -const listOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: electronTargetListSchema }); -const inspectionOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: electronPageInspectionSchema }); -const applicationOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: javascriptApplicationAnalysisResultSchema }); -const reconciliationOutputSchema = evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: javascriptRuntimeReconciliationResultSchema }); +const listOutputSchema = evidenceResultOf(electronTargetListSchema); +const inspectionOutputSchema = evidenceResultOf(electronPageInspectionSchema); +const applicationOutputSchema = evidenceResultOf( + javascriptApplicationAnalysisResultSchema, +); +const reconciliationOutputSchema = evidenceResultOf( + javascriptRuntimeReconciliationResultSchema, +); const endpoint = "http://127.0.0.1:9223"; const root = "/Applications/Example.app/Contents/Resources"; @@ -36,17 +33,12 @@ const root = "/Applications/Example.app/Contents/Resources"; export const ELECTRON_TOOL_CONTRACTS = [ { name: "list_electron_targets", + ...toolContractMetadata("list_electron_targets"), description: "List Electron file:// page targets from an approved user-owned loopback CDP endpoint. Every path is canonicalized and must remain contained by an approved filesystem root, including after symlink resolution.", kind: "electron-provider", inputSchema: listElectronTargetsInputSchema, outputSchema: listOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "List approved Electron file pages", @@ -62,17 +54,12 @@ export const ELECTRON_TOOL_CONTRACTS = [ }, { name: "inspect_electron_page", + ...toolContractMetadata("inspect_electron_page"), description: "Passively inspect one approved Electron file page through CDP. Returns root-confined frames, DOM structure, resource paths, and scripts without evaluating renderer JavaScript or invoking Electron APIs; script contents require separate approval.", kind: "electron-provider", inputSchema: inspectElectronPageInputSchema, outputSchema: inspectionOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, examples: [ { title: "Inspect an approved Electron page", @@ -99,17 +86,12 @@ export const ELECTRON_TOOL_CONTRACTS = [ }, { name: "analyze_javascript_application", + ...toolContractMetadata("analyze_javascript_application"), description: "Statically reconstruct one approved local ASAR or extracted JavaScript application as Evidence v2 and JavaScript Application Graph v1. Maps BrowserWindow preferences, preload and contextBridge APIs, literal and dynamic IPC, handler locations, sender-validation observations, utility processes, and requested native addon bindings without executing JavaScript; source-map contents require separate approval.", kind: "electron-provider", inputSchema: analyzeJavaScriptApplicationInputSchema, outputSchema: applicationOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Analyze one approved local Electron application", @@ -124,17 +106,12 @@ export const ELECTRON_TOOL_CONTRACTS = [ }, { name: "reconcile_javascript_runtime", + ...toolContractMetadata("reconcile_javascript_runtime"), description: "Reconcile verified static JavaScript application graphs with existing passive web or Electron CDP Evidence. Exact captured-source digests take priority over caller-declared file/URL mappings; target, frame, script, and worker ambiguity remains explicit, and source-map authority stays separate.", kind: "electron-provider", inputSchema: reconcileJavaScriptRuntimeInputSchema, outputSchema: reconciliationOutputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Reconcile one passive Electron capture", diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts index 6b74affc9..1f3109e73 100644 --- a/src/contracts/investigationExamples.ts +++ b/src/contracts/investigationExamples.ts @@ -1,7 +1,6 @@ import { compareFunctions } from "../domain/functionComparison.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { createEvidenceBundle } from "../domain/evidenceBundle.js"; import { compareProcessCaptures, processCaptureSchema, @@ -130,10 +129,5 @@ export const INVESTIGATION_EXAMPLES = { }, ], }, - evidence_bundle: createEvidenceBundle([ - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]), }, }; diff --git a/src/contracts/managedToolContracts.ts b/src/contracts/managedToolContracts.ts index 49254ceb5..8b390dd09 100644 --- a/src/contracts/managedToolContracts.ts +++ b/src/contracts/managedToolContracts.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContracts.js"; import { managedOutputSchemas } from "./toolOutputSchemas.js"; +import { toolContractMetadata } from "./toolEffects.js"; /** Exact caller boundary for execution-free PE/CLI triage and identity. */ export const managedArtifactInputSchema = z.object({ @@ -119,17 +120,12 @@ if (nativeBoundaryOutputSchema === undefined) export const MANAGED_TOOL_CONTRACTS = [ { name: "inspect_managed_artifact", + ...toolContractMetadata("inspect_managed_artifact"), description: "Classify the active PE/CLI artifact and inventory exact assembly/module identity, target framework evidence, references, resources, and custom attributes without loading or executing target code. Returns bounded pages and explicit partial or malformed coverage.", kind: "managed-provider", inputSchema: managedArtifactInputSchema, outputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: "Example inspect managed artifact request", diff --git a/src/contracts/nativeToolContracts.ts b/src/contracts/nativeToolContracts.ts index 4ed43be98..589c43e95 100644 --- a/src/contracts/nativeToolContracts.ts +++ b/src/contracts/nativeToolContracts.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContracts.js"; import { nativeOutputSchemas } from "./toolOutputSchemas.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { toolContractMetadata } from "./toolEffects.js"; const examples: Readonly>>> = { inspect_macho: {}, @@ -22,16 +23,11 @@ const native = ( throw new Error(`Missing native output schema for ${name}`); return { name, + ...toolContractMetadata(name), description, kind: "native-provider", inputSchema, outputSchema, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, examples: [ { title: `Example ${name.replaceAll("_", " ")} request`, diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index d6d452960..de4fe5838 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -1,5 +1,4 @@ import type { JsonValue } from "../domain/jsonValue.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js"; import { UNKNOWN_CONTRACT_EXAMPLES } from "./unknownContractExamples.js"; import { ARTIFACT_COMPARISON_EXAMPLE } from "./artifactComparisonExample.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "./functionComparisonExample.js"; @@ -42,32 +41,18 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< }, compare_process_captures: { left_evidence_id: `ev_${"0".repeat(64)}`, - left: EMPTY_PROCESS_CAPTURE_EXAMPLE, right_evidence_id: `ev_${"1".repeat(64)}`, - right: EMPTY_PROCESS_CAPTURE_EXAMPLE, }, - compare_artifacts: ARTIFACT_COMPARISON_EXAMPLE, - compare_functions: FUNCTION_COMPARISON_EXAMPLE, + compare_artifacts: { + left_evidence_ids: [ARTIFACT_COMPARISON_EXAMPLE.left.evidence_id], + right_evidence_ids: [ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id], + }, + compare_functions: { + left_evidence_ids: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id], + right_evidence_ids: [FUNCTION_COMPARISON_EXAMPLE.right.evidence_id], + }, compare_bundles: { - left: { - bundle_version: 2, - artifacts: [], - providers: [], - environments: [], - scenarios: [], - captures: [], - unknowns: [], - records: [], - }, - right: { - bundle_version: 2, - artifacts: [], - providers: [], - environments: [], - scenarios: [], - captures: [], - unknowns: [], - records: [], - }, + left_bundle_path: "/approved/left-evidence.json", + right_bundle_path: "/approved/right-evidence.json", }, }; diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index 32b119787..780b2d561 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -1,9 +1,10 @@ import type { z } from "zod"; import type { JsonValue } from "../domain/jsonValue.js"; +import type { ToolEffects } from "./toolEffects.js"; /** Caller-visible MCP execution hints. */ -export interface ToolAnnotations { +interface ToolAnnotations { readonly readOnlyHint: boolean; readonly destructiveHint: boolean; readonly idempotentHint: boolean; @@ -17,7 +18,7 @@ export interface ToolExample { } /** Adapter family responsible for implementing a public MCP tool. */ -export type ToolKind = +type ToolKind = | "official-proxy" | "enhanced" | "native-provider" @@ -31,10 +32,12 @@ export type ToolKind = /** Single source of truth for a public MCP tool. */ export interface ToolContract { readonly name: Name; + readonly title: string; readonly description: string; readonly kind: ToolKind; readonly inputSchema: z.ZodObject; readonly outputSchema: z.ZodObject; + readonly effects: ToolEffects; readonly annotations: ToolAnnotations; readonly examples: readonly ToolExample[]; } diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 1cab66aa8..1c2abd149 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,9 +1,6 @@ import { z } from "zod"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { - processCaptureSchema, - processScenarioSchema, -} from "../domain/processCapture.js"; +import { processScenarioSchema } from "../domain/processCapture.js"; import { recordUnknownInputSchema, updateUnknownInputSchema, @@ -35,18 +32,10 @@ import { closeBinaryInputSchema, openBinaryInputSchema, } from "./sessionLifecycleInputs.js"; -import type { - ToolAnnotations, - ToolContract, - ToolExample, - ToolKind, -} from "./toolContractTypes.js"; +import type { ToolContract, ToolExample } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; -export type { - ToolAnnotations, - ToolContract, - ToolExample, -} from "./toolContractTypes.js"; +export type { ToolContract, ToolExample } from "./toolContractTypes.js"; const document = z.string().optional().describe("The document name"); const address = z @@ -92,27 +81,6 @@ const examplesFor = ( ]; }; -const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({ - readOnlyHint: - (kind === "enhanced" && name !== "trace_feature") || - name === "binary_session" || - name === "list_unknowns" || - name === "verify_unknown_resolution", - destructiveHint: - name === "export_evidence_bundle" || - name === "close_binary" || - name === "unset_bookmark" || - name === "set_address_name" || - name === "set_addresses_names" || - name === "set_comment" || - name === "set_inline_comment", - idempotentHint: - name !== "close_binary" && - name !== "record_unknown" && - name !== "update_unknown", - openWorldHint: name === "capture_process_scenario", -}); - const official = ( name: Name, description: string, @@ -128,11 +96,11 @@ const official = ( }); return { name, + ...toolContractMetadata(name), description, kind: "official-proxy", inputSchema: trackedInputSchema, outputSchema: requireOutputSchema(officialOutputSchemas, name), - annotations: annotations(name, "official-proxy"), examples: examplesFor(name, trackedInputSchema), }; }; @@ -143,11 +111,11 @@ const enhanced = ( inputSchema: z.ZodObject, ): ToolContract => ({ name, + ...toolContractMetadata(name), description, kind: "enhanced", inputSchema, outputSchema: requireOutputSchema(enhancedOutputSchemas, name), - annotations: annotations(name, "enhanced"), examples: examplesFor(name, inputSchema), }); @@ -157,11 +125,11 @@ const session = ( inputSchema: z.ZodObject, ): ToolContract => ({ name, + ...toolContractMetadata(name), description, kind: "session", inputSchema, outputSchema: requireOutputSchema(sessionOutputSchemas, name), - annotations: annotations(name, "session"), examples: examplesFor(name, inputSchema), }); @@ -408,6 +376,42 @@ export const binarySessionInputSchema = z.object({ expected_server_path: z.string().min(1).optional(), }); +/** Session-owned Evidence bundle export options. */ +export const exportEvidenceBundleInputSchema = z.strictObject({ + path: z.string().min(1).optional(), + overwrite: z.boolean().default(false), +}); + +/** Session-owned Evidence bundle import options. */ +export const importEvidenceBundleInputSchema = z.strictObject({ + path: z.string().min(1), +}); + +/** Evidence references for deterministic process comparison. */ +export const processComparisonInputSchema = z.strictObject({ + left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), + right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), + max_capture_age_ms: z.number().int().nonnegative().optional(), + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record capture disagreement durably"), +}); + +/** Residual-unknown list filters. */ +export const listUnknownsInputSchema = z.strictObject({ + status: z + .enum(["open", "investigating", "blocked", "contradicted", "resolved"]) + .optional(), + severity: z.enum(["low", "medium", "high", "critical"]).optional(), + domain: z.string().trim().min(1).max(100).optional(), +}); + +/** Exact residual-unknown identity to revalidate. */ +export const verifyUnknownResolutionInputSchema = z.strictObject({ + unknown_id: z.string().regex(/^unk_[a-f0-9]{64}$/u), +}); + /** Target lifecycle tools available only on the long-lived MCP adapter. */ export const SESSION_TOOL_CONTRACTS = [ session( @@ -428,15 +432,12 @@ export const SESSION_TOOL_CONTRACTS = [ session( "export_evidence_bundle", "Return the session's deterministic Evidence v2 bundle, or atomically write it beneath an operator-approved root. Existing files require overwrite: true; records and manifests use canonical byte-stable ordering.", - z.object({ - path: z.string().min(1).optional(), - overwrite: z.boolean().default(false), - }), + exportEvidenceBundleInputSchema, ), session( "import_evidence_bundle", "Read a bounded local JSON bundle beneath an operator-approved root, validate every Evidence v2 ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.", - z.object({ path: z.string().min(1) }), + importEvidenceBundleInputSchema, ), session( "capture_process_scenario", @@ -446,17 +447,7 @@ export const SESSION_TOOL_CONTRACTS = [ session( "compare_process_captures", "Compare two compatible Process Capture v4 observations across terminal, interaction, exit, settlement, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Process Capture v3 is unsupported and must be recaptured with capture_process_scenario. Missing or truncated observations are never treated as equivalent.", - z.object({ - left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), - left: processCaptureSchema, - right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), - right: processCaptureSchema, - max_capture_age_ms: z.number().int().nonnegative().optional(), - unknown_registry_approved: z - .literal(true) - .optional() - .describe("Explicit approval to record capture disagreement durably"), - }), + processComparisonInputSchema, ), session( "compare_artifacts", @@ -496,13 +487,7 @@ export const SESSION_TOOL_CONTRACTS = [ session( "list_unknowns", "List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.", - z.object({ - status: z - .enum(["open", "investigating", "blocked", "contradicted", "resolved"]) - .optional(), - severity: z.enum(["low", "medium", "high", "critical"]).optional(), - domain: z.string().trim().min(1).max(100).optional(), - }), + listUnknownsInputSchema, ), session( "record_unknown", @@ -517,7 +502,7 @@ export const SESSION_TOOL_CONTRACTS = [ session( "verify_unknown_resolution", "Revalidate the current residual-unknown head against live bundled evidence, exact authority/confidence/environment requirements, and revision integrity. Withdrawn and out-of-scope dispositions are not truth claims.", - z.object({ unknown_id: z.string().regex(/^unk_[a-f0-9]{64}$/u) }), + verifyUnknownResolutionInputSchema, ), ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts new file mode 100644 index 000000000..ba40b7473 --- /dev/null +++ b/src/contracts/toolEffects.ts @@ -0,0 +1,181 @@ +/** Canonical side effects for one caller-visible tool. */ +export interface ToolEffects { + readonly mutatesTarget: boolean; + readonly mutatesSession: boolean; + readonly writesFilesystem: boolean; + readonly launchesProcess: boolean; + readonly accessesNetwork: boolean; + readonly changesUiState: boolean; + readonly mayDiscardData: boolean; + readonly idempotent: boolean; +} + +/** MCP execution hints derived only from canonical effects. */ +export interface DerivedToolAnnotations { + readonly readOnlyHint: boolean; + readonly destructiveHint: boolean; + readonly idempotentHint: boolean; + readonly openWorldHint: boolean; +} + +const effects = (overrides: Partial = {}): ToolEffects => ({ + mutatesTarget: false, + mutatesSession: false, + writesFilesystem: false, + launchesProcess: false, + accessesNetwork: false, + changesUiState: false, + mayDiscardData: false, + idempotent: true, + ...overrides, +}); + +const evidence = effects({ mutatesSession: true }); +const nativeEvidence = effects({ mutatesSession: true, launchesProcess: true }); +const browserEvidence = effects({ + mutatesSession: true, + accessesNetwork: true, +}); +const sessionEvidence = effects({ mutatesSession: true }); + +/** Explicit effect audit for every public tool. */ +export const TOOL_EFFECTS: Readonly> = { + address_name: evidence, + comment: evidence, + current_address: evidence, + current_procedure: evidence, + current_document: evidence, + goto_address: effects({ mutatesSession: true, changesUiState: true }), + inline_comment: evidence, + list_bookmarks: evidence, + list_documents: evidence, + list_names: evidence, + list_procedures: evidence, + list_segments: evidence, + list_strings: evidence, + next_address: evidence, + prev_address: evidence, + procedure_address: evidence, + procedure_assembly: evidence, + procedure_callees: evidence, + procedure_callers: evidence, + procedure_info: evidence, + procedure_references: evidence, + procedure_pseudo_code: evidence, + resolve_containing_procedure: evidence, + search_procedures: evidence, + search_strings: evidence, + set_address_name: effects({ mutatesTarget: true, mutatesSession: true }), + set_addresses_names: effects({ mutatesTarget: true, mutatesSession: true }), + set_bookmark: effects({ mutatesTarget: true, mutatesSession: true }), + set_comment: effects({ mutatesTarget: true, mutatesSession: true }), + set_current_document: effects({ mutatesSession: true, changesUiState: true }), + set_inline_comment: effects({ mutatesTarget: true, mutatesSession: true }), + unset_bookmark: effects({ + mutatesTarget: true, + mutatesSession: true, + mayDiscardData: true, + }), + xrefs: evidence, + swift_classes: evidence, + get_objc_classes: evidence, + get_objc_protocols: evidence, + batch_decompile: evidence, + get_call_graph: evidence, + analyze_swift_types: evidence, + find_xrefs_to_name: evidence, + binary_overview: evidence, + analyze_function: evidence, + trace_feature: effects({ mutatesSession: true, idempotent: false }), + inspect_macho: nativeEvidence, + inspect_signature: nativeEvidence, + inspect_plist: nativeEvidence, + list_architectures: nativeEvidence, + demangle_swift: nativeEvidence, + inventory_artifact: evidence, + extract_artifact: effects({ mutatesSession: true, writesFilesystem: true }), + inspect_managed_artifact: evidence, + list_browser_targets: browserEvidence, + inspect_web_page: browserEvidence, + analyze_web_bundle: browserEvidence, + observe_web_session: browserEvidence, + discover_webmcp_tools: browserEvidence, + compare_web_captures: browserEvidence, + capture_web_screenshot: browserEvidence, + compare_web_screenshots: browserEvidence, + list_electron_targets: browserEvidence, + inspect_electron_page: browserEvidence, + analyze_javascript_application: evidence, + reconcile_javascript_runtime: evidence, + trace_application_feature: evidence, + compare_application_versions: evidence, + run_controlled_replay: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), + open_binary: effects({ mutatesSession: true, launchesProcess: true }), + close_binary: effects({ + mutatesSession: true, + writesFilesystem: true, + mayDiscardData: true, + idempotent: false, + }), + binary_session: effects(), + export_evidence_bundle: effects({ + writesFilesystem: true, + mayDiscardData: true, + }), + import_evidence_bundle: effects({ mutatesSession: true }), + capture_process_scenario: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + idempotent: false, + }), + compare_process_captures: sessionEvidence, + compare_artifacts: sessionEvidence, + compare_functions: sessionEvidence, + compare_bundles: effects({ mutatesSession: true }), + find_changed_behavior: sessionEvidence, + build_call_path: sessionEvidence, + correlate_static_and_runtime: sessionEvidence, + verify_reconstruction: sessionEvidence, + list_unknowns: effects(), + record_unknown: effects({ mutatesSession: true, idempotent: false }), + update_unknown: effects({ mutatesSession: true, idempotent: false }), + verify_unknown_resolution: effects(), +}; + +/** Derive MCP annotations without tool-name or tool-family heuristics. */ +export const annotationsFromEffects = ( + value: ToolEffects, +): DerivedToolAnnotations => ({ + readOnlyHint: !( + value.mutatesTarget || + value.mutatesSession || + value.writesFilesystem || + value.launchesProcess || + value.accessesNetwork || + value.changesUiState || + value.mayDiscardData + ), + destructiveHint: value.mayDiscardData, + idempotentHint: value.idempotent, + openWorldHint: value.launchesProcess || value.accessesNetwork, +}); + +/** Resolve the audited metadata for one public tool. */ +export const toolContractMetadata = (name: string) => { + const audited = TOOL_EFFECTS[name]; + if (audited === undefined) + throw new Error(`Missing effect audit for ${name}`); + return { + title: name + .split("_") + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "), + effects: audited, + annotations: annotationsFromEffects(audited), + }; +}; diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index a6c821d52..1d2eca224 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { evidenceEnvelopeSchema } from "../domain/evidence.js"; import { processCaptureComparisonSchema, processCaptureSchema, @@ -46,10 +45,14 @@ import { type ProviderRejectionCode, } from "./providerSelection.js"; -const resultOf = (schema: z.ZodType) => - evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: schema }); +/** Compact MCP result with an immutable link to complete session Evidence. */ +export const evidenceResultOf = (schema: z.ZodType) => + z.strictObject({ + result: schema, + evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), + evidence_uri: z.string().regex(/^rea:\/\/evidence\/ev_[a-f0-9]{64}$/u), + }); +const resultOf = evidenceResultOf; const lifecycleResultOf = (schema: z.ZodType) => z.object({ result: schema }); /** Resolve a required named output schema or reject contract drift. */ @@ -194,6 +197,16 @@ const sessionProvider = z.object({ "policy_disabled", ]), remediation: z.string().nullable(), + effects: z.strictObject({ + mutatesTarget: z.boolean(), + mutatesSession: z.boolean(), + writesFilesystem: z.boolean(), + launchesProcess: z.boolean(), + accessesNetwork: z.boolean(), + changesUiState: z.boolean(), + mayDiscardData: z.boolean(), + idempotent: z.boolean(), + }), annotations: z.object({ read_only: z.boolean(), destructive: z.boolean(), @@ -610,51 +623,15 @@ export const sessionOutputSchemas: Readonly> = { total: z.number().int().min(0), }), ), - capture_process_scenario: lifecycleResultOf( - evidenceEnvelopeSchema - .omit({ normalized_result: true }) - .extend({ normalized_result: processCaptureSchema }), - ), - compare_process_captures: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: processCaptureComparisonSchema, - }), - ), - compare_artifacts: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: artifactComparisonResultSchema, - }), - ), - compare_functions: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: functionComparisonResultSchema, - }), - ), - compare_bundles: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: bundleComparisonResultSchema, - }), - ), - find_changed_behavior: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: changedBehaviorResultSchema, - }), - ), - build_call_path: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: callPathResultSchema, - }), - ), - correlate_static_and_runtime: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: staticRuntimeCorrelationResultSchema, - }), - ), - verify_reconstruction: lifecycleResultOf( - evidenceEnvelopeSchema.omit({ normalized_result: true }).extend({ - normalized_result: reconstructionVerificationResultSchema, - }), - ), + capture_process_scenario: resultOf(processCaptureSchema), + compare_process_captures: resultOf(processCaptureComparisonSchema), + compare_artifacts: resultOf(artifactComparisonResultSchema), + compare_functions: resultOf(functionComparisonResultSchema), + compare_bundles: resultOf(bundleComparisonResultSchema), + find_changed_behavior: resultOf(changedBehaviorResultSchema), + build_call_path: resultOf(callPathResultSchema), + correlate_static_and_runtime: resultOf(staticRuntimeCorrelationResultSchema), + verify_reconstruction: resultOf(reconstructionVerificationResultSchema), list_unknowns: lifecycleResultOf(z.array(residualUnknownSchema)), record_unknown: lifecycleResultOf(residualUnknownSchema), update_unknown: lifecycleResultOf(residualUnknownSchema), diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 9cfb2ed8e..922fe55f4 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -6,7 +6,6 @@ import { type ArtifactNode, type ArtifactOccurrence, } from "./artifactGraph.js"; -import { evidenceSchema } from "./evidence.js"; import { parseArtifactInventoryEvidence, type InventorySet, @@ -42,9 +41,9 @@ const comparisonDimensionSchema = z.enum([ ]); /** Strict Evidence-backed input for deterministic artifact comparison. */ -export const artifactComparisonInputSchema = z.object({ - left: z.union([evidenceSchema, z.array(evidenceSchema).min(1).max(100)]), - right: z.union([evidenceSchema, z.array(evidenceSchema).min(1).max(100)]), +export const artifactComparisonInputSchema = z.strictObject({ + left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + right_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(500).default(100), unknown_registry_approved: z diff --git a/src/domain/bundleComparison.ts b/src/domain/bundleComparison.ts index e11fa47b2..b07427599 100644 --- a/src/domain/bundleComparison.ts +++ b/src/domain/bundleComparison.ts @@ -3,7 +3,6 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { - evidenceBundleSchema, parseEvidenceBundle, serializeEvidenceBundle, type EvidenceBundle, @@ -22,9 +21,9 @@ const classificationSchema = z.enum([ ]); /** Strict bounded input for canonical Evidence bundle comparison. */ -export const bundleComparisonInputSchema = z.object({ - left: evidenceBundleSchema, - right: evidenceBundleSchema, +export const bundleComparisonInputSchema = z.strictObject({ + left_bundle_path: z.string().min(1).max(4_096), + right_bundle_path: z.string().min(1).max(4_096), record_pairs: z .array( z.object({ diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 0f6d060a9..e05d0bf27 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -69,11 +69,27 @@ export class AnalysisInputError extends AnalysisError { constructor( readonly operation: string, options?: ErrorOptions, + readonly issues: readonly AnalysisInputIssue[] = [], ) { super(`Invalid analysis input for ${operation}`, options); } } +/** Secret-safe correction metadata for one rejected caller argument. */ +export interface AnalysisInputIssue { + readonly path: readonly (string | number)[]; + readonly reason: + | "unknown_argument" + | "missing_argument" + | "invalid_type" + | "out_of_range" + | "invalid_value" + | "invalid_format"; + readonly expected?: JsonValue; + readonly minimum?: number; + readonly maximum?: number; +} + /** Provider output failed provider-neutral application parsing. */ export class AnalysisOutputError extends AnalysisError { readonly _tag = "AnalysisOutputError"; @@ -307,6 +323,18 @@ export class EvidenceIntegrityError extends AnalysisError { readonly _tag = "EvidenceIntegrityError"; } +/** A session Evidence reference is missing or has the wrong semantic identity. */ +export class EvidenceReferenceError extends EvidenceIntegrityError { + constructor( + readonly evidenceId: string, + readonly reason: "missing" | "wrong_operation" | "wrong_predicate", + readonly expected: string, + readonly actual: string | null, + ) { + super(`Evidence reference ${reason}: ${evidenceId}`); + } +} + /** A bounded evidence ledger cannot accept more records or serialized bytes. */ export class EvidenceLimitError extends AnalysisError { readonly _tag = "EvidenceLimitError"; @@ -681,6 +709,24 @@ const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { const errorDetails = ( error: AnalysisError, ): Readonly> | undefined => { + if (error instanceof AnalysisInputError && error.issues.length > 0) + return { + operation: error.operation, + issues: error.issues.map((issue) => ({ + path: [...issue.path], + reason: issue.reason, + ...(issue.expected === undefined ? {} : { expected: issue.expected }), + ...(issue.minimum === undefined ? {} : { minimum: issue.minimum }), + ...(issue.maximum === undefined ? {} : { maximum: issue.maximum }), + })), + }; + if (error instanceof EvidenceReferenceError) + return { + evidence_id: error.evidenceId, + reason: error.reason, + expected: error.expected, + actual: error.actual, + }; if (error instanceof ReplayPlanStaleError) return { approved_plan_digest: error.approvedDigest, @@ -793,6 +839,7 @@ const missingScopeDetails = ( }); const RETRYABLE_CODES: ReadonlySet = new Set([ + "invalid_request", "provider_timeout", "cancelled", "revision_conflict", @@ -800,6 +847,8 @@ const RETRYABLE_CODES: ReadonlySet = new Set([ ]); const remediationAction = (error: AnalysisError): string => { + if (error instanceof AnalysisInputError) + return "Correct the listed arguments and retry."; if (error instanceof ReplayPlanStaleError) return "Review the rebuilt replay plan and explicitly approve its new digest."; if (error instanceof PermissionRequiredError) diff --git a/src/domain/functionComparisonSchemas.ts b/src/domain/functionComparisonSchemas.ts index 224414a77..2e2a6bc3a 100644 --- a/src/domain/functionComparisonSchemas.ts +++ b/src/domain/functionComparisonSchemas.ts @@ -1,11 +1,5 @@ import { z } from "zod"; -import { evidenceSchema } from "./evidence.js"; - -const evidenceInput = z.union([ - evidenceSchema, - z.array(evidenceSchema).min(1).max(100), -]); const comparisonStatusSchema = z.enum([ "unchanged", "added", @@ -28,9 +22,9 @@ const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); /** Strict bounded input for explicit function-to-function comparison. */ -export const functionComparisonInputSchema = z.object({ - left: evidenceInput, - right: evidenceInput, +export const functionComparisonInputSchema = z.strictObject({ + left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + right_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(100), unknown_registry_approved: z.literal(true).optional(), diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts new file mode 100644 index 000000000..af384ce69 --- /dev/null +++ b/src/domain/inputIssueProjection.ts @@ -0,0 +1,83 @@ +import type { z } from "zod"; + +import type { AnalysisInputIssue } from "./errors.js"; + +/** Project Zod failures to secret-safe caller correction metadata. */ +export const projectInputIssues = ( + issues: readonly z.core.$ZodIssue[], + input: unknown, +): readonly AnalysisInputIssue[] => + issues.flatMap((issue) => projectIssue(issue, input)); + +const projectIssue = ( + issue: z.core.$ZodIssue, + input: unknown, +): readonly AnalysisInputIssue[] => { + const path = issue.path.flatMap((part) => + typeof part === "string" || typeof part === "number" ? [part] : [], + ); + if (issue.code === "unrecognized_keys") + return issue.keys.map((key) => ({ + path: [...path, key], + reason: "unknown_argument" as const, + })); + if (issue.code === "invalid_type") + return [ + { + path, + reason: + valueAtPath(input, path) === undefined + ? "missing_argument" + : "invalid_type", + expected: safeExpected(issue.expected), + }, + ]; + if (issue.code === "too_small") + return [boundedIssue(path, "minimum", numericBound(issue.minimum))]; + if (issue.code === "too_big") + return [boundedIssue(path, "maximum", numericBound(issue.maximum))]; + if (issue.code === "invalid_format") + return [{ path, reason: "invalid_format", expected: issue.format }]; + if (issue.code === "invalid_value") + return [ + { + path, + reason: "invalid_value", + expected: issue.values.filter(isSafeExpected), + }, + ]; + return [{ path, reason: "invalid_value" }]; +}; + +const valueAtPath = ( + input: unknown, + path: readonly (string | number)[], +): unknown => { + let current = input; + for (const part of path) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Readonly>)[part]; + } + return current; +}; + +const numericBound = (value: number | bigint): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const isSafeExpected = ( + value: unknown, +): value is string | number | boolean | null => + value === null || ["string", "number", "boolean"].includes(typeof value); + +const safeExpected = (value: unknown) => + isSafeExpected(value) ? value : "schema-defined value"; + +const boundedIssue = ( + path: readonly (string | number)[], + key: "minimum" | "maximum", + value: number | undefined, +): AnalysisInputIssue => ({ + path, + reason: "out_of_range", + ...(value === undefined ? {} : { [key]: value }), +}); diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts index cd92975bc..03d34b94b 100644 --- a/src/domain/reconstructionVerificationSchemas.ts +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { evidenceBundleSchema } from "./evidenceBundle.js"; - const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); const unknownIdSchema = z.string().regex(/^unk_[a-f0-9]{64}$/u); const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); @@ -78,9 +76,8 @@ export const reconstructionSpecificationSchema = z }); /** Bounded reconstruction-verification input. */ -export const reconstructionVerificationInputSchema = z.object({ +export const reconstructionVerificationInputSchema = z.strictObject({ specification: reconstructionSpecificationSchema, - evidence_bundle: evidenceBundleSchema, offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(100), unknown_registry_approved: z.literal(true).optional(), diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index 0c291d6e6..de40833b2 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -22,6 +22,7 @@ import { import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import { mcpProgressReporter } from "./mcpProgress.js"; interface ApplicationToolRegistration { @@ -44,7 +45,13 @@ export const registerApplicationTools = ( traceContract.name, toolRegistrationOptions(traceContract), async (input) => { - const parsed = traceApplicationFeatureInputSchema.parse(input); + const parsedInput = safeParseToolInput( + traceApplicationFeatureInputSchema, + input, + traceContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, traceContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, traceContract.name, @@ -61,7 +68,14 @@ export const registerApplicationTools = ( compareContract.name, toolRegistrationOptions(compareContract), async (input) => { - const parsed = compareApplicationVersionsInputSchema.parse(input); + const parsedInput = safeParseToolInput( + compareApplicationVersionsInputSchema, + input, + compareContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, compareContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, compareContract.name, @@ -92,7 +106,13 @@ export const registerApplicationTools = ( replayContract.name, toolRegistrationOptions(replayContract), async (input, context) => { - const parsed = controlledReplayInputSchema.parse(input); + const parsedInput = safeParseToolInput( + controlledReplayInputSchema, + input, + replayContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, replayContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, replayContract.name, diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 306f3f941..3f17a8e9c 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -10,11 +10,12 @@ import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; -import { resolveSessionEvidence } from "./sessionEvidence.js"; +import { resolveSessionEvidenceIds } 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"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Register Evidence-backed deterministic artifact comparison. */ export const registerArtifactComparisonTool = ( @@ -26,18 +27,36 @@ export const registerArtifactComparisonTool = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const parsed = artifactComparisonInputSchema.parse(input); - const left = resolveSessionEvidence(session, parsed.left); + const parsedInput = safeParseToolInput( + artifactComparisonInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; + const expected = { + operation: "inventory_artifact", + predicate: "rea.analysis/v2", + }; + const left = resolveSessionEvidenceIds( + session, + parsed.left_evidence_ids, + expected, + ); if (!left.ok) return toCallToolResult(left, contract); - const right = resolveSessionEvidence(session, parsed.right); + const right = resolveSessionEvidenceIds( + session, + parsed.right_evidence_ids, + expected, + ); if (!right.ok) return toCallToolResult(right, contract); 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 leftEvidenceIds = parsed.left_evidence_ids; + const rightEvidenceIds = parsed.right_evidence_ids; const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { predicateType: "rea.artifact-comparison/v1", operation: contract.name, @@ -67,12 +86,8 @@ export const registerArtifactComparisonTool = ( const artifactUnknownInput = ( input: { - readonly left: - | { readonly evidence_id: string } - | readonly { readonly evidence_id: string }[]; - readonly right: - | { readonly evidence_id: string } - | readonly { readonly evidence_id: string }[]; + readonly left_evidence_ids: readonly string[]; + readonly right_evidence_ids: readonly string[]; readonly unknown_registry_approved?: true | undefined; }, status: ReturnType["status"], @@ -85,8 +100,8 @@ const artifactUnknownInput = ( severity: status === "unknown" || status === "truncated" ? "high" : "medium", domain: "artifact-comparison", - supporting_evidence_ids: evidenceIds(input.left), - contradicting_evidence_ids: evidenceIds(input.right), + supporting_evidence_ids: [...input.left_evidence_ids], + contradicting_evidence_ids: [...input.right_evidence_ids], required_authority: "shipped-artifact", required_confidence: "observed", required_environment: null, @@ -99,10 +114,3 @@ const artifactUnknownInput = ( relationships: [], }; }; - -const evidenceIds = ( - input: - | { readonly evidence_id: string } - | readonly { readonly evidence_id: string }[], -): string[] => - (Array.isArray(input) ? input : [input]).map(({ evidence_id: id }) => id); diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index d88ef4e82..3ca4d2269 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -30,6 +30,7 @@ import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import { mcpProgressReporter } from "./mcpProgress.js"; interface BrowserToolRegistration { @@ -58,7 +59,13 @@ export const registerBrowserTools = ( listContract.name, toolRegistrationOptions(listContract), async (input, context) => { - const parsed = listBrowserTargetsInputSchema.parse(input); + const parsedInput = safeParseToolInput( + listBrowserTargetsInputSchema, + input, + listContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, listContract.name, @@ -78,7 +85,14 @@ export const registerBrowserTools = ( inspectContract.name, toolRegistrationOptions(inspectContract), async (input, context) => { - const parsed = inspectWebPageInputSchema.parse(input); + const parsedInput = safeParseToolInput( + inspectWebPageInputSchema, + input, + inspectContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, inspectContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, inspectContract.name, @@ -96,7 +110,14 @@ export const registerBrowserTools = ( analyzeContract.name, toolRegistrationOptions(analyzeContract), async (input, context) => { - const parsed = analyzeWebBundleInputSchema.parse(input); + const parsedInput = safeParseToolInput( + analyzeWebBundleInputSchema, + input, + analyzeContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, analyzeContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, analyzeContract.name, @@ -119,7 +140,14 @@ export const registerBrowserTools = ( sessionContract.name, toolRegistrationOptions(sessionContract), async (input, context) => { - const parsed = observeWebSessionInputSchema.parse(input); + const parsedInput = safeParseToolInput( + observeWebSessionInputSchema, + input, + sessionContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, sessionContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, sessionContract.name, @@ -142,7 +170,13 @@ export const registerBrowserTools = ( webMcpContract.name, toolRegistrationOptions(webMcpContract), async (input, context) => { - const parsed = discoverWebMcpToolsInputSchema.parse(input); + const parsedInput = safeParseToolInput( + discoverWebMcpToolsInputSchema, + input, + webMcpContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, webMcpContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, webMcpContract.name, @@ -165,7 +199,14 @@ export const registerBrowserTools = ( captureDiffContract.name, toolRegistrationOptions(captureDiffContract), async (input) => { - const parsed = compareWebCapturesInputSchema.parse(input); + const parsedInput = safeParseToolInput( + compareWebCapturesInputSchema, + input, + captureDiffContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, captureDiffContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, captureDiffContract.name, @@ -179,7 +220,14 @@ export const registerBrowserTools = ( screenshotContract.name, toolRegistrationOptions(screenshotContract), async (input, context) => { - const parsed = captureWebScreenshotInputSchema.parse(input); + const parsedInput = safeParseToolInput( + captureWebScreenshotInputSchema, + input, + screenshotContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, screenshotContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, screenshotContract.name, @@ -202,7 +250,14 @@ export const registerBrowserTools = ( screenshotDiffContract.name, toolRegistrationOptions(screenshotDiffContract), async (input) => { - const parsed = compareWebScreenshotsInputSchema.parse(input); + const parsedInput = safeParseToolInput( + compareWebScreenshotsInputSchema, + input, + screenshotDiffContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, screenshotDiffContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, screenshotDiffContract.name, diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 4ce035844..16caec8fc 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -1,73 +1,50 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; +import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js"; import type { ToolContract } from "../contracts/toolContracts.js"; import { bundleComparisonInputSchema, compareBundles, } from "../domain/bundleComparison.js"; import { createEvidence } from "../domain/evidence.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { err, ok } from "../domain/result.js"; +import { ok } from "../domain/result.js"; +import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; 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"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Register canonical Evidence bundle comparison. */ export const registerBundleComparisonTool = ( server: McpServer, session: BinarySessionPort, contract: ToolContract<"compare_bundles">, + evidenceFilePolicy: EvidenceFilePolicy, ): void => { server.registerTool( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const parsed = bundleComparisonInputSchema.parse(input); - const sourceIds = [ - ...new Set([ - ...parsed.left.records.map(({ evidence_id: id }) => id), - ...parsed.right.records.map(({ evidence_id: id }) => id), - ]), - ].sort(); - if ( - [...parsed.left.records, ...parsed.right.records].some( - (evidence) => !isSessionEvidence(session, evidence), - ) - ) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Bundle comparison input Evidence is not present in this session", - ), - ), - contract, - ); - const ownedUnknownDigests = new Set( - session - .exportEvidenceBundle() - .unknowns.map(({ revision_digest: digest }) => digest), + const parsedInput = safeParseToolInput( + bundleComparisonInputSchema, + input, + contract.name, ); - if ( - [...parsed.left.unknowns, ...parsed.right.unknowns].some( - ({ revision_digest: digest }) => !ownedUnknownDigests.has(digest), - ) - ) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Bundle comparison input unknown history is not present in this session", - ), - ), - contract, - ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; + const [left, right] = await Promise.all([ + readEvidenceBundle(parsed.left_bundle_path, evidenceFilePolicy), + readEvidenceBundle(parsed.right_bundle_path, evidenceFilePolicy), + ]); + if (!left.ok) return toCallToolResult(left, contract); + if (!right.ok) return toCallToolResult(right, contract); const computed = await runDerivedOperation(context, contract.name, () => compareBundles( - parsed.left, - parsed.right, + left.value, + right.value, parsed.record_pairs, parsed.offset, parsed.limit, @@ -89,7 +66,6 @@ export const registerBundleComparisonTool = ( confidence: "derived", authority: "analyst-inference", limitations: comparison.limitations, - evidenceLinks: sourceIds, }); const recorded = session.recordEvidence(evidence); return toCallToolResult(recorded.ok ? ok(evidence) : recorded, contract); diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index d9a7f44fb..d664f0dce 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -20,6 +20,7 @@ import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import { mcpProgressReporter } from "./mcpProgress.js"; interface ElectronToolRegistration { @@ -40,7 +41,13 @@ export const registerElectronTools = ( listContract.name, toolRegistrationOptions(listContract), async (input, context) => { - const parsed = listElectronTargetsInputSchema.parse(input); + const parsedInput = safeParseToolInput( + listElectronTargetsInputSchema, + input, + listContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, listContract.name, @@ -60,7 +67,14 @@ export const registerElectronTools = ( inspectContract.name, toolRegistrationOptions(inspectContract), async (input, context) => { - const parsed = inspectElectronPageInputSchema.parse(input); + const parsedInput = safeParseToolInput( + inspectElectronPageInputSchema, + input, + inspectContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, inspectContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, inspectContract.name, @@ -83,7 +97,14 @@ export const registerElectronTools = ( analyzeContract.name, toolRegistrationOptions(analyzeContract), async (input, context) => { - const parsed = analyzeJavaScriptApplicationInputSchema.parse(input); + const parsedInput = safeParseToolInput( + analyzeJavaScriptApplicationInputSchema, + input, + analyzeContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, analyzeContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, analyzeContract.name, @@ -101,7 +122,14 @@ export const registerElectronTools = ( reconcileContract.name, toolRegistrationOptions(reconcileContract), async (input) => { - const parsed = reconcileJavaScriptRuntimeInputSchema.parse(input); + const parsedInput = safeParseToolInput( + reconcileJavaScriptRuntimeInputSchema, + input, + reconcileContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, reconcileContract); + const parsed = parsedInput.value; const result = await logToolExecution( options.logger, reconcileContract.name, diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index aed3f6559..ffcbc18fa 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -22,6 +22,8 @@ import { REA_WORKFLOW_PROVIDER, workflowAnalysisProfile, } from "../application/InvestigationProviders.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Optional session services used by enhanced tool registration. */ export interface EnhancedToolRegistration { @@ -69,12 +71,7 @@ const registerEnhancedTool = ( const name = enhancedToolNameSchema.parse(contract.name); server.registerTool( name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), async (input, context) => { const progress = mcpProgressReporter(context); await progress.report({ @@ -83,9 +80,13 @@ const registerEnhancedTool = ( total: 1, message: "started", }); - const parsedInput = jsonObjectSchema.parse( - enhancedInputSchemas[name].parse(input), + const validatedInput = safeParseToolInput( + enhancedInputSchemas[name], + input, + name, ); + if (!validatedInput.ok) return toCallToolResult(validatedInput, contract); + const parsedInput = jsonObjectSchema.parse(validatedInput.value); const parameters: Record = { ...parsedInput }; const result = await logToolExecution(registration.logger, name, () => services.execute(name, input, context.mcpReq.signal), diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index f5ef12b0d..f53bdd19c 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -14,6 +14,7 @@ import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { mcpProgressReporter } from "./mcpProgress.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import type { PermissionAuthority } from "../application/PermissionAuthority.js"; import { AnalysisProtocolError, @@ -47,9 +48,13 @@ export const registerEvidenceTools = ( total: 1, message: "started", }); - const parameters = jsonObjectSchema.parse( - contract.inputSchema.parse(input), + const parsedInput = safeParseToolInput( + contract.inputSchema, + input, + contract.name, ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parameters = jsonObjectSchema.parse(parsedInput.value); if (options.permissionAuthority !== undefined) { const request = permissionRequest(contract.name, parameters); if (request !== undefined) { diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 7561cba06..9b9d89d6d 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -6,15 +6,16 @@ import { compareFunctions, functionComparisonInputSchema, } from "../domain/functionComparison.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { FUNCTION_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; -import { resolveSessionEvidence } from "./sessionEvidence.js"; +import { resolveSessionEvidenceIds } from "./sessionEvidence.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Register explicit Evidence-backed function comparison. */ export const registerFunctionComparisonTool = ( @@ -26,13 +27,31 @@ export const registerFunctionComparisonTool = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const parsed = functionComparisonInputSchema.parse(input); - const left = resolveSessionEvidence(session, parsed.left); + const parsedInput = safeParseToolInput( + functionComparisonInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; + const expected = { + operation: "analyze_function", + predicate: "rea.analysis/v2", + }; + const left = resolveSessionEvidenceIds( + session, + parsed.left_evidence_ids, + expected, + ); if (!left.ok) return toCallToolResult(left, contract); - const right = resolveSessionEvidence(session, parsed.right); + const right = resolveSessionEvidenceIds( + session, + parsed.right_evidence_ids, + expected, + ); if (!right.ok) return toCallToolResult(right, contract); - const leftIds = evidenceIds(left.value); - const rightIds = evidenceIds(right.value); + const leftIds = left.value.map(({ evidence_id: id }) => id); + const rightIds = right.value.map(({ evidence_id: id }) => id); const computed = await runDerivedOperation(context, contract.name, () => compareFunctions(left.value, right.value, parsed.offset, parsed.limit), ); @@ -100,6 +119,3 @@ const functionUnknownInput = ({ relationships: [], }; }; - -const evidenceIds = (input: Evidence | readonly Evidence[]): string[] => - (Array.isArray(input) ? input : [input]).map(({ evidence_id: id }) => id); diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 92fca322b..2892f58ca 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -3,6 +3,8 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; import { runCrossVersionInvestigation } from "../application/CrossVersionInvestigation.js"; import type { ToolContract } from "../contracts/toolContracts.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import { buildCallPath, callPathInputSchema } from "../domain/callPath.js"; import { correlateStaticAndRuntime, @@ -95,7 +97,13 @@ const registerChangedBehavior = ( contract.name, contractOptions(contract), async (input, context) => { - const parsed = changedBehaviorInputSchema.parse(input); + const parsedInput = safeParseToolInput( + changedBehaviorInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; const investigationRun = parsed.investigation_run; if (investigationRun !== undefined) { const progress = mcpProgressReporter(context); @@ -250,7 +258,13 @@ const registerCallPath = ( contract.name, contractOptions(contract), async (input, context) => { - const parsed = callPathInputSchema.parse(input); + const parsedInput = safeParseToolInput( + callPathInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; const closure = evidenceClosure( session, functionEvidenceIds(parsed.functions), @@ -313,7 +327,13 @@ const registerStaticRuntime = ( contract.name, contractOptions(contract), async (input, context) => { - const parsed = staticRuntimeCorrelationInputSchema.parse(input); + const parsedInput = safeParseToolInput( + staticRuntimeCorrelationInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; const closure = evidenceClosure( session, comparisonClosure([ @@ -378,30 +398,14 @@ const registerReconstruction = ( contract.name, contractOptions(contract), async (input, context) => { - const parsed = reconstructionVerificationInputSchema.parse(input); + const parsedInput = safeParseToolInput( + reconstructionVerificationInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; 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", - ), - ), - contract, - ); const computed = await runDerivedOperation(context, contract.name, () => verifyReconstruction( parsed.specification, @@ -455,12 +459,7 @@ const registerReconstruction = ( ); }; -const contractOptions = (contract: ToolContract) => ({ - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, -}); +const contractOptions = toolRegistrationOptions; const comparisonClosure = (comparisons: readonly Evidence[]): string[] => uniqueIds( diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 96f69204c..f528ae03c 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -18,6 +18,8 @@ import { UnknownRegistryError, } from "../domain/errors.js"; import { mcpProgressReporter } from "./mcpProgress.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Optional session services used by direct tool registration. */ export interface OfficialToolRegistration { @@ -56,14 +58,15 @@ const registerOfficialTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), async (input, context) => { - const arguments_ = projectOfficialArguments(contract, input); + const parsedInput = safeParseToolInput( + contract.inputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const arguments_ = projectOfficialArguments(contract, parsedInput.value); const progress = mcpProgressReporter(context); await progress.report({ phase: contract.name, @@ -153,7 +156,7 @@ const projectOfficialArguments = ( contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number], input: unknown, ): Readonly> => { - const parsed = jsonObjectSchema.parse(contract.inputSchema.parse(input)); + const parsed = jsonObjectSchema.parse(input); const projected: Record = {}; for (const key of Object.keys(contract.inputSchema.shape)) { diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 7425f4aa1..848981a1a 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -1,16 +1,16 @@ import type { McpServer } from "@modelcontextprotocol/server"; -import canonicalize from "canonicalize"; -import { z } from "zod"; import type { BinarySessionPort } from "../application/BinarySession.js"; -import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { + processComparisonInputSchema, + SESSION_TOOL_CONTRACTS, +} from "../contracts/toolContracts.js"; import { createEvidence } from "../domain/evidence.js"; import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { compareProcessCaptures, - processCaptureSchema, parseProcessCapture, } from "../domain/processCapture.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; @@ -19,6 +19,8 @@ import { PROCESS_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; +import { resolveSessionEvidenceIds } from "./sessionEvidence.js"; /** Register deterministic process-capture comparison and contradiction tracking. */ export const registerProcessComparisonTool = ( @@ -31,22 +33,41 @@ export const registerProcessComparisonTool = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const parsed = z - .object({ - left_evidence_id: z.string(), - left: processCaptureSchema, - right_evidence_id: z.string(), - right: processCaptureSchema, - max_capture_age_ms: z.number().int().nonnegative().optional(), - unknown_registry_approved: z.literal(true).optional(), - }) - .parse(input); - const validated = validateCaptureSources(session, parsed); - if (!validated.ok) return toCallToolResult(validated, contract); + const parsedInput = safeParseToolInput( + processComparisonInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; + const expected = { + operation: "capture_process_scenario", + predicate: "rea.process-capture/v4", + }; + const leftRecord = resolveSessionEvidenceIds( + session, + [parsed.left_evidence_id], + expected, + ); + if (!leftRecord.ok) return toCallToolResult(leftRecord, contract); + const rightRecord = resolveSessionEvidenceIds( + session, + [parsed.right_evidence_id], + expected, + ); + if (!rightRecord.ok) return toCallToolResult(rightRecord, contract); let comparison: ReturnType; + let leftCapture: ReturnType; + let rightCapture: ReturnType; try { + leftCapture = parseProcessCapture( + leftRecord.value[0]?.normalized_result, + ); + rightCapture = parseProcessCapture( + rightRecord.value[0]?.normalized_result, + ); const computed = await runDerivedOperation(context, contract.name, () => - compareProcessCaptures(parsed.left, parsed.right, { + compareProcessCaptures(leftCapture, rightCapture, { ...(parsed.max_capture_age_ms === undefined ? {} : { maxCaptureAgeMs: parsed.max_capture_age_ms }), @@ -73,8 +94,8 @@ export const registerProcessComparisonTool = ( parameters: { left_evidence_id: parsed.left_evidence_id, right_evidence_id: parsed.right_evidence_id, - left_normalization: parsed.left.normalization, - right_normalization: parsed.right.normalization, + left_normalization: leftCapture.normalization, + right_normalization: rightCapture.normalization, }, result: jsonValueSchema.parse(comparison), confidence: "derived", @@ -94,47 +115,6 @@ export const registerProcessComparisonTool = ( ); }; -const validateCaptureSources = ( - session: BinarySessionPort, - parsed: { - readonly left_evidence_id: string; - readonly left: z.infer; - readonly right_evidence_id: string; - readonly right: z.infer; - }, -) => { - for (const [evidenceId, capture] of [ - [parsed.left_evidence_id, parsed.left], - [parsed.right_evidence_id, parsed.right], - ] as const) { - const evidence = session.evidenceById(evidenceId); - let evidenceCapture: z.infer | undefined; - try { - evidenceCapture = parseProcessCapture(evidence?.normalized_result); - } catch { - evidenceCapture = undefined; - } - if ( - evidence === undefined || - evidence.operation !== "capture_process_scenario" || - evidence.predicate_type !== "rea.process-capture/v4" || - evidence.provider.id !== PROCESS_PROVIDER.id || - evidence.provider.name !== PROCESS_PROVIDER.name || - evidence.provider.version !== PROCESS_PROVIDER.version || - evidence.confidence !== "observed" || - evidence.authority !== "controlled-replay" || - evidenceCapture === undefined || - canonicalize(evidenceCapture) !== canonicalize(capture) - ) - return err( - new EvidenceIntegrityError( - "Process comparison payload does not match its source Evidence", - ), - ); - } - return { ok: true as const, value: null }; -}; - const comparisonUnknownInput = ( parsed: { readonly left_evidence_id: string; diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 49d8d15bb..f69a3e0f4 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -9,6 +9,8 @@ import { toCallToolResult } from "./toolResult.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import { createServerIdentity } from "../serverIdentity.js"; import { buildCapabilityInventory } from "../application/CapabilityInventory.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; /** Register the read-only provider and target status operation. */ export const registerSessionStatusTool = ( @@ -27,14 +29,15 @@ export const registerSessionStatusTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { - const parsed = binarySessionInputSchema.parse(input); + const parsedInput = safeParseToolInput( + binarySessionInputSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const parsed = parsedInput.value; const client = server.server.getClientVersion(); const clientCapabilities = server.server.getClientCapabilities(); const status = session.status(); diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index e09f961e2..82c46f3a7 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -2,7 +2,13 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import type { BinarySessionPort } from "../application/BinarySession.js"; -import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { + exportEvidenceBundleInputSchema, + importEvidenceBundleInputSchema, + listUnknownsInputSchema, + SESSION_TOOL_CONTRACTS, + verifyUnknownResolutionInputSchema, +} from "../contracts/toolContracts.js"; import { toCallToolResult } from "./toolResult.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; @@ -43,6 +49,8 @@ import { createProcessCaptureEvidence } from "../application/ProcessEvidence.js" import { registerProcessComparisonTool } from "./registerProcessComparisonTool.js"; import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js"; import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js"; import { registerInvestigationTools } from "./registerInvestigationTools.js"; import { @@ -129,14 +137,16 @@ const registerProcessTools = ({ }: ProcessToolRegistration): void => { server.registerTool( captureContract.name, - { - description: captureContract.description, - inputSchema: captureContract.inputSchema, - outputSchema: captureContract.outputSchema, - annotations: captureContract.annotations, - }, + toolRegistrationOptions(captureContract), async (input, context) => { - const scenario = processScenarioSchema.parse(input); + const parsedInput = safeParseToolInput( + processScenarioSchema, + input, + captureContract.name, + ); + if (!parsedInput.ok) + return toCallToolResult(parsedInput, captureContract); + const scenario = parsedInput.value; if (permissionAuthority !== undefined) { const authorized = await permissionAuthority.authorize( { @@ -218,19 +228,15 @@ const registerEvidenceTools = ({ }: EvidenceToolRegistration): void => { server.registerTool( exportContract.name, - { - description: exportContract.description, - inputSchema: exportContract.inputSchema, - outputSchema: exportContract.outputSchema, - annotations: exportContract.annotations, - }, + toolRegistrationOptions(exportContract), async (input) => { - const parsed = z - .object({ - path: z.string().min(1).optional(), - overwrite: z.boolean().default(false), - }) - .parse(input); + const parsedInput = safeParseToolInput( + exportEvidenceBundleInputSchema, + input, + exportContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, exportContract); + const parsed = parsedInput.value; const bundle = session.exportEvidenceBundle(); if (parsed.path === undefined) return toCallToolResult(ok(bundle), exportContract); @@ -273,14 +279,15 @@ const registerEvidenceTools = ({ ); server.registerTool( importContract.name, - { - description: importContract.description, - inputSchema: importContract.inputSchema, - outputSchema: importContract.outputSchema, - annotations: importContract.annotations, - }, + toolRegistrationOptions(importContract), async (input) => { - const path = z.object({ path: z.string().min(1) }).parse(input).path; + const parsedInput = safeParseToolInput( + importEvidenceBundleInputSchema, + input, + importContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, importContract); + const path = parsedInput.value.path; if (permissionAuthority !== undefined) { const authorized = await permissionAuthority.authorize( { @@ -335,28 +342,15 @@ const registerUnknownTools = ({ }: UnknownToolRegistration): void => { server.registerTool( listContract.name, - { - description: listContract.description, - inputSchema: listContract.inputSchema, - outputSchema: listContract.outputSchema, - annotations: listContract.annotations, - }, + toolRegistrationOptions(listContract), (input) => { - const filters = z - .object({ - status: z - .enum([ - "open", - "investigating", - "blocked", - "contradicted", - "resolved", - ]) - .optional(), - severity: z.enum(["low", "medium", "high", "critical"]).optional(), - domain: z.string().trim().min(1).max(100).optional(), - }) - .parse(input); + const parsedInput = safeParseToolInput( + listUnknownsInputSchema, + input, + listContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, listContract); + const filters = parsedInput.value; return toCallToolResult( ok( session.listUnknowns({ @@ -373,48 +367,45 @@ const registerUnknownTools = ({ ); server.registerTool( recordContract.name, - { - description: recordContract.description, - inputSchema: recordContract.inputSchema, - outputSchema: recordContract.outputSchema, - annotations: recordContract.annotations, - }, + toolRegistrationOptions(recordContract), (input) => { - const recorded = session.recordUnknown( - recordUnknownInputSchema.parse(input), + const parsedInput = safeParseToolInput( + recordUnknownInputSchema, + input, + recordContract.name, ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, recordContract); + const recorded = session.recordUnknown(parsedInput.value); if (recorded.ok) server.sendResourceListChanged(); return toCallToolResult(recorded, recordContract); }, ); server.registerTool( updateContract.name, - { - description: updateContract.description, - inputSchema: updateContract.inputSchema, - outputSchema: updateContract.outputSchema, - annotations: updateContract.annotations, - }, + toolRegistrationOptions(updateContract), (input) => { - const updated = session.updateUnknown( - updateUnknownInputSchema.parse(input), + const parsedInput = safeParseToolInput( + updateUnknownInputSchema, + input, + updateContract.name, ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, updateContract); + const updated = session.updateUnknown(parsedInput.value); if (updated.ok) server.sendResourceListChanged(); return toCallToolResult(updated, updateContract); }, ); server.registerTool( verifyContract.name, - { - description: verifyContract.description, - inputSchema: verifyContract.inputSchema, - outputSchema: verifyContract.outputSchema, - annotations: verifyContract.annotations, - }, + toolRegistrationOptions(verifyContract), (input) => { - const unknownId = z - .object({ unknown_id: z.string().regex(/^unk_[a-f0-9]{64}$/u) }) - .parse(input).unknown_id; + const parsedInput = safeParseToolInput( + verifyUnknownResolutionInputSchema, + input, + verifyContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, verifyContract); + const unknownId = parsedInput.value.unknown_id; return toCallToolResult( session.verifyUnknownResolution(unknownId), verifyContract, @@ -458,14 +449,15 @@ const registerLifecycleTools = ({ const [openContract, closeContract, statusContract] = contracts; server.registerTool( openContract.name, - { - description: openContract.description, - inputSchema: openContract.inputSchema, - outputSchema: openContract.outputSchema, - annotations: openContract.annotations, - }, + toolRegistrationOptions(openContract), async (input, context) => { - const parsed = openBinaryInputSchema.parse(input); + const parsedInput = safeParseToolInput( + openBinaryInputSchema, + input, + openContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, openContract); + const parsed = parsedInput.value; let snapshot: AnalysisSnapshot | undefined; if (parsed.snapshot_path !== undefined) { if (permissionAuthority !== undefined) { @@ -526,14 +518,15 @@ const registerLifecycleTools = ({ ); server.registerTool( closeContract.name, - { - description: closeContract.description, - inputSchema: closeContract.inputSchema, - outputSchema: closeContract.outputSchema, - annotations: closeContract.annotations, - }, + toolRegistrationOptions(closeContract), async (input) => { - const parsed = closeBinaryInputSchema.parse(input); + const parsedInput = safeParseToolInput( + closeBinaryInputSchema, + input, + closeContract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, closeContract); + const parsed = parsedInput.value; if (parsed.snapshot_path !== undefined) { if (permissionAuthority !== undefined) { const authorized = await permissionAuthority.authorize( @@ -688,7 +681,12 @@ export const registerSessionTools = ( registerProcessComparisonTool(server, session, compareContract); registerArtifactComparisonTool(server, session, compareArtifactsContract); registerFunctionComparisonTool(server, session, compareFunctionsContract); - registerBundleComparisonTool(server, session, compareBundlesContract); + registerBundleComparisonTool( + server, + session, + compareBundlesContract, + evidenceFilePolicy, + ); const investigationContracts = [ changedBehaviorContract, callPathContract, diff --git a/src/server/sessionEvidence.ts b/src/server/sessionEvidence.ts index 19b81ad97..ffb3d6cc5 100644 --- a/src/server/sessionEvidence.ts +++ b/src/server/sessionEvidence.ts @@ -1,38 +1,50 @@ -import canonicalize from "canonicalize"; - import type { BinarySessionPort } from "../application/BinarySession.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; -import { parseEvidence, type Evidence } from "../domain/evidence.js"; +import { + EvidenceIntegrityError, + EvidenceReferenceError, +} from "../domain/errors.js"; +import type { Evidence } from "../domain/evidence.js"; import { err, ok, type Result } from "../domain/result.js"; type EvidenceAuthorityResult = Result; -/** Resolve caller-supplied records to their immutable session-owned values. */ -export const resolveSessionEvidence = ( +/** Resolve bounded IDs to authoritative records with exact semantic identity. */ +export const resolveSessionEvidenceIds = ( session: BinarySessionPort, - input: Evidence | readonly Evidence[], + evidenceIds: readonly string[], + expected: { readonly operation: string; readonly predicate: string }, ): EvidenceAuthorityResult => { - const supplied = Array.isArray(input) ? input : [input]; - const authoritative: Evidence[] = []; - for (const item of supplied) { - const parsed = parseEvidence(item); - const owned = session.evidenceById(parsed.evidence_id); - if (owned === undefined || canonicalize(owned) !== canonicalize(parsed)) + const records: Evidence[] = []; + for (const evidenceId of evidenceIds) { + const record = session.evidenceById(evidenceId); + if (record === undefined) return err( - new EvidenceIntegrityError( - "Comparison input does not match its session-owned Evidence", + new EvidenceReferenceError( + evidenceId, + "missing", + expected.operation, + null, ), ); - authoritative.push(owned); + if (record.operation !== expected.operation) + return err( + new EvidenceReferenceError( + evidenceId, + "wrong_operation", + expected.operation, + record.operation, + ), + ); + if (record.predicate_type !== expected.predicate) + return err( + new EvidenceReferenceError( + evidenceId, + "wrong_predicate", + expected.predicate, + record.predicate_type, + ), + ); + records.push(record); } - return ok(authoritative); -}; - -/** Check exact ownership for a single Evidence record. */ -export const isSessionEvidence = ( - session: BinarySessionPort, - input: Evidence, -): boolean => { - const owned = session.evidenceById(input.evidence_id); - return owned !== undefined && canonicalize(owned) === canonicalize(input); + return ok(records); }; diff --git a/src/server/toolInputValidation.ts b/src/server/toolInputValidation.ts new file mode 100644 index 000000000..ef29abc76 --- /dev/null +++ b/src/server/toolInputValidation.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../domain/errors.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Parse one MCP request exactly once and retain only schema-owned correction data. */ +export const safeParseToolInput = ( + schema: Schema, + input: unknown, + operation: string, +): Result, AnalysisInputError> => { + const unknownIssues = unknownArgumentIssues(schema, input); + if (unknownIssues.length > 0) + return err(new AnalysisInputError(operation, undefined, unknownIssues)); + const parsed = schema.safeParse(input); + return parsed.success + ? ok(parsed.data) + : err( + new AnalysisInputError( + operation, + undefined, + projectInputIssues(parsed.error.issues, input), + ), + ); +}; + +const schemaCache = new WeakMap>>(); + +const unknownArgumentIssues = ( + schema: z.ZodType, + input: unknown, +): readonly AnalysisInputIssue[] => { + let jsonSchema = schemaCache.get(schema); + if (jsonSchema === undefined) { + const converter = schema["~standard"].jsonSchema?.input; + if (converter === undefined) + throw new TypeError( + "Tool input schema does not expose Standard JSON Schema", + ); + jsonSchema = converter({ target: "draft-2020-12" }); + schemaCache.set(schema, jsonSchema); + } + return scanUnknownArguments(input, jsonSchema, jsonSchema, []); +}; + +const scanUnknownArguments = ( + value: unknown, + schema: Readonly>, + root: Readonly>, + path: readonly (string | number)[], +): AnalysisInputIssue[] => { + const resolved = resolveSchema(schema, root); + if (Array.isArray(value)) { + const items = objectValue(resolved.items); + return items === undefined + ? [] + : value.flatMap((item, index) => + scanUnknownArguments(item, items, root, [...path, index]), + ); + } + if (!isObject(value)) return []; + const variants = schemaVariants(resolved, root); + const properties = Object.fromEntries( + variants.flatMap((variant) => + Object.entries(objectValue(variant.properties) ?? {}), + ), + ); + const closed = + variants.length > 0 && + variants.every( + (variant) => + variant.additionalProperties !== true && + !isObject(variant.additionalProperties), + ); + const issues: AnalysisInputIssue[] = []; + for (const [key, child] of Object.entries(value)) { + const propertySchema = objectValue(properties[key]); + if (propertySchema === undefined) { + if (closed) + issues.push({ path: [...path, key], reason: "unknown_argument" }); + continue; + } + issues.push( + ...scanUnknownArguments(child, propertySchema, root, [...path, key]), + ); + } + return issues; +}; + +const schemaVariants = ( + schema: Readonly>, + root: Readonly>, +): readonly Readonly>[] => { + const resolved = resolveSchema(schema, root); + const variants = [resolved.oneOf, resolved.anyOf, resolved.allOf] + .flatMap((value) => (Array.isArray(value) ? value : [])) + .flatMap((value) => { + const object = objectValue(value); + return object === undefined ? [] : schemaVariants(object, root); + }); + if (variants.length > 0) return variants; + return resolved.type === "object" || isObject(resolved.properties) + ? [resolved] + : []; +}; + +const resolveSchema = ( + schema: Readonly>, + root: Readonly>, +): Readonly> => { + const reference = schema.$ref; + if (typeof reference !== "string" || !reference.startsWith("#/$defs/")) + return schema; + const definition = objectValue(objectValue(root.$defs)?.[reference.slice(8)]); + return definition === undefined ? schema : definition; +}; + +const isObject = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const objectValue = ( + value: unknown, +): Readonly> | undefined => + isObject(value) ? value : undefined; diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts index 4a852ed84..2552c3241 100644 --- a/src/server/toolRegistrationOptions.ts +++ b/src/server/toolRegistrationOptions.ts @@ -1,9 +1,196 @@ +import { z } from "zod"; + import type { ToolContract } from "../contracts/toolContracts.js"; /** Project the public contract fields accepted by MCP tool registration. */ export const toolRegistrationOptions = (contract: ToolContract) => ({ + title: contract.title, description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, + inputSchema: advertisedInputSchema(contract.inputSchema), + outputSchema: advertisedOutputSchema(contract.outputSchema), annotations: contract.annotations, }); + +/** + * Advertise the exact Zod contract while leaving validation to REA's adapter. + * The SDK otherwise converts validation failures to text-only JSON-RPC errors + * before REA can return its stable structured error envelope. + */ +const advertisedInputSchema = (schema: z.ZodObject) => ({ + "~standard": { + version: 1 as const, + vendor: "rea", + validate: (input: unknown) => ({ value: input }), + jsonSchema: { + input: () => + compactJsonSchema( + describeProperties(closeObjectSchemas(inputJsonSchema(schema))), + ), + output: () => + compactJsonSchema( + describeProperties(closeObjectSchemas(inputJsonSchema(schema))), + ), + }, + }, +}); + +const advertisedOutputSchema = (schema: z.ZodObject) => { + const standard = schema["~standard"]; + const converter = standard.jsonSchema?.output; + if (converter === undefined) + throw new TypeError( + "Tool output schema does not expose Standard JSON Schema", + ); + return { + "~standard": { + ...standard, + jsonSchema: { + input: () => + compactJsonSchema( + closeObjectSchemas(converter({ target: "draft-2020-12" })), + ), + output: () => + compactJsonSchema( + closeObjectSchemas(converter({ target: "draft-2020-12" })), + ), + }, + }, + }; +}; + +const inputJsonSchema = (schema: z.ZodObject): Record => { + const converter = schema["~standard"].jsonSchema?.input; + if (converter === undefined) + throw new TypeError( + "Tool input schema does not expose Standard JSON Schema", + ); + return converter({ target: "draft-2020-12" }); +}; + +const closeObjectSchemas = ( + value: Readonly>, +): Record => { + const closed = Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, closeSchemaValue(child)]), + ); + return value.type === "object" && + isObject(value.properties) && + value.additionalProperties === undefined + ? { ...closed, additionalProperties: false } + : closed; +}; + +const closeSchemaValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(closeSchemaValue); + if (!isObject(value)) return value; + return closeObjectSchemas(value); +}; + +const describeProperties = ( + value: Readonly>, +): Record => + Object.fromEntries( + Object.entries(value).map(([key, child]) => { + if (key !== "properties" || typeof child !== "object" || child === null) + return [key, describeValue(child)]; + return [ + key, + Object.fromEntries( + Object.entries(child).map(([property, schema]) => [ + property, + isObject(schema) && typeof schema.description !== "string" + ? { + ...describeProperties(schema), + description: `${property.replaceAll("_", " ")}.`, + } + : describeValue(schema), + ]), + ), + ]; + }), + ); + +const describeValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(describeValue); + return isObject(value) ? describeProperties(value) : value; +}; + +const isObject = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Replace repeated schema fragments with deterministic tool-local definitions. */ +const compactJsonSchema = ( + schema: Readonly>, +): Record => { + const { $schema: _dialect, ...schemaBody } = schema; + const counts = new Map(); + collectSchemas(schemaBody, counts, true); + const candidates = [...counts.entries()] + .filter(([encoded, count]) => count > 1 && encoded.length >= 128) + .sort( + ([left], [right]) => + right.length - left.length || left.localeCompare(right), + ) + .map(([encoded], index) => [encoded, `rea_${String(index)}`] as const); + if (candidates.length === 0) return schemaBody; + const names = new Map(candidates); + const definitions = Object.fromEntries( + candidates.map(([encoded, name]) => [ + name, + replaceSchemas(JSON.parse(encoded), names, encoded), + ]), + ); + const compacted = replaceSchemas(schemaBody, names); + if (!isObject(compacted)) + throw new TypeError("Compacted schema root is not an object"); + return { + ...compacted, + $defs: { + ...(isObject(schemaBody.$defs) ? schemaBody.$defs : {}), + ...definitions, + }, + }; +}; + +const collectSchemas = ( + value: unknown, + counts: Map, + root = false, + keywordContainer = false, +): void => { + if (Array.isArray(value)) { + for (const item of value) collectSchemas(item, counts); + return; + } + if (!isObject(value)) return; + if (!root && !keywordContainer && value.$ref === undefined) { + const encoded = JSON.stringify(value); + counts.set(encoded, (counts.get(encoded) ?? 0) + 1); + } + for (const [key, child] of Object.entries(value)) + collectSchemas(child, counts, false, schemaMapKeyword(key)); +}; + +const replaceSchemas = ( + value: unknown, + names: ReadonlyMap, + skipped?: string, + keywordContainer = false, +): unknown => { + if (Array.isArray(value)) + return value.map((item) => replaceSchemas(item, names, skipped)); + if (!isObject(value)) return value; + const encoded = JSON.stringify(value); + const name = names.get(encoded); + if (!keywordContainer && name !== undefined && encoded !== skipped) + return { $ref: `#/$defs/${name}` }; + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + replaceSchemas(child, names, skipped, schemaMapKeyword(key)), + ]), + ); +}; + +const schemaMapKeyword = (key: string): boolean => + key === "properties" || key === "patternProperties" || key === "$defs"; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index b2644d8b9..03f59a595 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -29,14 +29,15 @@ export const toCallToolResult = ( const errorResult = (error: AnalysisError): CallToolResult => { const projected = projectAnalysisError(error); + const structuredContent = { error: projected }; return { content: [ { type: "text", - text: projected.message, + text: JSON.stringify(structuredContent), }, ], - structuredContent: { error: projected }, + structuredContent, isError: true, }; }; @@ -46,30 +47,33 @@ const successResult = ( contract: ToolContract, options: ToolResultOptions, ): CallToolResult => { - const candidate = contract.kind === "session" ? { result: value } : value; + const candidate = + compactEvidence(value) ?? + (contract.kind === "session" ? { result: value } : value); const parsed = contract.outputSchema.safeParse(candidate); if (!parsed.success) { + const structuredContent = { + error: projectAnalysisError( + new AnalysisOutputError( + contract.name, + "output does not match the tool contract", + ), + ), + }; return { content: [ { type: "text", - text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", + text: JSON.stringify(structuredContent), }, ], - structuredContent: { - error: projectAnalysisError( - new AnalysisOutputError( - contract.name, - "output does not match the tool contract", - ), - ), - }, + structuredContent, isError: true, }; } return { content: [ - { type: "text", text: formatValue(value) }, + { type: "text", text: JSON.stringify(parsed.data) }, ...evidenceResourceLinks( value, contract.kind === "session" || @@ -80,6 +84,23 @@ const successResult = ( }; }; +const compactEvidence = (value: JsonValue): JsonValue | undefined => { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + typeof value.evidence_id !== "string" || + !/^ev_[a-f0-9]{64}$/u.test(value.evidence_id) || + !("normalized_result" in value) + ) + return undefined; + return { + result: value.normalized_result, + evidence_id: value.evidence_id, + evidence_uri: `rea://evidence/${value.evidence_id}`, + }; +}; + const evidenceResourceLinks = ( value: JsonValue, available: boolean, @@ -103,10 +124,3 @@ const evidenceResourceLinks = ( }, ]; }; - -const formatValue = (value: JsonValue): string => { - if (value === null) return "OK"; - if (typeof value === "string") return value; - if (typeof value === "object") return JSON.stringify(value, null, 2); - return String(value); -};