diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 1d50599359..fccae5cb79 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -188,7 +188,11 @@ The complete field/type diagnostic is preserved when normalization fits the response budget, including request-specific rejected fields and reasons. Zod input issues retain their field paths and available type and range constraints. SDK diagnostic parts may be distributed between field issues and the main -message so each retained explanation appears once. +message so each retained explanation appears once. Known neighboring-tool +argument-name mistakes (such as `address` instead of `procedure`, or `query` +instead of `pattern`) provide targeted replacement guidance and the expected +canonical property name without accepting unauthorized aliases or changing +canonical Evidence parameters. If their conservative projection allowance is exhausted, the response states that field-level detail was omitted and retains the original SDK diagnostic. Oversized SDK diagnostics keep the existing retained-Evidence recovery path. An unknown diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 2285303699..6378be7615 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -1,4 +1,5 @@ import { describeResidualUnknownGraphConstraint } from "./residualUnknownGraphError.js"; +import { knownArgumentMistakeRemediation } from "./inputArgumentGuidance.js"; import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, @@ -214,11 +215,18 @@ export const analysisErrorRemediationAction = ( ? `Correct ${[...new Set(error.settings.map(({ setting }) => setting))].join(", ")} in the environment that starts REA (your shell or the MCP client's registration), then rerun.` : analysisErrorUserMessage(error)) ); - if (error instanceof AnalysisInputError) - return ( - error.remediationAction ?? - `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.` + if (error instanceof AnalysisInputError) { + const mistakeRemediation = knownArgumentMistakeRemediation( + error.operation, + error.issues, ); + const baseAction = + error.remediationAction ?? + `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`; + return mistakeRemediation === undefined + ? baseAction + : `${mistakeRemediation} ${baseAction}`; + } if ( error instanceof AnalysisOutputError && error.remediationAction !== undefined diff --git a/src/domain/inputArgumentGuidance.ts b/src/domain/inputArgumentGuidance.ts new file mode 100644 index 0000000000..770c0b5d06 --- /dev/null +++ b/src/domain/inputArgumentGuidance.ts @@ -0,0 +1,125 @@ +import type { AnalysisInputIssue } from "./analysisErrorCore.js"; + +/** Deterministic correction metadata for a known neighboring-tool argument name mistake. */ +export interface ArgumentMistakeGuidance { + readonly expected: string; + readonly message: string; + readonly remediation: string; +} + +const PROCEDURE_OPERATIONS: ReadonlySet = new Set([ + "analyze_function", + "annotate_native_function", + "inspect_native_api", + "procedure_address", + "procedure_assembly", + "procedure_callees", + "procedure_callers", + "procedure_info", + "procedure_pseudo_code", + "procedure_references", + "read_function_instructions", + "trace_native_values", +]); + +const ADDRESS_OPERATIONS: ReadonlySet = new Set([ + "address_name", + "address_to_file_offset", + "comment", + "get_call_graph", + "goto_address", + "inline_comment", + "inspect_address_context", + "inspect_native_instruction", + "next_address", + "prev_address", + "read_bytes", + "resolve_containing_procedure", + "resolve_native_call_targets", + "set_address_name", + "set_bookmark", + "set_comment", + "set_inline_comment", + "unset_bookmark", + "xrefs", +]); + +const PATTERN_OPERATIONS: ReadonlySet = new Set([ + "analyze_swift_types", + "get_objc_classes", + "search_bytes", + "search_procedures", + "search_strings", +]); + +const QUERY_OPERATIONS: ReadonlySet = new Set([ + "search_guidance", + "trace_feature", +]); + +const PROCEDURE_INSTEAD_OF_ADDRESS: ArgumentMistakeGuidance = { + expected: "procedure", + message: "Unrecognized argument 'address'; did you mean 'procedure'?", + remediation: + "Use 'procedure' (a procedure name or address) instead of 'address'.", +}; + +const ADDRESS_INSTEAD_OF_PROCEDURE: ArgumentMistakeGuidance = { + expected: "address", + message: "Unrecognized argument 'procedure'; did you mean 'address'?", + remediation: "Use 'address' instead of 'procedure'.", +}; + +const PATTERN_INSTEAD_OF_QUERY: ArgumentMistakeGuidance = { + expected: "pattern", + message: "Unrecognized argument 'query'; did you mean 'pattern'?", + remediation: "Use 'pattern' instead of 'query'.", +}; + +const QUERY_INSTEAD_OF_PATTERN: ArgumentMistakeGuidance = { + expected: "query", + message: "Unrecognized argument 'pattern'; did you mean 'query'?", + remediation: "Use 'query' instead of 'pattern'.", +}; + +/** Lookup known neighboring-tool argument-name confusion for one top-level key. */ +export const knownArgumentMistakeGuidance = ( + operation: string | undefined, + argumentName: string, +): ArgumentMistakeGuidance | undefined => { + if (operation === undefined) return undefined; + if (argumentName === "address" && PROCEDURE_OPERATIONS.has(operation)) + return PROCEDURE_INSTEAD_OF_ADDRESS; + if (argumentName === "procedure" && ADDRESS_OPERATIONS.has(operation)) + return ADDRESS_INSTEAD_OF_PROCEDURE; + if (argumentName === "query" && PATTERN_OPERATIONS.has(operation)) + return PATTERN_INSTEAD_OF_QUERY; + if (argumentName === "pattern" && QUERY_OPERATIONS.has(operation)) + return QUERY_INSTEAD_OF_PATTERN; + return undefined; +}; + +/** Combine distinct argument-name replacement hints for an operation's rejected issues. */ +export const knownArgumentMistakeRemediation = ( + operation: string, + issues: readonly AnalysisInputIssue[], +): string | undefined => { + const hints: string[] = []; + const seen = new Set(); + for (const issue of issues) { + if (issue.reason !== "unknown_argument" || issue.path.length !== 1) + continue; + const key = issue.path[0]; + if (typeof key !== "string") continue; + const guidance = knownArgumentMistakeGuidance(operation, key); + if ( + guidance !== undefined && + issue.expected === guidance.expected && + !seen.has(guidance.remediation) + ) { + seen.add(guidance.remediation); + hints.push(guidance.remediation); + } + } + return hints.length === 0 ? undefined : hints.join(" "); +}; diff --git a/src/domain/inputIssueProjection.test.ts b/src/domain/inputIssueProjection.test.ts index 3ba9fe8aaf..5e2385cc4d 100644 --- a/src/domain/inputIssueProjection.test.ts +++ b/src/domain/inputIssueProjection.test.ts @@ -6,7 +6,39 @@ import { projectInputIssues, projectInputIssuesWithinBudget, } from "./inputIssueProjection.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; import { processScenarioSchema } from "./process/processScenario.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js"; +import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js"; +import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js"; + +const officialContract = ( + name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing official contract: ${name}`); + return contract; +}; + +const applicationContract = ( + name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = APPLICATION_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing application contract: ${name}`); + return contract; +}; + +const enhancedContract = ( + name: (typeof ENHANCED_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = ENHANCED_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing enhanced contract: ${name}`); + return contract; +}; describe("input issue projection", () => { it("builds application input errors with projected and semantic issues", () => { @@ -119,6 +151,214 @@ describe("input issue projection", () => { }); }); +describe("input issue argument guidance", () => { + it("guides callers from neighboring-tool argument names while keeping unknown keys strict", () => { + const input = { address: "0x401000", count: 10 }; + for (const op of [ + "read_function_instructions", + "procedure_pseudo_code", + "procedure_assembly", + ] as const) { + const guidance = knownArgumentMistakeGuidance(op, "address"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(input); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + input, + ); + expect(error.issues).toEqual([ + { path: ["procedure"], reason: "missing_argument", expected: "string" }, + { + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }, + { path: ["count"], reason: "unknown_argument" }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + + const searchInput = { query: "flag" }; + for (const op of ["search_strings", "search_procedures"] as const) { + const guidance = knownArgumentMistakeGuidance(op, "query"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(searchInput); + if (parsed.success) throw new Error("expected invalid input"); + const searchError = analysisInputErrorFromIssues( + op, + parsed.error.issues, + searchInput, + ); + expect(searchError.issues).toEqual([ + { path: ["pattern"], reason: "missing_argument", expected: "string" }, + { + path: ["query"], + reason: "unknown_argument", + expected: "pattern", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(searchError).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + }); + + it("guides callers for symmetric neighboring-tool argument names", () => { + const addressInput = { procedure: "main" }; + for (const op of ["resolve_containing_procedure", "read_bytes"] as const) { + const guidance = knownArgumentMistakeGuidance(op, "procedure"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(addressInput); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + addressInput, + ); + expect(error.issues).toEqual([ + { path: ["address"], reason: "missing_argument", expected: "string" }, + { + path: ["procedure"], + reason: "unknown_argument", + expected: "address", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + + const xrefsGuidance = knownArgumentMistakeGuidance("xrefs", "procedure"); + expect(xrefsGuidance).toBeDefined(); + const xrefsParsed = + officialContract("xrefs").inputSchema.safeParse(addressInput); + if (xrefsParsed.success) throw new Error("expected invalid input"); + const xrefsError = analysisInputErrorFromIssues( + "xrefs", + xrefsParsed.error.issues, + addressInput, + ); + expect(xrefsError.issues).toEqual([ + { + path: ["procedure"], + reason: "unknown_argument", + expected: "address", + message: xrefsGuidance!.message, + }, + ]); + const xrefsAction = projectAnalysisError(xrefsError).remediation.action; + expect(xrefsAction.startsWith(xrefsGuidance!.remediation)).toBe(true); + expect(xrefsAction).toContain("xrefs"); + + const queryInput = { pattern: "flag" }; + const queryCases = [ + { + op: "search_guidance" as const, + contract: applicationContract("search_guidance"), + }, + { + op: "trace_feature" as const, + contract: enhancedContract("trace_feature"), + }, + ]; + for (const { op, contract } of queryCases) { + const guidance = knownArgumentMistakeGuidance(op, "pattern"); + expect(guidance).toBeDefined(); + const parsed = contract.inputSchema.safeParse(queryInput); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + queryInput, + ); + expect(error.issues).toEqual([ + { path: ["query"], reason: "missing_argument", expected: "string" }, + { + path: ["pattern"], + reason: "unknown_argument", + expected: "query", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + }); +}); + +describe("input issue argument guidance suppression", () => { + it("suppresses replacement guidance when expected argument is already present", () => { + const inputWithBoth = { + procedure: "main", + address: "0x401000", + comment: "entry comment", + }; + const parsed = officialContract( + "annotate_native_function", + ).inputSchema.safeParse(inputWithBoth); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + "annotate_native_function", + parsed.error.issues, + inputWithBoth, + ); + expect(error.issues).toEqual([ + { path: ["address"], reason: "unknown_argument" }, + ]); + const action = projectAnalysisError(error).remediation.action; + const suppressedGuidance = knownArgumentMistakeGuidance( + "annotate_native_function", + "address", + ); + expect(suppressedGuidance).toBeDefined(); + expect(action).not.toContain(suppressedGuidance!.remediation); + expect(action).toContain("annotate_native_function"); + + const budgeted = projectInputIssuesWithinBudget( + parsed.error.issues, + inputWithBoth, + 1024, + "annotate_native_function", + ); + expect(budgeted).toEqual(error.issues); + + const inputMissingExpected = { + address: "0x401000", + comment: "entry comment", + }; + const parsedMissing = officialContract( + "annotate_native_function", + ).inputSchema.safeParse(inputMissingExpected); + if (parsedMissing.success) throw new Error("expected invalid input"); + const errorMissing = analysisInputErrorFromIssues( + "annotate_native_function", + parsedMissing.error.issues, + inputMissingExpected, + ); + const missingGuidance = knownArgumentMistakeGuidance( + "annotate_native_function", + "address", + ); + expect(missingGuidance).toBeDefined(); + expect(errorMissing.issues).toContainEqual({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: missingGuidance!.message, + }); + const missingAction = projectAnalysisError(errorMissing).remediation.action; + expect(missingAction.startsWith(missingGuidance!.remediation)).toBe(true); + }); +}); + describe("bounded input issue projection", () => { it("bounds union expansion before constructing field diagnostics", () => { const schema = z.object({ @@ -189,4 +429,46 @@ describe("bounded input issue projection", () => { 10, ); }); + + it("bounds pre-allocation budget accounting for guided unrecognized keys", () => { + const issue: z.core.$ZodIssue = { + code: "unrecognized_keys", + path: [], + keys: ["address"], + message: "Unrecognized keys: address", + }; + expect(projectInputIssuesWithinBudget([issue], {}, 800)).toEqual([ + { path: ["address"], reason: "unknown_argument" }, + ]); + + expect( + projectInputIssuesWithinBudget( + [issue], + {}, + 800, + "read_function_instructions", + ), + ).toBeUndefined(); + + const guidance = knownArgumentMistakeGuidance( + "read_function_instructions", + "address", + ); + expect(guidance).toBeDefined(); + expect( + projectInputIssuesWithinBudget( + [issue], + {}, + 2000, + "read_function_instructions", + ), + ).toEqual([ + { + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }, + ]); + }); }); diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index 657b256e73..f7aed8b34e 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -3,6 +3,10 @@ import { AnalysisInputError, type AnalysisInputIssue, } from "./analysisErrorCore.js"; +import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js"; + +const isRootArgument = (path: readonly unknown[]): boolean => + path.length === 0 || (path.length === 1 && path[0] === "input"); /** Build the standard application input error while retaining schema causes and extra semantic issues. */ export const analysisInputErrorFromIssues = ( @@ -23,7 +27,10 @@ export const analysisInputErrorFromIssues = ( ? {} : { userMessage: options.userMessage }), }, - [...projectInputIssues(issues, input), ...(options.additionalIssues ?? [])], + [ + ...projectInputIssues(issues, input, operation), + ...(options.additionalIssues ?? []), + ], ); /** Remove only the internal operation/input envelope from caller correction paths. */ @@ -36,7 +43,7 @@ export const analysisOperationInputErrorFromIssues = ( new AnalysisInputError( operation, { cause }, - projectInputIssues(issues, { operation, input }).map((issue) => + projectInputIssues(issues, { operation, input }, operation).map((issue) => issue.path[0] === "input" ? { ...issue, path: issue.path.slice(1) } : issue, @@ -47,8 +54,9 @@ export const analysisOperationInputErrorFromIssues = ( export const projectInputIssues = ( issues: readonly z.core.$ZodIssue[], input: unknown, + operation?: string, ): readonly AnalysisInputIssue[] => - issues.flatMap((issue) => projectIssue(issue, input)); + issues.flatMap((issue) => projectIssue(issue, input, undefined, operation)); const projectionBudgetExceeded = Symbol( "input issue projection budget exceeded", @@ -59,11 +67,13 @@ export const projectInputIssuesWithinBudget = ( issues: readonly z.core.$ZodIssue[], input: unknown, budgetBytes: number, + operation?: string, ): readonly AnalysisInputIssue[] | undefined => { return projectInputIssueGroupsWithinBudget( issues, input, budgetBytes, + operation, )?.flat(); }; @@ -128,11 +138,13 @@ export const projectInputIssueGroupsWithinBudget = ( issues: readonly z.core.$ZodIssue[], input: unknown, budgetBytes: number, + operation?: string, ): readonly (readonly AnalysisInputIssue[])[] | undefined => { const budget = { remaining: budgetBytes }; try { const groups: (readonly AnalysisInputIssue[])[] = []; - for (const issue of issues) groups.push(projectIssue(issue, input, budget)); + for (const issue of issues) + groups.push(projectIssue(issue, input, budget, operation)); return groups; } catch (error) { if (error === projectionBudgetExceeded) return undefined; @@ -143,6 +155,8 @@ export const projectInputIssueGroupsWithinBudget = ( const chargeProjection = ( issue: z.core.$ZodIssue, budget: { remaining: number }, + operation?: string, + input?: unknown, ): void => { // Six bytes per UTF-16 code unit covers JSON escaping. The fixed allowance // covers the projected object, property names, scalar values and delimiters. @@ -166,7 +180,19 @@ const chargeProjection = ( } if (issue.code === "invalid_key") charge(32 * issue.issues.length); if (issue.code === "unrecognized_keys") - for (const key of issue.keys) charge(256 + pathCost + 6 * key.length); + for (const key of issue.keys) { + charge(256 + pathCost + 6 * key.length); + if (isRootArgument(issue.path)) { + const guidance = knownArgumentMistakeGuidance(operation, key); + const expectedAlreadyPresent = + guidance !== undefined && + valueAtPath(input, [...issue.path, guidance.expected]) !== undefined; + if (guidance !== undefined && !expectedAlreadyPresent) + charge( + 6 * guidance.expected.length + 6 * guidance.message.length + 32, + ); + } + } if (issue.code === "invalid_value") for (const value of issue.values) charge(typeof value === "string" ? 6 * value.length + 32 : 32); @@ -186,12 +212,39 @@ const nestedIssue = ( return { ...child, path: [...parent.path, ...child.path] }; }; +const projectUnrecognizedKeyIssues = ( + keys: readonly string[], + path: readonly (string | number)[], + input: unknown, + operation?: string, +): readonly AnalysisInputIssue[] => + keys.map((key) => { + const guidance = isRootArgument(path) + ? knownArgumentMistakeGuidance(operation, key) + : undefined; + const expectedAlreadyPresent = + guidance !== undefined && + valueAtPath(input, [...path, guidance.expected]) !== undefined; + const effectiveGuidance = expectedAlreadyPresent ? undefined : guidance; + return { + path: [...path, key], + reason: "unknown_argument" as const, + ...(effectiveGuidance === undefined + ? {} + : { + expected: effectiveGuidance.expected, + message: effectiveGuidance.message, + }), + }; + }); + const projectIssue = ( issue: z.core.$ZodIssue, input: unknown, budget?: { remaining: number }, + operation?: string, ): readonly AnalysisInputIssue[] => { - if (budget !== undefined) chargeProjection(issue, budget); + if (budget !== undefined) chargeProjection(issue, budget, operation, input); const path = issue.path.flatMap((part) => typeof part === "string" || typeof part === "number" ? [part] : [], ); @@ -216,20 +269,27 @@ const projectIssue = ( return uniqueIssues( issue.errors.flatMap((branch) => branch.flatMap((branchIssue) => - projectIssue(nestedIssue(issue, branchIssue, budget), input, budget), + projectIssue( + nestedIssue(issue, branchIssue, budget), + input, + budget, + operation, + ), ), ), ); } if (issue.code === "invalid_key") return issue.issues.flatMap((keyIssue) => - projectIssue(nestedIssue(issue, keyIssue, budget), input, budget), + projectIssue( + nestedIssue(issue, keyIssue, budget), + input, + budget, + operation, + ), ); if (issue.code === "unrecognized_keys") - return issue.keys.map((key) => ({ - path: [...path, key], - reason: "unknown_argument" as const, - })); + return projectUnrecognizedKeyIssues(issue.keys, path, input, operation); if (issue.code === "invalid_type") return [ { @@ -279,10 +339,7 @@ const uniqueIssues = ( ...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(), ]; -const valueAtPath = ( - input: unknown, - path: readonly (string | number)[], -): unknown => { +const valueAtPath = (input: unknown, path: readonly PropertyKey[]): unknown => { let current = input; for (const part of path) { if (typeof current !== "object" || current === null) return undefined; diff --git a/src/ida/IdaInput.ts b/src/ida/IdaInput.ts index 994d744c9a..832b80c9cc 100644 --- a/src/ida/IdaInput.ts +++ b/src/ida/IdaInput.ts @@ -3,7 +3,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; import { idaAddressSchema } from "./IdaProtocolValues.js"; -import { projectInputIssues } from "../domain/inputIssueProjection.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; /** Apply the named public contract and IDA's explicit-address boundary before startup. */ export const parseIdaInput = ( @@ -14,12 +14,11 @@ export const parseIdaInput = ( const parsed = contract?.inputSchema.safeParse(parameters); if (parsed?.success !== true) return err( - new AnalysisInputError( + analysisInputErrorFromIssues( operation, + parsed?.error.issues ?? [], + parameters, { cause: parsed?.error }, - parsed?.error === undefined - ? [] - : projectInputIssues(parsed.error.issues, parameters), ), ); if (operation === "xrefs" && typeof parameters.address !== "string") diff --git a/src/server/EvidenceMcpServer.test.ts b/src/server/EvidenceMcpServer.test.ts index ae8f130946..eaae922d1d 100644 --- a/src/server/EvidenceMcpServer.test.ts +++ b/src/server/EvidenceMcpServer.test.ts @@ -13,6 +13,8 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { knownArgumentMistakeGuidance } from "../domain/inputArgumentGuidance.js"; import { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { forgetPendingMcpRequest } from "./EvidenceMcpServerCore.js"; import { createToolResultDelivery } from "./toolResult.js"; @@ -368,3 +370,152 @@ describe("SDK validation error delivery", () => { }, ); }); + +const createGuidanceClientServer = async ( + toolName: string, + inputSchema: z.ZodTypeAny, +) => { + const delivery = createToolResultDelivery(4096); + const records = new InvestigationRecords(); + const server = new EvidenceMcpServer( + { name: "sdk-argument-guidance-test", version: "1" }, + { capabilities: {} }, + (evidence) => records.recordEvidence(evidence), + delivery, + ); + server.registerTool(toolName, { inputSchema }, async () => ({ + content: [{ type: "text", text: "valid call" }], + })); + const client = new Client( + { name: "sdk-argument-guidance-client", version: "1" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const serving = serveStdio(() => server, { transport: serverTransport }); + await client.connect(clientTransport); + return { + client, + close: () => Promise.all([client.close(), serving.close()]), + }; +}; + +const officialContract = ( + name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing official contract: ${name}`); + return contract; +}; + +describe("SDK argument name guidance", () => { + it("guides procedure_pseudo_code caller from address to procedure without duplicating diagnostic", async () => { + const guidance = knownArgumentMistakeGuidance( + "procedure_pseudo_code", + "address", + ); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "procedure_pseudo_code", + officialContract("procedure_pseudo_code").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "procedure_pseudo_code", + arguments: { address: "0x401000" }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }), + ); + expect( + payload.error.remediation.action.startsWith(guidance!.remediation), + ).toBe(true); + expect(payload.error.message).not.toContain("address"); + } finally { + await session.close(); + } + }); + + it("guides search_strings caller from query to pattern", async () => { + const guidance = knownArgumentMistakeGuidance("search_strings", "query"); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "search_strings", + officialContract("search_strings").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "search_strings", + arguments: { query: "admin" }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["query"], + reason: "unknown_argument", + expected: "pattern", + message: guidance!.message, + }), + ); + expect( + payload.error.remediation.action.startsWith(guidance!.remediation), + ).toBe(true); + } finally { + await session.close(); + } + }); + + it("rejects address with procedure guidance and retains unexplained key count in diagnostics", async () => { + const guidance = knownArgumentMistakeGuidance( + "read_function_instructions", + "address", + ); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "read_function_instructions", + officialContract("read_function_instructions").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "read_function_instructions", + arguments: { address: "0x401000", count: 10 }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }), + ); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["count"], + reason: "unknown_argument", + }), + ); + const countIssue = payload.error.details.issues.find( + (issue: { path: string[] }) => issue.path?.[0] === "count", + ); + expect(countIssue?.expected).toBeUndefined(); + expect(countIssue?.message).toBeUndefined(); + expect(payload.error.message).toContain("count"); + } finally { + await session.close(); + } + }); +}); diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 2d451eb78b..40bacd7c6b 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -194,7 +194,11 @@ const partitionSdkDiagnostic = ( ); } else { for (const issue of group) mapped.push(issue); - remaining.push(diagnostic.slice(start, offset)); + const explainedByIssues = + raw.code === "unrecognized_keys" && + group.length > 0 && + group.every((item) => item.message !== undefined); + if (!explainedByIssues) remaining.push(diagnostic.slice(start, offset)); } } if (offset !== diagnostic.length) return undefined; @@ -1048,6 +1052,7 @@ class ErrorEvidenceTransport implements Transport { validation.issues, validation.input, this.delivery.resultBudgetBytes, + tool, ); const projectionLimited = validation !== undefined && groups === undefined; const summary = `The arguments for MCP tool ${tool} do not match its advertised input schema.`;