fix(mcp): parse adapter inputs exactly once

This commit is contained in:
morluto 2026-07-16 19:16:04 +08:00
parent d1e72207f4
commit 37c0f39954
11 changed files with 288 additions and 89 deletions

View File

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

View File

@ -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<Record<string, JsonValue>>,
signal?: AbortSignal,

View File

@ -30,6 +30,15 @@ export const analyzeJavaScriptApplication = async (
): Promise<Result<Evidence, AnalysisError>> => {
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<typeof analyzeJavaScriptApplicationInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
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(

View File

@ -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<typeof traceApplicationFeatureInputSchema>,
): Result<Evidence, AnalysisError> => {
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<typeof compareApplicationVersionsInputSchema>,
): Result<Evidence, AnalysisError> => {
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,
),

View File

@ -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<typeof controlledReplayInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<JsonValue, AnalysisError>> => {
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

View File

@ -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<typeof reconcileJavaScriptRuntimeInputSchema>,
): Result<Evidence, AnalysisError> => {
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

View File

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

View File

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

View File

@ -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<string, JsonValue> = { ...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<string, JsonValue | undefined>>,
): Record<string, JsonValue> =>
Object.fromEntries(
Object.entries(input).filter(
(entry): entry is [string, JsonValue] => entry[1] !== undefined,
),
);
const parseEnhancedCall = (
name: ValidatedEnhancedCall["name"],
input: unknown,
): Result<ValidatedEnhancedCall, AnalysisInputError> => {
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<Record<string, JsonValue>>;

View File

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

View File

@ -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<void> }> = [];
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: {