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.
This commit is contained in:
morluto 2026-07-16 18:49:35 +08:00
parent ad398a17f5
commit ee1cd40405
47 changed files with 1387 additions and 672 deletions

View File

@ -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"
},

View File

@ -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",
});

View File

@ -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" },

View File

@ -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 ||

View File

@ -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 <version>");
@ -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`,
);

View File

@ -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 };

View File

@ -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,

View File

@ -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<Evidence, AnalysisError> => {
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<Evidence, AnalysisError> => {
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);

View File

@ -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<Result<JsonValue, AnalysisError>> => {
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(

View File

@ -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<Evidence, AnalysisError> => {
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(

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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 extends string>(
name: Name,
description: string,
inputSchema: z.ZodObject,
mutatesFilesystem: boolean,
): ToolContract<Name> => {
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[];

View File

@ -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 extends z.ZodType>(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",

View File

@ -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",

View File

@ -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,
]),
},
};

View File

@ -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",

View File

@ -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<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_macho: {},
@ -22,16 +23,11 @@ const native = <Name extends string>(
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`,

View File

@ -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",
},
};

View File

@ -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<Name extends string = string> {
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[];
}

View File

@ -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 extends string>(
name: Name,
description: string,
@ -128,11 +96,11 @@ const official = <Name extends string>(
});
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 = <Name extends string>(
inputSchema: z.ZodObject,
): ToolContract<Name> => ({
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 = <Name extends string>(
inputSchema: z.ZodObject,
): ToolContract<Name> => ({
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[];

View File

@ -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> = {}): 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<Record<string, ToolEffects>> = {
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),
};
};

View File

@ -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<Record<string, z.ZodObject>> = {
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),

View File

@ -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

View File

@ -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({

View File

@ -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<Record<string, JsonValue>> | 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<AnalysisErrorProjection["code"]> = new Set([
"invalid_request",
"provider_timeout",
"cancelled",
"revision_conflict",
@ -800,6 +847,8 @@ const RETRYABLE_CODES: ReadonlySet<AnalysisErrorProjection["code"]> = 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)

View File

@ -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(),

View File

@ -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<Record<string | number, unknown>>)[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 }),
});

View File

@ -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(),

View File

@ -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,

View File

@ -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<typeof compareArtifacts>["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);

View File

@ -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,

View File

@ -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);

View File

@ -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,

View File

@ -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<string, JsonValue> = { ...parsedInput };
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),

View File

@ -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) {

View File

@ -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);

View File

@ -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(

View File

@ -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<Record<string, JsonValue>> => {
const parsed = jsonObjectSchema.parse(contract.inputSchema.parse(input));
const parsed = jsonObjectSchema.parse(input);
const projected: Record<string, JsonValue> = {};
for (const key of Object.keys(contract.inputSchema.shape)) {

View File

@ -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<typeof compareProcessCaptures>;
let leftCapture: ReturnType<typeof parseProcessCapture>;
let rightCapture: ReturnType<typeof parseProcessCapture>;
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<typeof processCaptureSchema>;
readonly right_evidence_id: string;
readonly right: z.infer<typeof processCaptureSchema>;
},
) => {
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<typeof processCaptureSchema> | 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;

View File

@ -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();

View File

@ -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,

View File

@ -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<Evidence[], EvidenceIntegrityError>;
/** 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);
};

View File

@ -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 extends z.ZodType>(
schema: Schema,
input: unknown,
operation: string,
): Result<z.output<Schema>, 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<z.ZodType, Readonly<Record<string, unknown>>>();
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<Record<string, unknown>>,
root: Readonly<Record<string, unknown>>,
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<Record<string, unknown>>,
root: Readonly<Record<string, unknown>>,
): readonly Readonly<Record<string, unknown>>[] => {
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<Record<string, unknown>>,
root: Readonly<Record<string, unknown>>,
): Readonly<Record<string, unknown>> => {
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<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const objectValue = (
value: unknown,
): Readonly<Record<string, unknown>> | undefined =>
isObject(value) ? value : undefined;

View File

@ -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<string, unknown> => {
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<string, unknown>>,
): Record<string, unknown> => {
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<string, unknown>>,
): Record<string, unknown> =>
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<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Replace repeated schema fragments with deterministic tool-local definitions. */
const compactJsonSchema = (
schema: Readonly<Record<string, unknown>>,
): Record<string, unknown> => {
const { $schema: _dialect, ...schemaBody } = schema;
const counts = new Map<string, number>();
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<string, number>,
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<string, string>,
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";

View File

@ -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);
};