Skip to content

feat(core,server,client): SEP-2106 JSON Schema 2020-12 — $ref guard, per-dialect validation, non-object structuredContent#2337

Open
felixweinberger wants to merge 15 commits into
v2-2026-07-28from
fweinberger/sep-2106-json-schema
Open

feat(core,server,client): SEP-2106 JSON Schema 2020-12 — $ref guard, per-dialect validation, non-object structuredContent#2337
felixweinberger wants to merge 15 commits into
v2-2026-07-28from
fweinberger/sep-2106-json-schema

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Implements SEP-2106 (tool inputSchema/outputSchema conform to JSON Schema 2020-12; structuredContent may be any JSON value) and the SEP-1613 dialect default, with the spec's $ref/composition-bounds security guidance.

Motivation and Context

The 2026-07-28 spec widens tool schemas to the full JSON Schema 2020-12 vocabulary and requires implementations to (a) default to a 2020-12 validator, (b) refuse to auto-dereference network $refs, (c) handle unsupported $schema dialects gracefully, and (d) accept any JSON value as structuredContent. The Node default validator was draft-07 with strict:false (2020-12 keywords silently ignored), the providers did not read $schema, and structuredContent was typed/parsed as a record.

How Has This Been Tested?

  • Unit: schemaBounds.test.ts (ref classifier, bounds, cycle-guard, truncation), dialect.test.ts (no $schema → 2020-12 default; non-2020-12 $schemaSchemaCompileError; custom-instance ctor bypasses the check), importGuard.test.ts (no HTTP client reachable from validators/**).
  • e2e (test/e2e/scenarios/jsonschema.test.ts): ref-denied, same-document-ref-ok, unsupported-dialect-graceful, bad-schema-isolates-tool, non-object-output, 2020-12:prefixItems, falsy-structured-content-validated, dialect:default-is-2020-12, array-structured-content-textfallback (+ author opt-out), primitive-structured-content, 2025:jsonschema:{object-root-still-enforced, non-object-structured-content-stripped}.
  • Conformance: json-schema-ref-no-deref (client) and json-schema-2020-12 (server) burned from expected-failures.
  • examples/schema-validators runs on all four transport×era legs (run:examples): list-forecasts returns array structuredContent; modern clients receive the array, legacy clients receive the auto-injected JSON text block.
  • Mutation-tested: reverting the Ajv2020 default, the === undefined falsy guard, or the $ref guard each fails the suite.

Breaking Changes

Three, all documented in docs/migration.md § JSON Schema:

  • CallToolResult.structuredContent is now typed unknown (was Record<string, unknown>). Source-breaking for typed consumers; runtime parse on the 2025 path is unchanged. Cast or narrow before property access.
  • Node default validator is now JSON Schema 2020-12 (was draft-07 with strict:false). Absent $schema defaults to 2020-12; $schema declaring anything other than 2020-12 is rejected with SchemaCompileError{kind:'unsupported-dialect'}. Pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.
  • Non-same-document $ref/$dynamicRef are rejected with SchemaCompileError{ref-denied} before compile (the spec's MUST-NOT auto-dereference). Per-tool failure — tools/list resolves with every tool; callTool on the bad one fails with InvalidParams before the request is sent. Use #/$defs/… or #anchor.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • 2020-12 only. The default AjvJsonSchemaValidator validates as JSON Schema 2020-12; declared $schema values other than 2020-12 are rejected with a typed error pointing at the custom-instance escape hatch. (Per-dialect routing for draft-07/2019-09 is a tracked follow-up.)
  • schemaBounds.ts. assertSchemaSafeToCompile (cycle-guarded; depth 64 / subschema-count 10 000), isSameDocumentRef, SchemaCompileError (attacker-controlled $ref/$schema strings truncated to 200 chars). Exported for custom jsonSchemaValidator implementations to call.
  • Lazy outputSchema compile. A tool whose outputSchema fails to compile (ref-denied, unsupported dialect, bounds, engine error) does not poison tools/list; the failure is stored on the response cache's per-tool entry and surfaced as ProtocolError(InvalidParams) when that tool is called. Compile state invalidates with the cached tool definition (list_changed, reconnect, re-list).
  • Legacy interop. When a tool's handler returns non-object structuredContent, McpServer returns a result with a {type:'text', text: JSON.stringify(sc)} block appended (any author-supplied text block opts out) and, on the 2025 era, with the non-object value omitted so a strictly-conformant 2025 client doesn't reject the entire response. A non-object (or typeless non-object-shaped) outputSchema is dropped from the legacy tools/list projection with a warn-once.
  • Thanks to @mattzcareyschemaBounds.ts and several runtime fixes are adapted from feat(core,server,client): implement SEP-2106 (tool schemas conform to JSON Schema 2020-12) #2249.

@felixweinberger felixweinberger requested a review from a team as a code owner June 22, 2026 16:04
@changeset-bot

changeset-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dfdc1a7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@modelcontextprotocol/client Major
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/core/src/util/standardSchema.ts
Comment thread packages/server/src/server/mcp.ts Outdated
Comment thread packages/core/src/validators/schemaBounds.ts
@pkg-pr-new

pkg-pr-new Bot commented Jun 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2337

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2337

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2337

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2337

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2337

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2337

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2337

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2337

commit: dfdc1a7

Comment thread packages/core/src/validators/types.ts Outdated
Comment thread test/conformance/src/everythingClient.ts
Comment thread packages/core/src/validators/ajvProvider.ts Outdated
felixweinberger added a commit that referenced this pull request Jun 22, 2026
…peless-root stamping, result mutation, dead-surface JSDoc
Comment thread packages/core/src/util/standardSchema.ts
Comment thread packages/core/src/validators/schemaBounds.ts Outdated
Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
Comment on lines +119 to 126
`The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` +
`Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.`
);
}

const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 The retained getSchema($id) ?? compile(schema) fast-path on a single long-lived Ajv2020 instance means the first schema compiled under a given $id wins for the life of the provider — a later, different schema that reuses the same $id (two tools sharing one $id, or a list_changed update that changes a tool's outputSchema while keeping its $id) is silently ignored and data keeps validating against the stale schema, even though the new SEP-2106 guards run on the new schema text. This pattern is pre-existing (not introduced by this PR), but since this function is being rewritten as the untrusted-schema hardening point, consider a follow-up: drop the $id fast-path, key the cache by full schema identity, or removeSchema/re-add when the schema text differs.

Extended reasoning...

What the bug is

AjvJsonSchemaValidator.getValidator() (packages/core/src/validators/ajvProvider.ts:119-126) keeps the pre-existing pattern:

const ajvValidator =
    '$id' in schema && typeof schema.$id === 'string'
        ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
        : this._ajv.compile(schema);

Ajv registers every compiled schema that declares $id in its instance-level registry, and the provider holds a single Ajv2020 instance for its entire lifetime (per-connection on the Client, per-server on McpServer; nothing ever calls removeSchema). So the first schema compiled under a given $id wins forever: any later, different schema declaring the same $id resolves through getSchema($id) to the old compiled validator and the new schema text is silently ignored. (The getSchema-first ordering exists precisely to dodge Ajv's "schema with key or id already exists" duplicate-id error, which is why this never throws — it just returns the stale validator.)

The code path that triggers it

Schemas reaching this function come from an untrusted peer (a server's advertised tool outputSchemas on the client side). Two realistic triggers:

  1. Two tools in one listing reuse one $id — the second tool's structuredContent is validated against the first tool's schema.
  2. A list_changed re-list changes a tool's outputSchema while keeping its $id — the response cache's stamp-keyed index correctly re-invokes _compileOutputValidator, the SEP-2106 guards (assertSchemaSafeToCompile, the dialect check) run on the new schema text, but the validator actually returned is the stale getSchema($id) entry compiled from the old schema.

Why the new code in this PR doesn't prevent it

This PR's hardening all happens before the lookup: assertSchemaSafeToCompile(schema) and the $schema dialect check inspect the new schema object, then the lookup may discard that object entirely in favor of the cached compile. The PR's invalidation story ("compile state invalidates with the cached tool definition — list_changed, reconnect, re-list", and the new substrate-held compile-error lifecycle in responseCache.ts/client.ts) therefore does not hold for $id-bearing schemas: the recompile resolves to the same stale entry on the persistent Ajv instance. The CfWorkerJsonSchemaValidator is unaffected (it constructs a fresh Validator per schema).

Step-by-step proof

  1. Server lists tool A with outputSchema: { $id: 'https://example.com/s', type: 'object', required: ['x'] }. Client calls A; getValidator finds no registered $id, falls through to compile(schema), and Ajv registers the compiled function under https://example.com/s.
  2. Server emits tools/list_changed and re-lists A with a changed schema keeping the same $id: { $id: 'https://example.com/s', type: 'object', required: ['y'] }.
  3. Client re-lists; the cache stamp changes, so outputValidator() re-derives its index and calls _compileOutputValidator with the new schema. assertSchemaSafeToCompile and the dialect check pass on the new text.
  4. getValidator hits this._ajv.getSchema('https://example.com/s'), which returns the validator compiled in step 1. The required: ['y'] schema is never compiled.
  5. The tool returns structuredContent: { x: 1 } (valid against the old schema, invalid against the advertised one) — the client accepts it. Conversely, { y: 1 } (valid against the advertised schema) is rejected with InvalidParams. The validator and the advertised schema have permanently diverged for the rest of the connection.

Impact

Wrong-schema validation rather than a crash: data is accepted/rejected against a schema other than the one currently advertised, undermining the per-tool isolation and recompile-on-relist guarantees this PR documents. The security angle is limited (both schemas come from the same peer, which could already advertise anything), but the benign stale-after-list_changed case is a genuine correctness gap.

Why pre-existing, and how to fix

The getSchema($id) ?? compile line is unchanged context in this diff — the pattern predates the PR — so this is filed as a follow-up rather than a blocker. Note that simply deleting the getSchema lookup is not a drop-in fix (Ajv would then throw the duplicate-$id error on the second compile of the same $id). Reasonable options:

  • Key the validator cache by full schema identity (e.g. serialized schema text) instead of $id, compiling $id-bearing schemas on a throwaway/scoped basis.
  • removeSchema(schema.$id) and re-compile when the registered schema's text differs from the incoming one.
  • Drop the $id fast-path and strip/namespace $id before compiling, so untrusted peers cannot collide in the shared registry at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing (this PR doesn't introduce it — same pattern at base). Tracked as a baseline follow-up: drop the $id fast-path or key the cache by full schema identity. Out of scope here per the security-hardening boundary; will address separately.

Comment thread packages/core/src/types/types.ts
Comment thread docs/migration.md
…ialect validation

schemaBounds.ts (SchemaCompileError, isSameDocumentRef, assertSchemaSafeToCompile cycle-guarded). Both built-in providers reject non-same-document $ref/$dynamicRef and over-bound schemas before compile, read $schema and route to a matching engine (Ajv2020/Ajv2019/draft-07; cfworker by draft), and reject unknown dialects with a typed error. Default (no $schema) is 2020-12 (SEP-1613). Custom-instance ctor bypasses routing — caller owns dialect.
…on tools/list

cacheToolMetadata wraps getValidator in try/catch and stores any throw per-tool; callTool fails fast (before the request) with ProtocolError(InvalidParams, {reason}) when a compileError is present. listTools resolves with every tool; only the bad one is unreachable.
standardSchemaToJsonSchema('output') no longer forces type:'object'. Public CallToolResult.structuredContent widens to unknown via a types.ts override; legacy runtime Zod parse stays z.record (Q10-L2 byte-identity). Type-level pins narrowed to the structuredContent field only.
…back, legacy projection/strip

structuredContent presence check is === undefined (null/0/false/'' are legal values, validated against outputSchema). McpServer auto-appends a TextContent JSON block when structuredContent is non-object and the handler authored none (any text block opts out); on the legacy era the non-object value is then omitted (invalid 2025 wire data). Non-object outputSchema is dropped from the legacy tools/list projection with a warn-once.
…n-schema expected-failures

everythingClient registers json-schema-ref-no-deref handler; everythingServer's json_schema_2020_12_tool advertises the full keyword set via fromJsonSchema. Both expected-failures entries removed. e2e: jsonschema.test.ts covers ref-denied, same-document-ref, unsupported-dialect, bad-schema-isolates, non-object-output, prefixItems (Ajv2020 pin), falsy-validated, dialect:{draft-07,default-2020-12}, array textfallback (+author opt-out), primitive sc, legacy projection/strip.
…schema-validators array example

migration.md §JSON Schema covers the three breaks (ref-denied, Ajv default 2020-12 with per-dialect routing, structuredContent unknown) with opt-backs. schema-validators example gains list-forecasts (array outputSchema) demonstrating the known-server cast idiom and the auto-TextContent fallback / legacy strip on all four legs. Thanks @mattzcarey (#2249).
Drop per-dialect Ajv routing. The default validator supports JSON Schema
2020-12 only — the spec's only MUST (SEP-1613). Schemas declaring a
different $schema are rejected with SchemaCompileError{unsupported-dialect};
the existing custom-engine constructor (Ajv instance / {draft}) skips the
check so callers who own their dialect still can.

The pre-PR draft-07 'support' was accidental (the engine never read
$schema), and 2020-12-only matches the Go and C# SDKs. Net: one Ajv
class on the default path instead of three — restores the
@modelcontextprotocol/node bundle build at default heap.

- ajvProvider: single Ajv2020 instance; drop Ajv2019 import, AjvDialect,
  KNOWN_DIALECT_URIS, _byDialect/_instanceFor; drop Ajv2019/Ajv2020
  re-exports (consumers import from ajv directly; Ajv stays for the
  draft-07 opt-back)
- cfWorkerProvider: same $schema check; explicit {draft} bypasses it
- client/server validators/ajv: re-export addFormats, Ajv,
  AjvJsonSchemaValidator only
- dialect.test: 2020-12 compiles, draft-07/2019-09 reject, custom
  engine bypasses
- e2e: drop client:jsonschema:dialect:draft-07-enforced; keep
  default-is-2020-12
- migration docs: replace per-dialect routing with 2020-12-only +
  opt-back
…peless-root stamping, result mutation, dead-surface JSDoc
…SCHEMA_KEYWORDS JSDoc accuracy

isProvablyObjectShapedRoot: a typeless oneOf/anyOf/allOf root whose every
member is type:'object' is also stamped, so z.discriminatedUnion / z.union of
objects / z.intersection of objects keep their 2025-era outputSchema
advertisement (the previous unconditional stamp happened to work for these;
the fix-#2 narrow heuristic regressed them). A typeless union with any
non-object member is still returned as-is. 3 new output-arm unit tests.

SUBSCHEMA_KEYWORDS JSDoc: 'consumed only within this module's tests' →
'has no consumers' (the test file does not import it either).
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from 21988e6 to 8623fba Compare June 22, 2026 21:43
…solve, isCallToolResult widening, isError skip, opt-back snippet
Comment thread packages/core/src/types/types.ts
Comment thread packages/server/src/server/mcp.ts
Comment thread packages/core/src/util/standardSchema.ts
Comment thread packages/core/src/validators/ajvProvider.ts
…le-level warn-once, recursive object-shape check, Ajv2020 customization docs

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 packages/core/src/types/types.ts:401-409 — Two sibling docs were missed in the structuredContent-widening sweep and now contradict the behavior this PR ships: (1) examples/guides/clientGuide.examples.ts (#callTool_structuredOutput, ~lines 221-224) and the docs/client.md snippet rendered from it still use the old falsy presence check if (result.structuredContent) and call structuredContent "a machine-readable JSON object", while the identical example in client.examples.ts / the callTool JSDoc was migrated to the !== undefined + narrowing pattern in this PR; (2) docs/server.md's NOTE (~lines 131-141) still marks a named interface for structuredContent as a type error (not assignable to { [key: string]: unknown }) — with structuredContent now typed unknown that type error no longer occurs. Update the guide example/prose to match client.examples.ts and delete or rewrite the server.md NOTE (TypeScript no longer checks the value; runtime validateToolOutput is the guard).

    Extended reasoning...

    What is stale

    This PR widens CallToolResult.structuredContent to unknown via WidenStructuredContent (packages/core/src/types/types.ts:401-409) and migrates the structured-output examples to the SEP-2106 pattern: presence checked with !== undefined and the value narrowed before property access. The PR updates packages/client/src/client/client.examples.ts (#Client_callTool_structuredOutput) and the callTool JSDoc accordingly, and docs/migration.md / docs/migration-SKILL.md explicitly instruct users to replace if (!result.structuredContent) with the === undefined form. Two sibling docs that describe the same surfaces were not updated and now describe the pre-PR behavior.

    1. examples/guides/clientGuide.examples.ts + docs/client.md

    The #callTool_structuredOutput region in examples/guides/clientGuide.examples.ts (~lines 221-224) still reads:

    // Machine-readable output for the client application
    if (result.structuredContent) {
        console.log(result.structuredContent); // e.g. { bmi: 22.86 }
    }

    and docs/client.md (~lines 272-283) renders that snippet verbatim, introduced by prose calling structuredContent "a machine-readable JSON object". After this PR structuredContent is typed unknown and may legally be any JSON value — including null, 0, false, '' — which the falsy check misclassifies as absent. This is the byte-identical example the PR already migrated in client.examples.ts and the callTool JSDoc, so the surviving copy is exactly the partial-migration shape: the prose guide now teaches the deprecated pattern that the PR's own migration table tells users to remove.

    Step-by-step: a reader follows docs/client.md, copies the snippet, and connects to a SEP-2106 server whose tool returns structuredContent: 0 (the PR's own falsy-structured-content-validated e2e fixture). The if (result.structuredContent) gate is falsy → their app silently treats a present, schema-valid value as missing. Meanwhile the migration guide they are also reading tells them to use === undefined — the two docs contradict each other.

    2. docs/server.md interface-vs-type-alias NOTE

    docs/server.md (~lines 131-141) contains a NOTE telling authors to use a type alias rather than an interface for structuredContent, because named interfaces lack implicit index signatures and so are not assignable to { [key: string]: unknown }; it marks interface BmiResult { bmi: number } as a type error and recommends the structuredContent: { ...result } spread workaround. That was true pre-PR (the field was Record<string, unknown>). After this PR the handler return path uses the widened public CallToolResult (ToolCallback's result type), so structuredContent is unknown — any value, including a named-interface-typed object, is assignable. The PR's own new test in packages/server/test/server/mcp.compat.test.ts demonstrates this: even structuredContent: 'not-an-array' compiles, with a comment noting that runtime validateToolOutput is the guard. The documented type error therefore no longer occurs, and the type-alias guidance and spread workaround are obsolete.

    Why this matters / why nothing catches it

    Neither file is in the diff and neither is exercised by tests, so the suite stays green while the published guides describe behavior the PR removed. Both verifier passes confirmed these are the only surviving instances of the old falsy-check example and the only doc still describing the old assignability constraint; no existing review comment covers them (the prior comments target migration.md, the JSDoc, and the changesets).

    How to fix

    • In examples/guides/clientGuide.examples.ts#callTool_structuredOutput, apply the same !== undefined + narrowing pattern used in client.examples.ts, and adjust the docs/client.md introducing sentence ("a machine-readable JSON object" → "any JSON value, typed unknown"); regenerate the rendered snippet.
    • Delete the docs/server.md NOTE, or replace it with the new guidance: structuredContent is typed unknown, so TypeScript no longer checks the handler's value against the declared outputSchema — runtime validateToolOutput is the guard.

    Docs/example consistency only — no runtime impact — but worth fixing in this PR since it is the same example/constraint the PR already updated elsewhere.

Comment thread docs/migration.md Outdated
Comment thread packages/core/src/types/specTypeSchema.ts
Comment thread docs/migration.md
Comment thread packages/core/src/validators/ajvProvider.ts
…ceiling), ToolResultContent widen, migration prose alignment
Comment thread packages/client/src/validators/ajv.ts
Comment thread packages/core/src/types/specTypeSchema.ts Outdated
Comment thread packages/client/src/client/client.examples.ts
Comment thread packages/core/src/types/specTypeSchema.ts
…eaks cf-workers), reword to 'install ajv yourself', clientGuide/server.md SEP-2106 alignment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant