diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 19b43056f..1db251793 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -72,20 +72,26 @@ export const runCrossVersionInvestigation = async ( cause: parsed.error, }), ); + return runCrossVersionInvestigationValidated(parsed.data, policy, execution); +}; + +/** Run input already parsed by a trusted adapter boundary. */ +export const runCrossVersionInvestigationValidated = async ( + input: CrossVersionInvestigationInput, + policy: EvidenceFilePolicy, + execution: CrossVersionInvestigationExecution, +): Promise> => { const initial = await readInvestigationWorkspace( - parsed.data.workspace_path, + input.workspace_path, policy, ); if (!initial.ok) return initial; - const preflight = validateWorkspaceRequest(initial.value, parsed.data); + const preflight = validateWorkspaceRequest(initial.value, input); if (!preflight.ok) return preflight; if (isCancelled(execution.signal)) return err(new AnalysisCancelledError("find_changed_behavior")); - if (parsed.data.replay_run_id !== undefined) { - const replay = selectCompletedInvestigationReplay( - initial.value, - parsed.data, - ); + if (input.replay_run_id !== undefined) { + const replay = selectCompletedInvestigationReplay(initial.value, input); if (!replay.ok) return replay; return completeOutcome( replay.value.workspace, @@ -106,7 +112,7 @@ export const runCrossVersionInvestigation = async ( }); const snapshots = await scanVersions( - parsed.data, + input, execution.inputRoots, execution.signal, execution.integrityContinueEnabled, @@ -119,7 +125,7 @@ export const runCrossVersionInvestigation = async ( message: "Version inputs scanned", }); return continueInvestigation({ - input: parsed.data, + input, initial: initial.value, snapshots: snapshots.value, policy, diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a481760de..6c7bbf631 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -2,6 +2,7 @@ import type { AnalysisOperation, AnalysisOperationPort, } from "./AnalysisProvider.js"; +import type { z } from "zod"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; import { @@ -37,15 +38,23 @@ type TraceReference = { containing_procedure: JsonValue; }; +/** One enhanced call whose input was parsed at its owning adapter boundary. */ +export type ValidatedEnhancedCall = { + [Name in EnhancedToolName]: { + readonly name: Name; + readonly input: z.output<(typeof enhancedInputSchemas)[Name]>; + }; +}[EnhancedToolName]; + /** * Composes direct provider operations into bounded reverse-engineering tools. - * Inputs are parsed again at this application boundary even though MCP normally - * validates them, allowing direct callers to use the same service safely. + * Direct callers use `execute`; adapters that own validation use + * `executeValidated` so an MCP input is parsed exactly once. */ export class EnhancedTools { constructor(private readonly analysis: AnalysisOperationPort) {} - /** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */ + /** Parse an untrusted direct-call input once, then dispatch exhaustively. */ execute( name: EnhancedToolName, input: unknown, @@ -55,58 +64,87 @@ export class EnhancedTools { case "swift_classes": { const parsed = enhancedInputSchemas.swift_classes.safeParse(input); return parsed.success - ? this.#swiftClasses(parsed.data.pattern, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "get_objc_classes": { const parsed = enhancedInputSchemas.get_objc_classes.safeParse(input); return parsed.success - ? this.#objcClasses(parsed.data.pattern, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "get_objc_protocols": - return this.#objcProtocols(signal); + return this.executeValidated({ name, input: {} }, signal); case "batch_decompile": { const parsed = enhancedInputSchemas.batch_decompile.safeParse(input); return parsed.success - ? this.#batchDecompile(parsed.data.addresses, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "get_call_graph": { const parsed = enhancedInputSchemas.get_call_graph.safeParse(input); return parsed.success - ? this.#callGraph(parsed.data, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "analyze_swift_types": - return this.#analyzeSwiftTypes(signal); + return this.executeValidated({ name, input: {} }, signal); case "find_xrefs_to_name": { const parsed = enhancedInputSchemas.find_xrefs_to_name.safeParse(input); return parsed.success - ? this.#findXrefs(parsed.data.name, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "binary_overview": { const parsed = enhancedInputSchemas.binary_overview.safeParse(input); return parsed.success - ? this.#binaryOverview(parsed.data, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "analyze_function": { const parsed = enhancedInputSchemas.analyze_function.safeParse(input); return parsed.success - ? this.#analyzeFunction(parsed.data, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } case "trace_feature": { const parsed = enhancedInputSchemas.trace_feature.safeParse(input); return parsed.success - ? this.#traceFeature(parsed.data, signal) + ? this.executeValidated({ name, input: parsed.data }, signal) : invalidInput(name, parsed.error); } } } + /** Dispatch input already parsed by a trusted adapter boundary. */ + executeValidated( + call: ValidatedEnhancedCall, + signal?: AbortSignal, + ): EnhancedResult { + switch (call.name) { + case "swift_classes": + return this.#swiftClasses(call.input.pattern, signal); + case "get_objc_classes": + return this.#objcClasses(call.input.pattern, signal); + case "get_objc_protocols": + return this.#objcProtocols(signal); + case "batch_decompile": + return this.#batchDecompile(call.input.addresses, signal); + case "get_call_graph": + return this.#callGraph(call.input, signal); + case "analyze_swift_types": + return this.#analyzeSwiftTypes(signal); + case "find_xrefs_to_name": + return this.#findXrefs(call.input.name, signal); + case "binary_overview": + return this.#binaryOverview(call.input, signal); + case "analyze_function": + return this.#analyzeFunction(call.input, signal); + case "trace_feature": + return this.#traceFeature(call.input, signal); + } + } + async #analyzeFunction( input: Readonly>, signal?: AbortSignal, diff --git a/src/application/JavaScriptApplicationService.ts b/src/application/JavaScriptApplicationService.ts index c33d2825d..7e78e82c1 100644 --- a/src/application/JavaScriptApplicationService.ts +++ b/src/application/JavaScriptApplicationService.ts @@ -30,6 +30,15 @@ export const analyzeJavaScriptApplication = async ( ): Promise> => { const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput); if (!parsed.success) return err(new AnalysisInputError(OPERATION)); + return analyzeJavaScriptApplicationValidated(authority, parsed.data, options); +}; + +/** Analyze input already parsed by a trusted adapter boundary. */ +export const analyzeJavaScriptApplicationValidated = async ( + authority: PermissionAuthority | undefined, + input: z.output, + options: ExecutionOptions = {}, +): Promise> => { if (authority === undefined) return err( new AnalysisCapabilityUnavailableError( @@ -41,12 +50,12 @@ export const analyzeJavaScriptApplication = async ( const authorized = await authority.authorize( { capability: "investigation_input", - roots: [parsed.data.input_path], + roots: [input.input_path], executables: [], environment_names: [], network: "none", mount: false, - operation_identity: `${OPERATION}:${parsed.data.input_path}`, + operation_identity: `${OPERATION}:${input.input_path}`, }, "read", ); @@ -55,7 +64,7 @@ export const analyzeJavaScriptApplication = async ( authorized.error instanceof PermissionRequiredError ? authorized.error : new ArtifactOperationError(OPERATION, "path", { - logicalPath: parsed.data.input_path, + logicalPath: input.input_path, declaredSha256: null, calculatedSha256: null, unpacked: false, @@ -70,10 +79,10 @@ export const analyzeJavaScriptApplication = async ( try { const reconstructed = await reconstructJavaScriptArtifact( { - input_path: parsed.data.input_path, - format: parsed.data.format, - source_map_read_approved: parsed.data.source_map_read_approved, - limits: parsed.data.limits, + input_path: input.input_path, + format: input.format, + source_map_read_approved: input.source_map_read_approved, + limits: input.limits, }, options.signal, ); @@ -91,7 +100,7 @@ export const analyzeJavaScriptApplication = async ( message: "Application graph and Electron boundaries reconstructed", terminal: true, }); - return ok(createJavaScriptApplicationEvidence(parsed.data, result)); + return ok(createJavaScriptApplicationEvidence(input, result)); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) return err( diff --git a/src/application/JavaScriptApplicationWorkflowService.ts b/src/application/JavaScriptApplicationWorkflowService.ts index f1e6d36cd..2f2da0db1 100644 --- a/src/application/JavaScriptApplicationWorkflowService.ts +++ b/src/application/JavaScriptApplicationWorkflowService.ts @@ -35,27 +35,35 @@ export const traceApplicationFeatureEvidence = ( projectInputIssues(parsed.error.issues, rawInput), ), ); + return traceApplicationFeatureEvidenceValidated(parsed.data); +}; + +/** Derive one feature trace from input parsed by a trusted adapter. */ +export const traceApplicationFeatureEvidenceValidated = ( + input: z.output, +): Result => { + const operation = "trace_application_feature"; try { - const source = parseApplicationGraphEvidence(parsed.data.application); + const source = parseApplicationGraphEvidence(input.application); const nativeEvidence = parseNativeApplicationEvidence( - parsed.data.native_observations, + input.native_observations, ); const result = traceApplicationFeature({ sourceEvidenceId: source.evidence.evidence_id, graph: source.graph, nativeEvidence, - seed: parsed.data.seed, - direction: parsed.data.direction, - limits: parsed.data.limits, + seed: input.seed, + direction: input.direction, + limits: input.limits, }); return ok( createApplicationFeatureTraceEvidence( { application_evidence_id: source.evidence.evidence_id, native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id), - seed: parsed.data.seed, - direction: parsed.data.direction, - limits: parsed.data.limits, + seed: input.seed, + direction: input.direction, + limits: input.limits, }, result, ), @@ -79,14 +87,22 @@ export const compareApplicationVersionsEvidence = ( projectInputIssues(parsed.error.issues, rawInput), ), ); + return compareApplicationVersionsEvidenceValidated(parsed.data); +}; + +/** Compare versions from input parsed by a trusted adapter. */ +export const compareApplicationVersionsEvidenceValidated = ( + input: z.output, +): Result => { + const operation = "compare_application_versions"; try { - const left = parseApplicationGraphEvidence(parsed.data.left); - const right = parseApplicationGraphEvidence(parsed.data.right); + const left = parseApplicationGraphEvidence(input.left); + const right = parseApplicationGraphEvidence(input.right); const leftNative = parseNativeApplicationEvidence( - parsed.data.left_native_observations, + input.left_native_observations, ); const rightNative = parseNativeApplicationEvidence( - parsed.data.right_native_observations, + input.right_native_observations, ); const result = compareJavaScriptApplicationVersions({ left: { @@ -101,7 +117,7 @@ export const compareApplicationVersionsEvidence = ( }, leftNativeEvidence: leftNative, rightNativeEvidence: rightNative, - limits: parsed.data.limits, + limits: input.limits, }); return ok( createApplicationVersionComparisonEvidence( @@ -112,7 +128,7 @@ export const compareApplicationVersionsEvidence = ( right_native_evidence_ids: rightNative.map( ({ evidence_id: id }) => id, ), - limits: parsed.data.limits, + limits: input.limits, }, result, ), diff --git a/src/application/JavaScriptReplayService.ts b/src/application/JavaScriptReplayService.ts index 1bbc342e5..b283cf5dd 100644 --- a/src/application/JavaScriptReplayService.ts +++ b/src/application/JavaScriptReplayService.ts @@ -56,6 +56,15 @@ export const runControlledReplay = async ( projectInputIssues(parsed.error.issues, rawInput), ), ); + return runControlledReplayValidated(dependencies, parsed.data, options); +}; + +/** Plan or execute replay input already parsed by a trusted adapter. */ +export const runControlledReplayValidated = async ( + dependencies: JavaScriptReplayDependencies, + input: z.output, + options: ExecutionOptions = {}, +): Promise> => { if (!dependencies.policy.enabled) return err( new AnalysisCapabilityUnavailableError( @@ -74,9 +83,9 @@ export const runControlledReplay = async ( ); if (isAborted(options.signal)) return err(new AnalysisCancelledError(OPERATION)); - const request = permissionRequest(parsed.data, dependencies.policy); + const request = permissionRequest(input, dependencies.policy); const authorized = - parsed.data.mode === "plan" + input.mode === "plan" ? await dependencies.authority.explain(request, "read") : await dependencies.authority.authorize(request, "read"); if (!authorized.ok) @@ -90,7 +99,7 @@ export const runControlledReplay = async ( let prepared; try { prepared = await prepareReplayPlan( - parsed.data, + input, dependencies.policy, dependencies.host, ); @@ -105,7 +114,7 @@ export const runControlledReplay = async ( } if (isAborted(options.signal)) return err(new AnalysisCancelledError(OPERATION)); - if (parsed.data.mode === "plan") + if (input.mode === "plan") return ok( asJson({ phase: "plan", @@ -114,21 +123,21 @@ export const runControlledReplay = async ( evidence: null, }), ); - if (parsed.data.plan_digest !== prepared.publicPlan.plan_digest) + if (input.plan_digest !== prepared.publicPlan.plan_digest) return err( new ReplayPlanStaleError( - parsed.data.plan_digest ?? "", + input.plan_digest ?? "", prepared.publicPlan.plan_digest, ), ); let canonicalExportPath: string | undefined; - if (parsed.data.reproducer_export !== undefined) { - if (parsed.data.reproducer_export.approved !== true) + if (input.reproducer_export !== undefined) { + if (input.reproducer_export.approved !== true) return err(new AnalysisInputError(`${OPERATION}:reproducer_export`)); const exportAuthorized = await dependencies.authority.authorize( { capability: "evidence_write", - roots: [parsed.data.reproducer_export.path], + roots: [input.reproducer_export.path], executables: [], environment_names: [], network: "none", @@ -146,7 +155,7 @@ export const runControlledReplay = async ( }), ); canonicalExportPath = exportAuthorized.value.request.roots[0]; - if (canonicalExportPath !== parsed.data.reproducer_export.path) + if (canonicalExportPath !== input.reproducer_export.path) return err( new AnalysisInputError(`${OPERATION}:reproducer_export:path`, { cause: new TypeError( @@ -183,7 +192,7 @@ export const runControlledReplay = async ( if (executed.termination === "cancelled") return err(new AnalysisCancelledError(OPERATION)); if ( - parsed.data.reproducer_export !== undefined && + input.reproducer_export !== undefined && canonicalExportPath !== undefined && executed.cleanup.state === "complete" ) { @@ -191,7 +200,7 @@ export const runControlledReplay = async ( schema_version: 1, plan: prepared.publicPlan, result: executed, - sources: parsed.data.reproducer_export.include_sources + sources: input.reproducer_export.include_sources ? { left: prepared.leftSources, ...(prepared.rightSources === undefined diff --git a/src/application/JavaScriptRuntimeReconciliationService.ts b/src/application/JavaScriptRuntimeReconciliationService.ts index 46bcb08f9..f78f5bf75 100644 --- a/src/application/JavaScriptRuntimeReconciliationService.ts +++ b/src/application/JavaScriptRuntimeReconciliationService.ts @@ -27,11 +27,16 @@ export const reconcileJavaScriptRuntimeEvidence = ( projectInputIssues(parsed.error.issues, rawInput), ), ); + return reconcileJavaScriptRuntimeEvidenceValidated(parsed.data); +}; + +/** Reconcile input already parsed by a trusted adapter boundary. */ +export const reconcileJavaScriptRuntimeEvidenceValidated = ( + input: z.output, +): Result => { try { - const result = reconcileJavaScriptRuntime(parsed.data); - return ok( - createJavaScriptRuntimeReconciliationEvidence(parsed.data, result), - ); + const result = reconcileJavaScriptRuntime(input); + return ok(createJavaScriptRuntimeReconciliationEvidence(input, result)); } catch (cause: unknown) { return err( cause instanceof z.ZodError || cause instanceof TypeError diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index de40833b2..60d159bbf 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -2,11 +2,11 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; import { - compareApplicationVersionsEvidence, - traceApplicationFeatureEvidence, + compareApplicationVersionsEvidenceValidated, + traceApplicationFeatureEvidenceValidated, } from "../application/JavaScriptApplicationWorkflowService.js"; import { - runControlledReplay, + runControlledReplayValidated, type JavaScriptReplayDependencies, } from "../application/JavaScriptReplayService.js"; import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js"; @@ -55,7 +55,7 @@ export const registerApplicationTools = ( const result = await logToolExecution( options.logger, traceContract.name, - () => Promise.resolve(traceApplicationFeatureEvidence(parsed)), + () => Promise.resolve(traceApplicationFeatureEvidenceValidated(parsed)), ); if (!result.ok) return toCallToolResult(result, traceContract); const sources = [parsed.application, ...parsed.native_observations]; @@ -79,7 +79,8 @@ export const registerApplicationTools = ( const result = await logToolExecution( options.logger, compareContract.name, - () => Promise.resolve(compareApplicationVersionsEvidence(parsed)), + () => + Promise.resolve(compareApplicationVersionsEvidenceValidated(parsed)), ); if (!result.ok) return toCallToolResult(result, compareContract); const sources = [ @@ -117,7 +118,7 @@ export const registerApplicationTools = ( options.logger, replayContract.name, () => - runControlledReplay(options.replay, parsed, { + runControlledReplayValidated(options.replay, parsed, { signal: context.mcpReq.signal, progress: mcpProgressReporter(context), }), diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index d664f0dce..10a778a5b 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -2,8 +2,8 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; import type { ElectronObservationPort } from "../application/ElectronObservationPort.js"; -import { analyzeJavaScriptApplication } from "../application/JavaScriptApplicationService.js"; -import { reconcileJavaScriptRuntimeEvidence } from "../application/JavaScriptRuntimeReconciliationService.js"; +import { analyzeJavaScriptApplicationValidated } from "../application/JavaScriptApplicationService.js"; +import { reconcileJavaScriptRuntimeEvidenceValidated } from "../application/JavaScriptRuntimeReconciliationService.js"; import { inspectElectronPage, listElectronTargets, @@ -109,10 +109,14 @@ export const registerElectronTools = ( options.logger, analyzeContract.name, () => - analyzeJavaScriptApplication(options.permissionAuthority, parsed, { - signal: context.mcpReq.signal, - progress: mcpProgressReporter(context), - }), + analyzeJavaScriptApplicationValidated( + options.permissionAuthority, + parsed, + { + signal: context.mcpReq.signal, + progress: mcpProgressReporter(context), + }, + ), ); if (!result.ok) return toCallToolResult(result, analyzeContract); return evidenceResult(options, analyzeContract, result.value); @@ -133,7 +137,8 @@ export const registerElectronTools = ( const result = await logToolExecution( options.logger, reconcileContract.name, - () => Promise.resolve(reconcileJavaScriptRuntimeEvidence(parsed)), + () => + Promise.resolve(reconcileJavaScriptRuntimeEvidenceValidated(parsed)), ); if (!result.ok) return toCallToolResult(result, reconcileContract); for (const source of [ diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index ffcbc18fa..fd6928595 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -1,6 +1,9 @@ import type { McpServer } from "@modelcontextprotocol/server"; -import { EnhancedTools } from "../application/EnhancedTools.js"; +import { + EnhancedTools, + type ValidatedEnhancedCall, +} from "../application/EnhancedTools.js"; import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; import type { BinarySessionPort } from "../application/BinarySession.js"; import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js"; @@ -13,9 +16,10 @@ import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import { createEvidence } from "../domain/evidence.js"; -import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; -import { UnknownRegistryError } from "../domain/errors.js"; +import { AnalysisInputError, UnknownRegistryError } from "../domain/errors.js"; +import { ok, type Result } from "../domain/result.js"; import { mcpProgressReporter } from "./mcpProgress.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { @@ -80,16 +84,12 @@ const registerEnhancedTool = ( total: 1, message: "started", }); - const validatedInput = safeParseToolInput( - enhancedInputSchemas[name], - input, - name, - ); - if (!validatedInput.ok) return toCallToolResult(validatedInput, contract); - const parsedInput = jsonObjectSchema.parse(validatedInput.value); - const parameters: Record = { ...parsedInput }; + const validatedCall = parseEnhancedCall(name, input); + if (!validatedCall.ok) return toCallToolResult(validatedCall, contract); + const parsedInput = validatedCall.value.input; + const parameters = jsonParameters(parsedInput); const result = await logToolExecution(registration.logger, name, () => - services.execute(name, input, context.mcpReq.signal), + services.executeValidated(validatedCall.value, context.mcpReq.signal), ); await progress.report({ phase: name, @@ -123,7 +123,7 @@ const registerEnhancedTool = ( return toCallToolResult(recorded, contract); const unknowns = recordWorkflowUnknowns({ name, - input: parsedInput, + input: parameters, result: result.value, evidenceId: evidence.evidence_id, recordUnknown: registration.recordUnknown, @@ -138,6 +138,103 @@ const registerEnhancedTool = ( ); }; +const jsonParameters = ( + input: Readonly>, +): Record => + Object.fromEntries( + Object.entries(input).filter( + (entry): entry is [string, JsonValue] => entry[1] !== undefined, + ), + ); + +const parseEnhancedCall = ( + name: ValidatedEnhancedCall["name"], + input: unknown, +): Result => { + switch (name) { + case "swift_classes": { + const parsed = safeParseToolInput( + enhancedInputSchemas.swift_classes, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "get_objc_classes": { + const parsed = safeParseToolInput( + enhancedInputSchemas.get_objc_classes, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "get_objc_protocols": { + const parsed = safeParseToolInput( + enhancedInputSchemas.get_objc_protocols, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "batch_decompile": { + const parsed = safeParseToolInput( + enhancedInputSchemas.batch_decompile, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "get_call_graph": { + const parsed = safeParseToolInput( + enhancedInputSchemas.get_call_graph, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "analyze_swift_types": { + const parsed = safeParseToolInput( + enhancedInputSchemas.analyze_swift_types, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "find_xrefs_to_name": { + const parsed = safeParseToolInput( + enhancedInputSchemas.find_xrefs_to_name, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "binary_overview": { + const parsed = safeParseToolInput( + enhancedInputSchemas.binary_overview, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "analyze_function": { + const parsed = safeParseToolInput( + enhancedInputSchemas.analyze_function, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + case "trace_feature": { + const parsed = safeParseToolInput( + enhancedInputSchemas.trace_feature, + input, + name, + ); + return parsed.ok ? ok({ name, input: parsed.value }) : parsed; + } + } +}; + interface WorkflowUnknownInput { readonly name: string; readonly input: Readonly>; diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 2892f58ca..18522d746 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; -import { runCrossVersionInvestigation } from "../application/CrossVersionInvestigation.js"; +import { runCrossVersionInvestigationValidated } from "../application/CrossVersionInvestigation.js"; import type { ToolContract } from "../contracts/toolContracts.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { safeParseToolInput } from "./toolInputValidation.js"; @@ -133,7 +133,7 @@ const registerChangedBehavior = ( operation: contract.name, }); } - const investigated = await runCrossVersionInvestigation( + const investigated = await runCrossVersionInvestigationValidated( investigationRun, policies.evidenceFiles, { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 3899b6a57..1b39645a6 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -1,6 +1,6 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; import { EnhancedTools } from "../src/application/EnhancedTools.js"; @@ -9,6 +9,7 @@ import { SESSION_TOOL_CONTRACTS, TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; +import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; import { AnalysisOutputError } from "../src/domain/errors.js"; import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js"; import { err } from "../src/domain/result.js"; @@ -144,6 +145,7 @@ const emptyBounded = () => ({ const resources: Array<{ close(): Promise }> = []; afterEach(async () => { + vi.restoreAllMocks(); await Promise.all( resources.splice(0).map(async (resource) => resource.close()), ); @@ -613,6 +615,16 @@ describe("enhanced MCP tools", () => { it("returns correction-only structured validation issues", async () => { const client = await connect(); + const traceParser = vi.spyOn( + enhancedInputSchemas.trace_feature, + "safeParse", + ); + await client.callTool({ + name: "trace_feature", + arguments: { query: "needle", max_operations: 1 }, + }); + expect(traceParser).toHaveBeenCalledTimes(1); + traceParser.mockClear(); const misspelled = await client.callTool({ name: "trace_feature", arguments: { query: "needle", max_operatons: 1 }, @@ -629,6 +641,7 @@ describe("enhanced MCP tools", () => { }, }, }); + expect(traceParser).not.toHaveBeenCalled(); const nestedMisspelled = await client.callTool({ name: "analyze_function", arguments: {