diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 431107ea8a..2faaaca41c 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -177,8 +177,13 @@ if retention is unavailable or fails, the diagnostic reports that reason. SDK-generated input validation failures are normalized into the same canonical `invalid_request` text when the bounded diagnostic fits the response budget. The complete field/type diagnostic is preserved when normalization fits the -response budget, including request-specific rejected fields and reasons. Oversized -SDK diagnostics keep the existing retained-Evidence recovery path. An unknown +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. +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 tool remains an MCP JSON-RPC `-32602` error, but its message directs the caller to `tools/list` and an advertised operation instead of repeating the unknown name. diff --git a/src/domain/inputIssueProjection.test.ts b/src/domain/inputIssueProjection.test.ts index 8dfe406d5e..3ba9fe8aaf 100644 --- a/src/domain/inputIssueProjection.test.ts +++ b/src/domain/inputIssueProjection.test.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { analysisInputErrorFromIssues, projectInputIssues, + projectInputIssuesWithinBudget, } from "./inputIssueProjection.js"; import { processScenarioSchema } from "./process/processScenario.js"; @@ -117,3 +118,75 @@ describe("input issue projection", () => { ); }); }); + +describe("bounded input issue projection", () => { + it("bounds union expansion before constructing field diagnostics", () => { + const schema = z.object({ + value: z.union([ + z.object({ a: z.string(), b: z.number() }), + z.object({ a: z.number(), c: z.string() }), + ]), + }); + const input = { value: { a: false } }; + const parsed = schema.safeParse(input); + if (parsed.success) throw new Error("expected invalid input"); + expect( + projectInputIssuesWithinBudget(parsed.error.issues, input, 32_768), + ).toEqual(projectInputIssues(parsed.error.issues, input)); + expect( + projectInputIssuesWithinBudget(parsed.error.issues, input, 300), + ).toBeUndefined(); + }); + + it("checks a nested path before copying its components", () => { + const input = { value: {} }; + const parsed = z + .object({ + value: z.union([ + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ]), + }) + .safeParse(input); + if (parsed.success) throw new Error("expected invalid input"); + const issue = parsed.error.issues[0]; + if (issue?.code !== "invalid_union") + throw new Error("expected union issue"); + const child = issue.errors[0]?.[0]; + if (child === undefined) throw new Error("expected branch issue"); + let copied = false; + const path = new Proxy( + Array.from({ length: 1000 }, () => "field"), + { + get(target, key, receiver) { + if (key === Symbol.iterator) copied = true; + return Reflect.get(target, key, receiver); + }, + }, + ); + const largePathIssue: z.core.$ZodIssue = { + code: "invalid_union", + path: issue.path, + message: issue.message, + errors: [[{ ...child, path }]], + }; + expect( + projectInputIssuesWithinBudget([largePathIssue], input, 1024), + ).toBeUndefined(); + expect(copied).toBe(false); + }); + + it("accounts for the repeated path on every unknown-key issue", () => { + const issue: z.core.$ZodIssue = { + code: "unrecognized_keys", + path: Array.from({ length: 20 }, () => "nested"), + keys: Array.from({ length: 10 }, (_, index) => `key_${String(index)}`), + message: "Unrecognized keys", + }; + expect(projectInputIssues([issue], {})).toHaveLength(10); + expect(projectInputIssuesWithinBudget([issue], {}, 5000)).toBeUndefined(); + expect(projectInputIssuesWithinBudget([issue], {}, 32_000)).toHaveLength( + 10, + ); + }); +}); diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index a768d819d6..afdf8a4f7b 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -50,10 +50,92 @@ export const projectInputIssues = ( ): readonly AnalysisInputIssue[] => issues.flatMap((issue) => projectIssue(issue, input)); +const projectionBudgetExceeded = Symbol( + "input issue projection budget exceeded", +); + +/** Bound representation expansion before constructing projected diagnostics. */ +export const projectInputIssuesWithinBudget = ( + issues: readonly z.core.$ZodIssue[], + input: unknown, + budgetBytes: number, +): readonly AnalysisInputIssue[] | undefined => { + return projectInputIssueGroupsWithinBudget( + issues, + input, + budgetBytes, + )?.flat(); +}; + +/** Retain each projected issue's source through bounded SDK normalization. */ +export const projectInputIssueGroupsWithinBudget = ( + issues: readonly z.core.$ZodIssue[], + input: unknown, + budgetBytes: number, +): readonly (readonly AnalysisInputIssue[])[] | undefined => { + const budget = { remaining: budgetBytes }; + try { + const groups: (readonly AnalysisInputIssue[])[] = []; + for (const issue of issues) groups.push(projectIssue(issue, input, budget)); + return groups; + } catch (error) { + if (error === projectionBudgetExceeded) return undefined; + throw error; + } +}; + +const chargeProjection = ( + issue: z.core.$ZodIssue, + budget: { remaining: number }, +): void => { + // Six bytes per UTF-16 code unit covers JSON escaping. The fixed allowance + // covers the projected object, property names, scalar values and delimiters. + const charge = (amount: number) => { + budget.remaining -= amount; + if (budget.remaining < 0) throw projectionBudgetExceeded; + }; + charge(256); + let pathCost = 0; + for (const part of issue.path) { + const cost = typeof part === "string" ? 6 * part.length + 16 : 32; + pathCost += cost; + charge(cost); + } + charge(6 * issue.message.length); + if (issue.code === "invalid_union") { + for (const branch of issue.errors) charge(32 + 32 * branch.length); + if ("options" in issue && Array.isArray(issue.options)) + for (const value of issue.options) + charge(typeof value === "string" ? 6 * value.length + 32 : 32); + } + 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); + if (issue.code === "invalid_value") + for (const value of issue.values) + charge(typeof value === "string" ? 6 * value.length + 32 : 32); +}; + +const nestedIssue = ( + parent: z.core.$ZodIssue, + child: z.core.$ZodIssue, + budget: { remaining: number } | undefined, +): z.core.$ZodIssue => { + if (budget !== undefined) { + // The combined path is allocated before the child is projected. Charge + // its references first so a long nested path cannot bypass the allowance. + budget.remaining -= 32 + 8 * (parent.path.length + child.path.length); + if (budget.remaining < 0) throw projectionBudgetExceeded; + } + return { ...child, path: [...parent.path, ...child.path] }; +}; + const projectIssue = ( issue: z.core.$ZodIssue, input: unknown, + budget?: { remaining: number }, ): readonly AnalysisInputIssue[] => { + if (budget !== undefined) chargeProjection(issue, budget); const path = issue.path.flatMap((part) => typeof part === "string" || typeof part === "number" ? [part] : [], ); @@ -78,20 +160,14 @@ const projectIssue = ( return uniqueIssues( issue.errors.flatMap((branch) => branch.flatMap((branchIssue) => - projectIssue( - { ...branchIssue, path: [...issue.path, ...branchIssue.path] }, - input, - ), + projectIssue(nestedIssue(issue, branchIssue, budget), input, budget), ), ), ); } if (issue.code === "invalid_key") return issue.issues.flatMap((keyIssue) => - projectIssue( - { ...keyIssue, path: [...issue.path, ...keyIssue.path] }, - input, - ), + projectIssue(nestedIssue(issue, keyIssue, budget), input, budget), ); if (issue.code === "unrecognized_keys") return issue.keys.map((key) => ({ diff --git a/src/server/EvidenceMcpServer.test.ts b/src/server/EvidenceMcpServer.test.ts index a5b69e279e..ae8f130946 100644 --- a/src/server/EvidenceMcpServer.test.ts +++ b/src/server/EvidenceMcpServer.test.ts @@ -46,13 +46,17 @@ const expectFittingSdkValidationRecovery = (result: { }, details: { operation: "procedure_pseudo_code", - issues: [ - { - path: [], - reason: "invalid_value", - message: expect.stringContaining("required"), - }, - ], + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["required"], + reason: "missing_argument", + expected: "string", + }), + expect.objectContaining({ + path: ["unexpected"], + reason: "unknown_argument", + }), + ]), }, }, }); diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 185bbca158..f514f275a9 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -42,8 +42,11 @@ import type { EvidenceWriter } from "../application/investigation/InvestigationR import { AnalysisInputError, AnalysisResourceConstraintError, + type AnalysisInputIssue, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { projectInputIssueGroupsWithinBudget } from "../domain/inputIssueProjection.js"; +import type { z } from "zod"; import { createEvidence } from "../domain/evidence.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { @@ -60,6 +63,46 @@ import { import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; const currentRequestSignal = new AsyncLocalStorage(); +interface ToolValidationContext { + readonly tool: string; + validation?: { + readonly issues: readonly z.core.$ZodIssue[]; + readonly input: unknown; + }; +} +const currentToolValidation = new AsyncLocalStorage(); + +/** Capture structured issues from the canonical validation the SDK already performs. */ +const captureInputValidation = ( + tool: string, + schema: Schema, +): Schema => { + const standard = schema["~standard"]; + if (standard.vendor !== "zod") return schema; + return { + ...schema, + "~standard": { + ...standard, + validate: (value: unknown) => { + const context = currentToolValidation.getStore(); + const capture = ( + result: Awaited>, + ) => { + if (context?.tool === tool && result.issues !== undefined) + context.validation = { + issues: result.issues as readonly z.core.$ZodIssue[], + input: value, + }; + return result; + }; + const result = standard.validate(value); + return result instanceof Promise + ? result.then(capture) + : capture(result); + }, + }, + } as Schema; +}; /** Signal for the completion request currently being dispatched by the SDK. */ export const currentMcpRequestSignal = (): AbortSignal | undefined => @@ -93,6 +136,73 @@ const sdkInputValidationDiagnostic = ( return diagnostic.length === 0 ? undefined : diagnostic; }; +/** Place an SDK diagnostic at a field only when that same issue produced it. */ +const partitionSdkDiagnostic = ( + validation: ToolValidationContext["validation"], + groups: readonly (readonly AnalysisInputIssue[])[] | undefined, + diagnostic: string, +): + | { issues: readonly AnalysisInputIssue[]; remaining: string | undefined } + | undefined => { + if ( + validation === undefined || + groups === undefined || + validation.issues.length !== groups.length + ) + return undefined; + let offset = 0; + const consume = (text: string): boolean => { + if (!diagnostic.startsWith(text, offset)) return false; + offset += text.length; + return true; + }; + const mapped: AnalysisInputIssue[] = []; + const remaining: string[] = []; + for (let index = 0; index < validation.issues.length; index += 1) { + const raw = validation.issues[index]; + const group = groups[index]; + if (raw === undefined || group === undefined) return undefined; + if (index > 0 && !consume(", ")) return undefined; + const start = offset; + for (let part = 0; part < raw.path.length; part += 1) { + if (part > 0 && !consume(".")) return undefined; + if (!consume(String(raw.path[part]))) return undefined; + } + if (raw.path.length > 0 && !consume(": ")) return undefined; + if (!consume(raw.message)) return undefined; + const issue = group.length === 1 ? group[0] : undefined; + const sameField = + issue !== undefined && + raw.path.length === issue.path.length && + raw.path.every((part, i) => part === issue.path[i]); + const sameMessage = + issue?.message !== undefined && issue.message === raw.message; + const direct = + raw.code !== "invalid_union" && + raw.code !== "invalid_key" && + raw.code !== "unrecognized_keys"; + if ( + sameField && + issue !== undefined && + (sameMessage || (direct && issue.message === undefined)) + ) { + mapped.push( + issue.message === undefined + ? { ...issue, message: raw.message } + : issue, + ); + } else { + for (const issue of group) mapped.push(issue); + remaining.push(diagnostic.slice(start, offset)); + } + } + if (offset !== diagnostic.length) return undefined; + return { + issues: mapped, + remaining: remaining.length === 0 ? undefined : remaining.join(", "), + }; +}; + /** Replace the tool payload after the SDK codec while retaining its envelope. */ const replaceToolResultPayload = ( original: CallToolResult, @@ -295,8 +405,17 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { presentAdvertisedInputSchema(projected, { budgetBytes: budget }), ) : undefined; + const presentedSchema = inputSchema ?? config.inputSchema; const registeredConfig = - inputSchema === undefined ? config : { ...config, inputSchema }; + presentedSchema === undefined + ? config + : { + ...config, + inputSchema: captureInputValidation( + name, + presentedSchema as StandardSchemaWithJSON, + ), + }; const scoped = (input: never, context: never) => this.analysisSettings === undefined ? callback(input, context) @@ -351,6 +470,10 @@ class ErrorEvidenceTransport implements Transport { onmessage: Transport["onmessage"]; readonly hasPerRequestStream?: boolean; private readonly pendingTools = new Map(); + private readonly pendingValidation = new Map< + RequestId, + ToolValidationContext + >(); private readonly pendingToolLists = new Set(); private readonly pendingCompletions = new Map(); @@ -402,12 +525,16 @@ class ErrorEvidenceTransport implements Transport { return; } } + let validation: ToolValidationContext | undefined; if ( isJSONRPCRequest(message) && message.method === "tools/call" && typeof message.params?.["name"] === "string" - ) + ) { this.pendingTools.set(message.id, message.params["name"]); + validation = { tool: message.params["name"] }; + this.pendingValidation.set(message.id, validation); + } if (isJSONRPCRequest(message) && message.method === "tools/list") this.pendingToolLists.add(message.id); if (isJSONRPCRequest(message) && message.method === "completion/complete") @@ -419,19 +546,25 @@ class ErrorEvidenceTransport implements Transport { message.method === "notifications/cancelled" ) { const id = message.params?.["requestId"]; - if (typeof id === "string" || typeof id === "number") + if (typeof id === "string" || typeof id === "number") { + this.pendingValidation.delete(id); forgetPendingMcpRequest( this.pendingTools, this.pendingToolLists, this.pendingCompletions, id, ); + } } const completion = isJSONRPCRequest(message) && message.method === "completion/complete" ? this.pendingCompletions.get(message.id) : undefined; - if (completion === undefined) this.onmessage?.(message, extra); + if (validation !== undefined) + currentToolValidation.run(validation, () => + this.onmessage?.(message, extra), + ); + else if (completion === undefined) this.onmessage?.(message, extra); else currentRequestSignal.run(completion.signal, () => this.onmessage?.(message, extra), @@ -440,6 +573,7 @@ class ErrorEvidenceTransport implements Transport { transport.onerror = (error) => this.onerror?.(error); transport.onclose = () => { this.pendingTools.clear(); + this.pendingValidation.clear(); this.pendingToolLists.clear(); for (const completion of this.pendingCompletions.values()) completion.abort(); @@ -464,6 +598,7 @@ class ErrorEvidenceTransport implements Transport { await this.transport.close(); } finally { this.pendingTools.clear(); + this.pendingValidation.clear(); this.pendingToolLists.clear(); for (const completion of this.pendingCompletions.values()) completion.abort(); @@ -527,6 +662,7 @@ class ErrorEvidenceTransport implements Transport { } else await this.transport.send(advertised, options); } finally { if (!isJSONRPCRequest(message) && id !== undefined) { + this.pendingValidation.delete(id); forgetPendingMcpRequest( this.pendingTools, this.pendingToolLists, @@ -579,7 +715,13 @@ class ErrorEvidenceTransport implements Transport { ? encodeToolResult(structured, budget, "error-text", targetContent) : measureMcpResultWithinBudget(sdkResultWithMarker, budget); const normalized = encoded.ok - ? this.normalizeSdkInputError(message, tool, targetContent, sdkDiagnostic) + ? this.normalizeSdkInputError( + message, + tool, + targetContent, + sdkDiagnostic, + this.pendingValidation.get(message.id)?.validation, + ) : undefined; if ( normalized !== undefined && @@ -742,6 +884,7 @@ class ErrorEvidenceTransport implements Transport { tool: string | undefined, targetContent: boolean, text: string | undefined, + validation: ToolValidationContext["validation"], ): JSONRPCMessage | undefined { if ( tool === undefined || @@ -750,7 +893,6 @@ class ErrorEvidenceTransport implements Transport { !isCallToolResult(message.result) ) return undefined; - let error: AnalysisInputError | AnalysisResourceConstraintError; // The SDK owns counting and refusal before Standard Schema validation. // Recognize its exact wire refusal using our configured fact, without // reparsing the arguments or manufacturing a complete element count. @@ -758,7 +900,7 @@ class ErrorEvidenceTransport implements Transport { text === `Invalid arguments for tool ${tool}: arguments contain more than the maximum of ${String(this.maximumInputElements)} elements` ) { - error = new AnalysisResourceConstraintError( + const error = new AnalysisResourceConstraintError( tool, "transport", "The MCP tool arguments exceed the configured input element allowance; schema validation and tool execution did not start.", @@ -768,17 +910,51 @@ class ErrorEvidenceTransport implements Transport { "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", }, ); - } else { - const diagnostic = sdkInputValidationDiagnostic(text, tool); - if (diagnostic === undefined) return undefined; - error = new AnalysisInputError( - tool, - { - userMessage: `The arguments for MCP tool ${tool} do not match its advertised input schema.`, - }, - [{ path: [], reason: "invalid_value", message: diagnostic }], - ); + const result = this.delivery.toErrorToolResult(error, targetContent); + return { + ...message, + result: replaceToolResultPayload(message.result, result), + }; } + const diagnostic = sdkInputValidationDiagnostic(text, tool); + if (diagnostic === undefined) return undefined; + const groups = + validation === undefined + ? undefined + : projectInputIssueGroupsWithinBudget( + validation.issues, + validation.input, + this.delivery.resultBudgetBytes, + ); + const projectionLimited = validation !== undefined && groups === undefined; + const summary = `The arguments for MCP tool ${tool} do not match its advertised input schema.`; + const partitioned = partitionSdkDiagnostic(validation, groups, diagnostic); + const issues = partitioned?.issues ?? groups?.flat(); + const error = new AnalysisInputError( + tool, + { + userMessage: + issues === undefined + ? projectionLimited + ? `${summary} Field-level issue projection exceeded its response-derived allowance.` + : summary + : partitioned !== undefined + ? partitioned.remaining === undefined + ? summary + : `${summary} ${partitioned.remaining}` + : `${summary} ${diagnostic}`, + }, + issues ?? [ + { + path: [], + reason: "invalid_value", + message: diagnostic, + ...(projectionLimited + ? { observed: { field_details: "omitted" } } + : {}), + }, + ], + ); const result = this.delivery.toErrorToolResult(error, targetContent); return { ...message, diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index c0f08cae21..461503e45f 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -407,10 +407,9 @@ it("rejects invalid tool input before reaching the provider", async () => { operation: "inspect_adb_device", issues: [ { - path: [], - reason: "invalid_value", - message: - "serial: Device serial cannot contain whitespace or quotes", + path: ["serial"], + reason: "invalid_format", + expected: "regex", }, ], }, diff --git a/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts index 59a2274eff..bf6098c6a2 100644 --- a/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts @@ -175,13 +175,7 @@ cliTest.for(["full", "compact"] as const)( recovery: { kind: "inspect_input_contract", operation: tool.name }, details: { operation: tool.name, - issues: [ - { - message: expect.stringContaining( - `${scenario.issue.path.join(".")}: Too big: expected number to be <=2147483647`, - ), - }, - ], + issues: [scenario.issue], }, }); } diff --git a/tests/boundary/mcp/nativeCallObservationMcp.test.ts b/tests/boundary/mcp/nativeCallObservationMcp.test.ts index 12a8f647b7..fe81631688 100644 --- a/tests/boundary/mcp/nativeCallObservationMcp.test.ts +++ b/tests/boundary/mcp/nativeCallObservationMcp.test.ts @@ -156,11 +156,9 @@ it("routes observe_native_calls through MCP with schema-checked input and output details: { issues: [ expect.objectContaining({ - path: [], - reason: "invalid_value", - message: expect.stringMatching( - /environment\.__proto__:.*expected string, received number/u, - ), + path: ["environment", "__proto__"], + reason: "invalid_type", + expected: "string", }), ], }, diff --git a/tests/boundary/mcp/sdkInputIssueAttribution.test.ts b/tests/boundary/mcp/sdkInputIssueAttribution.test.ts new file mode 100644 index 0000000000..e554827518 --- /dev/null +++ b/tests/boundary/mcp/sdkInputIssueAttribution.test.ts @@ -0,0 +1,381 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { createServer } from "../../../src/server/createServer.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +it("keeps concurrent SDK validation issues attached to their own tool call", async () => { + let calls = 0; + const server = createServer({ + kind: "fixed", + analysis: { + execute: () => { + calls += 1; + return Promise.resolve(observed(null)); + }, + }, + }); + const client = new Client({ name: "input-issue-attribution", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const [read, search, missing] = await Promise.all([ + client.callTool({ + name: "read_bytes", + arguments: { address: 123, length: 0, document: false }, + }), + client.callTool({ + name: "search_bytes", + arguments: { pattern: 123, limit: 0, cursor: 23 }, + }), + client.callTool({ name: "read_bytes", arguments: {} }), + ]); + expect(parseMcpToolError(read)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { path: ["address"], reason: "invalid_type", expected: "string" }, + { path: ["length"], reason: "out_of_range", minimum: 1 }, + { path: ["document"], reason: "invalid_type", expected: "string" }, + ], + }, + }, + }); + expect(parseMcpToolError(search)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { path: ["pattern"], reason: "invalid_type", expected: "string" }, + { path: ["limit"], reason: "out_of_range", minimum: 1 }, + { path: ["cursor"], reason: "invalid_type", expected: "string" }, + ], + }, + }, + }); + expect(parseMcpToolError(missing)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: ["address"], + reason: "missing_argument", + expected: "string", + }, + ], + }, + }, + }); + expect(calls).toBe(0); + await expect(client.ping()).resolves.toBeDefined(); + } finally { + await client.close(); + await server.close(); + } +}); + +it("preserves field diagnostics over production stdio and keeps serving", async () => { + const { call, client } = await connectLocalToolsMcp(); + const result = await call("read_bytes", { + address: 123, + length: 0, + document: false, + }); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "invalid_request", + recovery: { kind: "inspect_input_contract", operation: "read_bytes" }, + details: { + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["address"], + reason: "invalid_type", + expected: "string", + }), + expect.objectContaining({ + path: ["length"], + reason: "out_of_range", + minimum: 1, + }), + expect.objectContaining({ + path: ["document"], + reason: "invalid_type", + expected: "string", + }), + ]), + }, + }, + }); + await expect(client.ping()).resolves.toBeDefined(); +}); + +it("retains mixed SDK field diagnostics without repeating custom guidance", async () => { + const info = { name: "custom-issues", version: "1" }; + const server = new EvidenceMcpServer( + info, + { capabilities: {} }, + undefined, + createToolResultDelivery(4096), + ); + const inputSchema = z.strictObject({ + first: z.string().regex(/^a$/u, "first correction"), + second: z.string().regex(/^b$/u, "second correction"), + count: z.number(), + }); + server.registerTool("custom_issues", { inputSchema }, async () => ({ + content: [{ type: "text", text: "unexpected dispatch" }], + })); + const client = new Client(info); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const argumentsValue = { + first: "wrong", + second: "wrong", + count: "wrong", + extra: true, + }; + const parsed = inputSchema.safeParse(argumentsValue); + if (parsed.success) throw new Error("expected invalid input"); + const countIssue = parsed.error.issues.find( + (issue) => issue.path[0] === "count", + ); + if (countIssue === undefined) throw new Error("missing count issue"); + const unknownIssue = parsed.error.issues.find( + (issue) => issue.code === "unrecognized_keys", + ); + if (unknownIssue === undefined) + throw new Error("missing unknown-key issue"); + const result = await client.callTool({ + name: "custom_issues", + arguments: argumentsValue, + }); + const error = parseMcpToolError(result).error; + expect(error.details).toMatchObject({ + issues: [ + { + path: ["first"], + reason: "invalid_format", + message: "first correction", + }, + { + path: ["second"], + reason: "invalid_format", + message: "second correction", + }, + { + path: ["count"], + reason: "invalid_type", + expected: "number", + message: countIssue.message, + }, + { path: ["extra"], reason: "unknown_argument" }, + ], + }); + const text = JSON.stringify(error); + expect(text.split("first correction")).toHaveLength(2); + expect(text.split("second correction")).toHaveLength(2); + expect(error.message.split(unknownIssue.message)).toHaveLength(2); + } finally { + await client.close(); + await server.close(); + } +}); + +it("keeps same-path union and independent issues attributed to their producers", async () => { + const info = { name: "issue-producer", version: "1" }; + const server = new EvidenceMcpServer( + info, + { capabilities: {} }, + undefined, + createToolResultDelivery(4096), + ); + const inputSchema = z.intersection( + z.object({ + a: z.union([z.object({ x: z.string() }), z.object({ x: z.number() })]), + }), + z.object({ a: z.object({ x: z.array(z.string()) }) }), + ); + server.registerTool("overlapping_issues", { inputSchema }, async () => ({ + content: [{ type: "text", text: "unexpected dispatch" }], + })); + const client = new Client(info); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const input = { a: { x: false } }; + const parsed = inputSchema.safeParse(input); + if (parsed.success) throw new Error("expected invalid input"); + const rawArrayIssue = parsed.error.issues.find( + (issue) => issue.code === "invalid_type", + ); + if (rawArrayIssue === undefined) throw new Error("missing array issue"); + const result = await client.callTool({ + name: "overlapping_issues", + arguments: input, + }); + const error = parseMcpToolError(result).error; + const issues = z + .array( + z + .object({ + expected: z.unknown().optional(), + message: z.string().optional(), + }) + .passthrough(), + ) + .parse(error.details?.issues); + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["a", "x"], + reason: "invalid_type", + expected: "array", + message: rawArrayIssue.message, + }), + expect.objectContaining({ + path: ["a", "x"], + reason: "invalid_type", + expected: "string", + }), + expect.objectContaining({ + path: ["a", "x"], + reason: "invalid_type", + expected: "number", + }), + ]), + ); + const stringIssue = issues.find((issue) => issue.expected === "string"); + expect(stringIssue?.message).not.toBe(rawArrayIssue.message); + } finally { + await client.close(); + await server.close(); + } +}); + +it("discloses when bounded projection cannot retain union branch details", async () => { + const info = { name: "bounded-issues", version: "1" }; + const server = new EvidenceMcpServer( + info, + { capabilities: {} }, + undefined, + createToolResultDelivery(4096), + ); + const branches = Array.from({ length: 256 }, (_, index) => + z.object({ [`field_${String(index)}`]: z.string() }), + ); + const first = branches[0]; + const second = branches[1]; + if (first === undefined || second === undefined) + throw new Error("missing branches"); + const inputSchema = z.object({ + value: z.union([first, second, ...branches.slice(2)]), + }); + server.registerTool("bounded_issues", { inputSchema }, async () => ({ + content: [{ type: "text", text: "unexpected dispatch" }], + })); + const sdkServer = new McpServer(info); + sdkServer.registerTool("bounded_issues", { inputSchema }, async () => ({ + content: [{ type: "text", text: "unexpected dispatch" }], + })); + const keySchema = z.strictObject({}); + const unreachable = async () => ({ + content: [{ type: "text" as const, text: "unexpected dispatch" }], + }); + server.registerTool("long_key", { inputSchema: keySchema }, unreachable); + sdkServer.registerTool("long_key", { inputSchema: keySchema }, unreachable); + const client = new Client(info); + const sdkClient = new Client(info); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const [sdkClientTransport, sdkServerTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await sdkServer.connect(sdkServerTransport); + await sdkClient.connect(sdkClientTransport); + const sdkResult = await sdkClient.callTool({ + name: "bounded_issues", + arguments: { value: {} }, + }); + const sdkText = sdkResult.content.find( + (item) => item.type === "text", + )?.text; + const prefix = + "Input validation error: Invalid arguments for tool bounded_issues: "; + expect(sdkText?.startsWith(prefix)).toBe(true); + const diagnostic = sdkText?.slice(prefix.length); + expect(diagnostic).toBeTruthy(); + const result = await client.callTool({ + name: "bounded_issues", + arguments: { value: {} }, + }); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: [], + reason: "invalid_value", + message: diagnostic, + observed: { field_details: "omitted" }, + }, + ], + }, + }, + }); + const argumentsWithLongKey = { [`key_${"x".repeat(1600)}`]: true }; + const sdkLongKey = await sdkClient.callTool({ + name: "long_key", + arguments: argumentsWithLongKey, + }); + const sdkLongText = sdkLongKey.content.find( + (item) => item.type === "text", + )?.text; + const keyPrefix = + "Input validation error: Invalid arguments for tool long_key: "; + expect(sdkLongText?.startsWith(keyPrefix)).toBe(true); + const keyDiagnostic = sdkLongText?.slice(keyPrefix.length); + expect(keyDiagnostic).toBeTruthy(); + const longKey = await client.callTool({ + name: "long_key", + arguments: argumentsWithLongKey, + }); + const longKeyError = parseMcpToolError(longKey).error; + expect(longKeyError).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: [], + reason: "invalid_value", + message: keyDiagnostic, + observed: { field_details: "omitted" }, + }, + ], + }, + }); + expect(longKeyError.message).not.toContain(keyDiagnostic); + await expect(client.ping()).resolves.toBeDefined(); + } finally { + await client.close(); + await server.close(); + await sdkClient.close(); + await sdkServer.close(); + } +}); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 2ee8dd5bd7..74fab27725 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -752,9 +752,9 @@ describe("json depth bounds over MCP", () => { operation: "compare_process_captures", issues: [ { - path: [], + path: ["left", "parameters"], reason: "invalid_value", - message: `left.parameters: JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}. This release has no runtime override.`, + message: expect.stringContaining(String(MAX_JSON_DEPTH)), }, ], }, diff --git a/tests/boundary/process/androidMappingMcp.test.ts b/tests/boundary/process/androidMappingMcp.test.ts index 4e08889b25..1f40065c36 100644 --- a/tests/boundary/process/androidMappingMcp.test.ts +++ b/tests/boundary/process/androidMappingMcp.test.ts @@ -227,7 +227,7 @@ it("annotates class inspection with a caller-selected mapping and reuses one eng it.each([ ["missing", "invalid_request", "invalid_value"], - ["relative", "invalid_request", undefined], + ["relative", "invalid_request", "invalid_format"], ["not UTF-8", "invalid_request", "invalid_format"], ["no class records", "invalid_request", "invalid_format"], ["over budget", "resource_constraint", undefined], @@ -258,9 +258,15 @@ it.each([ }); // The advertised schema refuses a relative path before Android admission. if (scenario === "relative") - expect(JSON.stringify(error.details)).toContain( - "mapping_path: must be an absolute local filesystem path", - ); + expect(error.details).toMatchObject({ + issues: [ + { + path: ["mapping_path"], + reason: "invalid_format", + expected: "regex", + }, + ], + }); if (scenario === "not UTF-8" || scenario === "no class records") expect(JSON.stringify(error)).toContain( createHash("sha256")