fix(mcp): preserve input validation issue attribution (#7981)

* fix(mcp): preserve input validation issue attribution

* fix(mcp): charge nested issue projection before allocation

* style(mcp): format server declarations

* fix(mcp): avoid duplicate fallback diagnostics

* test(mcp): assert structured validation issues

* test(mcp): align browser input issue assertions

* fix(mcp): retain SDK diagnostic parts once

* style(mcp): format diagnostic projection

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: morluto <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-29 15:39:10 +08:00
1 parent f25d1e3e9f
commit 17ceb77ca5
11 files changed
+768 -56

No files matched your search

+7 -2
View File
@@ -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.
+73
View File
@@ -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,
);
});
});
+84 -8
View File
@@ -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) => ({
+11 -7
View File
@@ -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",
}),
]),
},
},
});
+193 -17
View File
@@ -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<AbortSignal>();
interface ToolValidationContext {
readonly tool: string;
validation?: {
readonly issues: readonly z.core.$ZodIssue[];
readonly input: unknown;
};
}
const currentToolValidation = new AsyncLocalStorage<ToolValidationContext>();
/** Capture structured issues from the canonical validation the SDK already performs. */
const captureInputValidation = <Schema extends StandardSchemaWithJSON>(
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<ReturnType<typeof standard.validate>>,
) => {
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<RequestId, string>();
private readonly pendingValidation = new Map<
RequestId,
ToolValidationContext
>();
private readonly pendingToolLists = new Set<RequestId>();
private readonly pendingCompletions = new Map<RequestId, AbortController>();
@@ -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,
+3 -4
View File
@@ -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",
},
],
},
@@ -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],
},
});
}
@@ -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",
}),
],
},
@@ -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();
}
});
+2 -2
View File
@@ -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)),
},
],
},
@@ -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")