Skip to content

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

Closed

Description

@CloneOfAlex

Which version line?

v2 — current (@modelcontextprotocol/inspector@latest)

Which client?

All / shared core

Inspector version

2.14.0 (released package; also v2/main @ 3b387d3)

Node version

v22.23.4

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:

const v = new LinearPatternJsonSchemaValidator();
const before = { $id: "urn:example:weather-output", type: "object",
  properties: { temp: { type: "number" } }, required: ["temp"] };
const after  = { $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 schema
v.getValidator(after)({ temp: 21 });     // violates the new schema

Case 2: two tools declare the same $id (e.g. a generic "output"):

v.getValidator({ $id: "output", type: "object", required: ["a"] })({ a: 1 });
v.getValidator({ $id: "output", type: "object", required: ["b"] })({ b: 1 });

Expected behavior

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).

Activity

  1. cliffhall commented on Nov 10, 2026

    @cliffhall
    Member

    Triage: Priority Medium (total 7)

    • 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).

  2. Rutvij97 commented on Nov 10, 2026

    @Rutvij97

    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.

  3. added this to the v2.16.0 milestone on Nov 11, 2026
  4. SalyyS1 commented on Nov 11, 2026

    @SalyyS1

    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:

    1. 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.
    2. 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.)

  5. self-assigned this
    on Nov 12, 2026
  6. cliffhall commented on Nov 12, 2026

    @cliffhall
    Member

    Fixed by #2974 (merged into v2/main as b453851).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions