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:
11 files changed
+768
-56
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user