Skip to content

feat(core,server,client): implement SEP-2106 (tool schemas conform to JSON Schema 2020-12)#2249

Open
mattzcarey wants to merge 6 commits into
mainfrom
feat/sep-2106-json-schema-2020-12
Open

feat(core,server,client): implement SEP-2106 (tool schemas conform to JSON Schema 2020-12)#2249
mattzcarey wants to merge 6 commits into
mainfrom
feat/sep-2106-json-schema-2020-12

Conversation

@mattzcarey

Copy link
Copy Markdown
Contributor

Implements SEP-2106 — tool inputSchema/outputSchema conform to JSON Schema 2020-12, and structuredContent may be any JSON value.

Closes #2192.

What changed

Schema surface

  • inputSchema keeps type: "object" at the root but now accepts any JSON Schema 2020-12 keyword (composition, conditional, $ref/$defs/$anchor, …).
  • outputSchema may now be any valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions (was restricted to type: "object").
  • structuredContent widens from { [key: string]: unknown } to unknown (source-breaking for typed consumers).
  • Updated the generated spec.types.ts (surgically, matching the upstream regen for these fields), the hand-maintained zod in schemas.ts, and standardSchemaToJsonSchema (the output path no longer forces type: "object"; input and prompt args stay object-only).

Stronger typing (cast-free)

  • New CallToolResultWithStructuredContent<T> type.
  • client.callTool<T = JSONValue>() is generic so callers get a precisely typed structuredContent. Implemented via a method overload so the body stays cast-free.
  • McpServer.registerTool now type-checks a handler's returned structuredContent against the tool's outputSchema inferred output (ToolResultFor<Output> threaded through ToolCallback).

Behavior

  • Fixed a latent falsy bug: server + client treated 0/false/"" as "no structured content"; now check === undefined.
  • Servers returning array/primitive structuredContent automatically also emit a serialized TextContent block so pre-SEP clients can fall back to the text content (object output, or results that already carry text, are untouched).

Security

  • Built-in validators refuse to dereference non-same-document $ref/$dynamicRef (SSRF guard) and reject schemas exceeding depth / subschema-count bounds (composition-DoS guard), via the new schemaBounds.ts. Custom jsonSchemaValidator implementations are unaffected.

Tests & docs

  • New test/integration/test/sep2106.test.ts (array/primitive round-trips, falsy values, TextContent auto-inject, typed callTool<T>) and packages/core/test/validators/schemaBounds.test.ts; provider-level SSRF tests added to validators.test.ts.
  • Migration notes in both docs/migration.md and docs/migration-SKILL.md; minor changeset.

Verification

typecheck:all ✅ · lint:all ✅ · build:all ✅ · sync:snippets no-diff ✅ · core 566, client 367, server 68, integration 427 ✅.

Note on generated types

spec.types.ts carries only the SEP-2106 field changes (so the spec↔SDK assignability test passes) rather than a full fetch:spec-types regen, which would pull unrelated drift owned by the dedicated update-spec-types branch. The edits match that branch verbatim for these fields, so a later full regen merges cleanly.

@mattzcarey mattzcarey requested a review from a team as a code owner June 2, 2026 17:42
@changeset-bot

changeset-bot Bot commented Jun 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 895e7a1

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

This PR includes changesets to release 7 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/client 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

@felixweinberger felixweinberger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Awesome thanks for working on this!

Quick note before a deeper read - there should be a currently failing conformance scenario in expected-failures.yaml here:

# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.

Can we remove that expected failure and confirm conformance passes with this change?

Comment on lines +75 to +77
// SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling.
assertSchemaSafeToCompile(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 new assertSchemaSafeToCompile() guard throws inside getValidator(), and Client.listTools()cacheToolMetadata() eagerly compiles every advertised tool's outputSchema with no per-tool error handling — so a single tool advertising a non-same-document $ref/$dynamicRef or an over-budget schema makes the entire listTools() reject, leaving the client unable to list or call any tool from that server (and listChanged auto-refresh surfaces the error on every notification). Consider catching per-tool in cacheToolMetadata() or deferring the guard to validation/callTool time so the failure is scoped to the offending tool; the same applies to CfWorkerJsonSchemaValidator.getValidator.

Extended reasoning...

What the bug is. This PR adds assertSchemaSafeToCompile(schema) to the top of both AjvJsonSchemaValidator.getValidator() (packages/core/src/validators/ajvProvider.ts:75-77) and CfWorkerJsonSchemaValidator.getValidator(). The guard throws synchronously for (a) any $ref/$dynamicRef that does not begin with # and (b) schemas exceeding the depth-64 / 10k-subschema bounds. The guard itself is a sensible security measure — the problem is where the throw surfaces. On the client, cacheToolMetadata() (packages/client/src/client/client.ts:944-954) iterates every advertised tool and calls this._jsonSchemaValidator.getValidator(tool.outputSchema) eagerly, with no try/catch, and it is invoked synchronously from listTools() (client.ts:~1004), also with no try/catch. Any throw from the new guard therefore rejects the whole listTools() promise.\n\nConcrete walk-through. 1) A server advertises two tools: good_tool (plain object outputSchema) and weird_tool whose outputSchema contains { "$ref": "https://example.com/schemas/forecast.json" } — a spec-valid JSON Schema 2020-12 schema, which SEP-2106 (this PR) explicitly allows servers to advertise. 2) The client calls client.listTools(). 3) The tools/list request succeeds and cacheToolMetadata(result.tools) runs. 4) For weird_tool, getValidator() calls assertSchemaSafeToCompile(), which throws JSON Schema contains a non-local "$ref" .... 5) The throw propagates out of cacheToolMetadata() and out of listTools(), so the caller gets a rejection and never sees good_tool either. 6) Because callTool() relies on the validator cache populated by listTools(), the application typically cannot use any tool from that server. 7) If listChanged.tools.onChanged auto-refresh is configured, every notifications/tools/list_changed re-triggers the same failure (the fetcher's catch just forwards the error to onChanged).\n\nWhy this is a regression introduced/amplified by this PR, not pre-existing. Pre-PR, the CfWorker provider's Validator constructor does not eagerly dereference external refs, so listTools() succeeded and only that one tool's callTool validation could fail — post-PR the whole tools/list path is poisoned on the browser/workerd default. The depth/subschema-count throws are entirely new for both providers: a deeply nested but legitimate schema that previously compiled fine now breaks listing every tool. (On the AJV/Node path an unresolvable external $ref did already throw at compile() time pre-PR, so that one sub-case is partially pre-existing — but the PR adds the new throw sites, extends the behavior to CfWorker, and SEP-2106 makes $ref-bearing output schemas far more likely to appear in the wild, so the blast radius grows materially.) Nothing in the PR's tests covers the client-side listTools() behavior when a server advertises such a schema, and the PR description frames the guard as rejecting that schema, not as disabling the whole server.\n\nImpact. A single misbehaving (or merely externally-referencing) tool definition acts as a denial-of-service against the client's view of the entire server: listTools() rejects, no validators are cached, and callTool() for unrelated, perfectly valid tools is effectively unusable. For hosts that aggregate many third-party MCP servers, one bad tool schema knocks out a whole server's toolset rather than just the offending tool.\n\nHow to fix. Keep the guard, but scope its failure to the offending tool. Either (1) wrap the per-tool getValidator() call in cacheToolMetadata() in a try/catch — skip caching a validator for that tool (or cache a validator that always fails) and optionally log/annotate, so listTools() still returns and other tools keep working, and the offending tool fails at callTool/validation time with a clear error; or (2) defer assertSchemaSafeToCompile to validation time so the throw happens inside the per-tool validator path that callTool() already wraps in error handling. The same per-tool isolation should apply to both built-in providers. A test asserting that listTools() succeeds when one tool advertises a non-local $ref outputSchema would lock the behavior in.

Comment thread packages/client/src/client/client.ts
@pkg-pr-new

pkg-pr-new Bot commented Jun 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 895e7a1

@mattzcarey mattzcarey force-pushed the feat/sep-2106-json-schema-2020-12 branch from 6b451c5 to a277433 Compare June 3, 2026 13:57
mattzcarey added a commit that referenced this pull request Jun 9, 2026
…review)

Addresses claude-bot review on PR #2249: assertSchemaSafeToCompile (SEP-2106
SSRF / composition-DoS guard) throws inside getValidator(), and
cacheToolMetadata() eagerly compiled every advertised tool's outputSchema with
no per-tool error handling. A single tool advertising a non-local $ref or an
over-budget schema therefore rejected the entire listTools() call, leaving the
client unable to list or call ANY tool from that server.

- catch compilation per-tool in cacheToolMetadata(); store the error in a new
  _toolOutputValidatorErrors map instead of letting it propagate.
- surface the scoped error from callTool() only when the offending tool is
  actually called (clear, descriptive ProtocolError).
- integration test: one tool with a non-local $ref outputSchema does not break
  listTools() or the use of a sibling good tool; the bad tool errors only on call.

Note: the second review comment (experimental/tasks callToolStream truthiness)
is stale \u2014 the tasks feature was removed in c8d7401 before this branch, no
callToolStream exists, and no truthiness structuredContent checks remain.
… schemas

Tools' inputSchema/outputSchema now conform to full JSON Schema 2020-12 and
structuredContent may be any JSON value (#2192).

- outputSchema accepts any 2020-12 schema (arrays/primitives/objects/compositions);
  inputSchema keeps `type: "object"` but allows all 2020-12 keywords. Updated the
  generated spec types, the zod schemas, and standardSchemaToJsonSchema (output path
  no longer forces `type: "object"`).
- structuredContent widens from `{ [key: string]: unknown }` to `unknown`
  (source-breaking for typed consumers).
- Stronger typing: new CallToolResultWithStructuredContent<T>; client.callTool<T>()
  generic (cast-free via overload); registerTool type-checks a handler's
  structuredContent against its outputSchema's inferred output.
- Fix falsy-structuredContent bug (=== undefined) in server + client.
- Server auto-emits a serialized TextContent fallback for non-object
  structuredContent (pre-SEP client interop).
- Security: validators reject non-same-document $ref/$dynamicRef (SSRF) and
  schemas exceeding depth/subschema bounds (composition DoS) via schemaBounds.

Adds unit + integration tests, migration docs (migration.md + migration-SKILL.md),
and a changeset.
Register the SEP-2106 conformance scenario in the everythingClient and
remove it from expected-failures.yaml. The client already blocks
non-local $ref dereferencing via assertSchemaSafeToCompile.
- ajvProvider: use Ajv2020 so the default Node validator honors the 2020-12
  dialect (prefixItems etc.); previously new Ajv() ran draft-07 and silently
  ignored 2020-12 keywords (R-2106-1/2).
- add MCP_DEFAULT_SCHEMA_DIALECT='2020-12' as the single source of truth;
  cfWorker provider defaults through it.
- refactor the server structuredContent text-fallback from in-place mutation to
  a pure withStructuredContentTextFallback() so the tools/call path is
  side-effect-free.
- tests: Ajv2020 prefixItems regression (both validators); standardSchema
  io:'output' branch; spec.types<->schemas field-level mirror; registerTool
  compile-time Output typing; falsy structuredContent round-trip (false/""/null);
  schema-safety guards surfacing cleanly via fromJsonSchema.
- changeset: note the Ajv2020 default-dialect fix.
…review)

Addresses claude-bot review on PR #2249: assertSchemaSafeToCompile (SEP-2106
SSRF / composition-DoS guard) throws inside getValidator(), and
cacheToolMetadata() eagerly compiled every advertised tool's outputSchema with
no per-tool error handling. A single tool advertising a non-local $ref or an
over-budget schema therefore rejected the entire listTools() call, leaving the
client unable to list or call ANY tool from that server.

- catch compilation per-tool in cacheToolMetadata(); store the error in a new
  _toolOutputValidatorErrors map instead of letting it propagate.
- surface the scoped error from callTool() only when the offending tool is
  actually called (clear, descriptive ProtocolError).
- integration test: one tool with a non-local $ref outputSchema does not break
  listTools() or the use of a sibling good tool; the bad tool errors only on call.

Note: the second review comment (experimental/tasks callToolStream truthiness)
is stale \u2014 the tasks feature was removed in c8d7401 before this branch, no
callToolStream exists, and no truthiness structuredContent checks remain.
Adds resolveExternalSchemaRefs(schema, options) — the SEP's optional, opt-in
external-$ref mode. Validators stay synchronous and never fetch; this helper runs
ahead of time and returns a self-contained schema (external docs fetched once,
flattened into $defs, every $ref rewritten to a root-relative JSON Pointer), so
the result passes the default safety guard and compiles with AJV/cfworker without
any network access at validation time.

Per the SEP it is:
- disabled by default (a separate function requiring explicit operator action);
- host-restricted: enforces an allowlist when given, else rejects
  loopback/link-local/private literal targets; https-only by default;
- bounded: per-request timeout, response byte cap (streaming-enforced),
  max-documents cap;
- observable: onDereference callback logs each fetched URI;
- fail-closed: unresolved/oversized/non-JSON/disallowed refs throw, never a
  silent pass.

Transitive refs are resolved; external $anchor fragments and nested
$id/$anchor in fetched docs are rejected (cannot be flattened safely).

17 unit tests (happy path, end-to-end compile+validate via both AJV and cfworker,
transitive resolution, allowlist/protocol/SSRF rejections, byte/document bounds,
fail-closed cases). Exported from core; examples synced; changeset updated.
@mattzcarey mattzcarey force-pushed the feat/sep-2106-json-schema-2020-12 branch from 336f4fb to c2533e1 Compare June 9, 2026 11:14
… fixture tool

The json-schema-2020-12 server scenario asserts that $schema, $defs/$anchor,
additionalProperties, composition (allOf/anyOf), and conditional (if/then/else)
keywords are preserved verbatim in tools/list. The fixture registered the tool
with a zod schema that never contained those keywords, so the scenario failed
with 'field was likely stripped'. Register the exact scenario schema as raw
JSON Schema via fromJsonSchema() instead; all 7 checks now pass.

Also note the per-tool outputSchema compile-failure scoping in the SEP-2106
changeset.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

Removed the json-schema-ref-no-deref expected failure from test/conformance/expected-failures.yaml and confirmed the client conformance suite passes with it gone. While verifying, I also found the json-schema-2020-12 server scenario (all-suite only, not in CI's active/draft runs) was failing because the fixture tool in everythingServer.ts registered a plain Zod schema without the 2020-12 keywords the scenario checks for — it now registers the scenario's raw 2020-12 schema via fromJsonSchema() and passes 7/7 checks.

Also rebased onto main (reconciled with the per-revision spec types from #2252) and scoped output-schema compile failures per-tool, so one tool advertising an uncompilable schema no longer rejects the whole listTools() — calling the offending tool fails with a descriptive error while other tools stay usable.

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.

Implement SEP-2106: Tools inputSchema & outputSchema Conform to JSON Schema 2020-12

2 participants