You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Schema validation reuses the first schema registered under a $id: a changed outputSchema or a second tool with the same $id is validated against the wrong schema #2935
Operating system (and browser, for the web client)
Linux (Debian)
Transport
Not applicable / never connected
MCP server under inspection
Any server whose tool outputSchema (or an elicitation requestedSchema) carries a $id. Reproduced directly against LinearPatternJsonSchemaValidator, which InspectorClient shares for the client's lifetime.
Steps to reproduce
getValidator does engine.getSchema(id) ?? engine.compile(schema) whenever the schema has a string $id, so whatever was first registered under that $id wins.
Case 1: tool output schema changes (e.g. after notifications/tools/list_changed) but keeps its $id:
constv=newLinearPatternJsonSchemaValidator();constbefore={$id: "urn:example:weather-output",type: "object",properties: {temp: {type: "number"}},required: ["temp"]};constafter={$id: "urn:example:weather-output",type: "object",properties: {temp: {type: "string",pattern: "^-?\\d+C$"}},required: ["temp"]};v.getValidator(before)({temp: 21});v.getValidator(after)({temp: "21C"});// conforms to the new schemav.getValidator(after)({temp: 21});// violates the new schema
Case 2: two tools declare the same $id (e.g. a generic "output"):
Each call validates against the schema passed in: case 1 accepts "21C" and rejects 21; case 2 accepts { b: 1 }.
Actual behavior
Validated against the first schema registered under the $id:
case 1: { temp: "21C" } → data/temp must be number (false failure); { temp: 21 } → valid (false pass)
case 2: { b: 1 } → data must have required property 'a'
For an inspector, both directions matter: a server developer who fixes an outputSchema keeps seeing results checked against the old one until the client is recreated, and a non-conforming result passes.
Logs, errors, or screenshots
The same getSchema($id) ?? compile shape is in the SDK's AjvJsonSchemaValidator.getValidator (@modelcontextprotocol/client ajvProvider), which the Inspector used directly before 2.14, so this likely predates 2.14 and was carried into the 2.14 copy. Related SDK issue on the no-$id path: modelcontextprotocol/typescript-sdk#2605.
Already prototyped a fix?
Not a PR. Possible fix: only reuse getSchema(id) when the registered schema is deep-equal to the one passed in; otherwise removeSchema(id) and compile the new one (or key reuse on a stable hash of the schema rather than $id).
Before you submit
I searched existing issues and this is not a duplicate.
This is not a security vulnerability report (those go through the private advisory process).
Severity 4 — the Inspector reports something false about the server under test: a result is checked against a stale or different schema that shares its $id, so it can fail when it is valid or pass when it is not
Urgency 2 — wanted eventually, nothing blocked on it. It likely predates 2.14 (the same shape is in the SDK provider).
Bonuses: +1 bug label
Board: #28, Status Incoming (awaiting maintainer review — no milestone yet).
I prototyped a fix for this locally and, per CONTRIBUTING, am sharing the prompt, the before/after behaviour and how I verified it rather than opening a PR.
Prompt used
In core/mcp/linearPatternValidator.ts, getValidator does engine.getSchema(id) ?? engine.compile(schema) when the schema has a string $id, so a different schema with the same $id is validated against whatever was registered first (see #2935). Fix it so the validator returned always corresponds to the schema object passed in: reuse the registered Ajv validator only when its .schema is the very same object (Ajv's compile already caches by object identity), otherwise removeSchema($id) and compile the new schema, so Ajv's "schema with key or id already exists" error is not raised. Keep the existing behaviour for a $defs entry that Ajv compiles on first lookup by $id, keep the compile budget accounting inside tracked(), and update the explanatory comment. Add tests in clients/web/src/test/core/mcp/linearPatternValidator.test.ts for: a changed schema that kept its $id (both the false failure and the false pass from the issue), two schemas declaring the same $id including alternating between them, and a $id registered by an earlier schema's $defs still being reusable and replaceable without breaking the already compiled outer schema.
Before: case 1 from the issue reports data/temp must be number for { temp: "21C" } and accepts { temp: 21 }; case 2 reports data must have required property 'a' for { b: 1 }. After: each call validates against the schema it was given: { temp: "21C" } is valid, { temp: 21 } fails with data/temp must be string, { b: 1 } is valid, and alternating between two tools sharing an $id keeps working (no "already exists" throw). The same schema object is still reused without recompiling, so the budget-tracking tests are unchanged.
Verification: the three new tests fail on v2/main (3b387d3) and pass with the change; vitest run --project=unit src/test/core/mcp/linearPatternValidator.test.ts is 28/28, prettier and eslint are clean for both files, and linearPatternValidator.ts stays at 100% on all four coverage dimensions. The change is about 15 lines in core/mcp/linearPatternValidator.ts plus ~65 lines of tests; for reference it is on my fork as v2/fix/2935-schema-id-reuse (https://github.com/Rutvij97/inspector/tree/v2/fix/2935-schema-id-reuse), signed off.
This hits a very common pattern with MCP servers built on TypeBox, Zod-to-JSON-Schema, or Pydantic where multiple tools share a generic top-level $id (such as "$id": "output" or "$id": "Response") or where a server hot-reloads tool schemas via notifications/tools/list_changed without bumping $id.
Because Ajv caches compiled validators in its global schema map keyed by schema.$id, calling engine.getSchema(id) ?? engine.compile(schema) has two failure modes:
If getSchema(id) is returned unconditionally, any second tool sharing that $id (or an updated outputSchema after list_changed) silently validates against the first tool's schema.
Even if you bypass getSchema(id) and call engine.compile(schema) directly, Ajv throws Error: schema with key or id "..." already exists unless the old $id is removed first (engine.removeSchema(id)) or the top-level $id is stripped from a shallow clone before compilation.
Since MCP tool validation in LinearPatternJsonSchemaValidator is always self-contained per tool (internal references use #/$defs/... rather than cross-document $id resolution), stripping top-level $id on a shallow copy before engine.compile (and keying any validator cache by a canonical JSON string / hash of the schema) avoids both stale cache hits and Ajv duplicate-$id exceptions.
(Relatedly, in mcp-slim we strip "$id" and "$schema" from both inputSchema and outputSchema on tools/list specifically to prevent client-side Ajv $id collisions while trimming schema token overhead.)
Activity
fix(sse): preserve custom Authorization and session headers across SS…
cliffhall commentedon Nov 10, 2026
Triage: Priority Medium (total 7)
$id, so it can fail when it is valid or pass when it is notbuglabelBoard: #28, Status
Incoming(awaiting maintainer review — no milestone yet).Rutvij97 commentedon Nov 10, 2026
I prototyped a fix for this locally and, per CONTRIBUTING, am sharing the prompt, the before/after behaviour and how I verified it rather than opening a PR.
Prompt used
Before: case 1 from the issue reports
data/temp must be numberfor{ temp: "21C" }and accepts{ temp: 21 }; case 2 reportsdata must have required property 'a'for{ b: 1 }.After: each call validates against the schema it was given:
{ temp: "21C" }is valid,{ temp: 21 }fails withdata/temp must be string,{ b: 1 }is valid, and alternating between two tools sharing an$idkeeps working (no "already exists" throw). The same schema object is still reused without recompiling, so the budget-tracking tests are unchanged.Verification: the three new tests fail on
v2/main(3b387d3) and pass with the change;vitest run --project=unit src/test/core/mcp/linearPatternValidator.test.tsis 28/28, prettier and eslint are clean for both files, andlinearPatternValidator.tsstays at 100% on all four coverage dimensions. The change is about 15 lines incore/mcp/linearPatternValidator.tsplus ~65 lines of tests; for reference it is on my fork asv2/fix/2935-schema-id-reuse(https://github.com/Rutvij97/inspector/tree/v2/fix/2935-schema-id-reuse), signed off.SalyyS1 commentedon Nov 11, 2026
This hits a very common pattern with MCP servers built on TypeBox, Zod-to-JSON-Schema, or Pydantic where multiple tools share a generic top-level
$id(such as"$id": "output"or"$id": "Response") or where a server hot-reloads tool schemas vianotifications/tools/list_changedwithout bumping$id.Because Ajv caches compiled validators in its global schema map keyed by
schema.$id, callingengine.getSchema(id) ?? engine.compile(schema)has two failure modes:getSchema(id)is returned unconditionally, any second tool sharing that$id(or an updatedoutputSchemaafterlist_changed) silently validates against the first tool's schema.getSchema(id)and callengine.compile(schema)directly, Ajv throwsError: schema with key or id "..." already existsunless the old$idis removed first (engine.removeSchema(id)) or the top-level$idis stripped from a shallow clone before compilation.Since MCP tool validation in
LinearPatternJsonSchemaValidatoris always self-contained per tool (internal references use#/$defs/...rather than cross-document$idresolution), stripping top-level$idon a shallow copy beforeengine.compile(and keying any validator cache by a canonical JSON string / hash of the schema) avoids both stale cache hits and Ajv duplicate-$idexceptions.(Relatedly, in
mcp-slimwe strip"$id"and"$schema"from bothinputSchemaandoutputSchemaontools/listspecifically to prevent client-side Ajv$idcollisions while trimming schema token overhead.)cliffhall commentedon Nov 12, 2026
Fixed by #2974 (merged into v2/main as b453851).