Skip to content

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

Merged
felixweinberger merged 11 commits into
v2-2026-07-28from
fweinberger/sep-2106-json-schema
Jun 24, 2026
Merged

feat(core,server,client): SEP-2106 JSON Schema 2020-12 — $ref guard, per-dialect validation, non-object structuredContent#2337
felixweinberger merged 11 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.

Stacked on #2351 (full wire/public separation — both per-era schema sets frozen and self-contained, function-only WireCodec), so this PR widens the public schemas directly with no type-level override layer and the 2025 wire-parse contract is unaffected.

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) handle unsupported $schema dialects gracefully, and (c) accept any JSON value as structuredContent. The Node default validator was draft-07 with strict:false (2020-12 keywords silently ignored), and structuredContent was typed/parsed as a record.

How Has This Been Tested?

  • Unit: validators.test.ts (Ajv2020 default via prefixItems; $schema rejection; custom-instance bypass), standardSchema.test.ts (output-arm typeless-root handling), publicTypeShapes.test.ts (8 type-equality pins + the frozen-2025-wire invariant).
  • e2e (test/e2e/scenarios/jsonschema.test.ts): same-document $ref ok, unsupported-dialect graceful, bad-schema isolates per-tool, non-object output, prefixItems (pins Ajv2020), falsy structuredContent validated, default-is-2020-12, primitive sc, array TextContent fallback (+ author opt-out), 2025-era wrap (schema + value + $ref rewrite).
  • 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 {result: [...]} plus the auto-injected JSON text block.
  • Mutation-tested: reverting the Ajv2020 default or the === undefined falsy guard each fails the suite.

Breaking Changes

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

  • CallToolResult.structuredContent is now typed unknown (was Record<string, unknown>); Tool['outputSchema'] is now an open JSON Schema document (was {type:'object', …}). Source-breaking for typed consumers; runtime parse on the 2025 wire is unchanged (the frozen 2025-wire schemas in refactor(core): wire-layer/public-layer full separation — frozen per-era schemas, function-only WireCodec, bidirectional lint rule #2351 still reject the wider shapes). 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 a typed error pointing at the custom-instance escape hatch. Pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.

External $ref is not a new break: neither built-in engine has ever fetched network refs (Ajv without loadSchema doesn't; @cfworker/json-schema has no fetch path). An unresolved external $ref raises an engine compile error, surfaced per-tool via the lazy-compile path — same as before, just now lazily isolated so it doesn't poison tools/list.

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

  • Public schema widening (types/schemas.ts, +19/−14): four leaf-field changes (structuredContentz.unknown(); outputSchema → open looseObject). With refactor(core): wire-layer/public-layer full separation — frozen per-era schemas, function-only WireCodec, bidirectional lint rule #2351 in place, the public types infer the SEP-2106 shapes directly — types.ts, guards.ts, and specTypeSchema.ts carry zero diff in this PR.
  • Validators: createDefaultAjvInstance()Ajv2020; both providers carry an inline $schema check rejecting non-2020-12 declarations. The draft-07 Ajv class stays re-exported for the documented opt-back.
  • Lazy outputSchema compile: a tool whose outputSchema fails to compile (engine error, unsupported dialect) 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.
  • Legacy interop (era-gated wrap): the {result:…} wrap lives in rev2025CodecencodeResult('tools/list', …) projects a non-object-root outputSchema as {type:'object', properties:{result: <natural>}, required:['result']} (with $ref JSON-pointers rewritten under #/properties/result), and projectCallToolResult wraps non-object structuredContent as {result: value}. A {type:'text', text: JSON.stringify(sc)} block is auto-appended when the handler authored none. McpServer is era-blind: tools/call calls this.server.codec.projectCallToolResult(…) and tools/list flows through encodeResult, so the same registration serves modern clients the natural shapes and legacy clients the wrapped ones. This matches the C# SDK's approach and the existing FastMCP convention.
  • Thanks to @mattzcarey — the falsy-presence fix and several runtime details 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: 448bf5e

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 Major
@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 Outdated
@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: 448bf5e

Comment thread packages/core/src/validators/types.ts Outdated
Comment thread test/conformance/src/everythingClient.ts Outdated
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 thread packages/core/src/validators/ajvProvider.ts
Comment thread packages/core/src/types/types.ts Outdated
Comment thread docs/migration.md
felixweinberger added a commit that referenced this pull request Jun 22, 2026
…peless-root stamping, result mutation, dead-surface JSDoc
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from 21988e6 to 8623fba Compare June 22, 2026 21:43
felixweinberger added a commit that referenced this pull request Jun 22, 2026
…solve, isCallToolResult widening, isError skip, opt-back snippet
Comment thread packages/core/src/types/types.ts Outdated
Comment thread packages/server/src/server/mcp.ts Outdated
Comment thread packages/core/src/util/standardSchema.ts
Comment thread packages/core/src/validators/ajvProvider.ts
felixweinberger added a commit that referenced this pull request Jun 22, 2026
…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 Outdated
Comment thread docs/migration.md Outdated
Comment thread packages/core/src/validators/ajvProvider.ts
felixweinberger added a commit that referenced this pull request Jun 22, 2026
…ceiling), ToolResultContent widen, migration prose alignment
Comment thread packages/client/src/validators/ajv.ts Outdated
Comment thread packages/core/src/types/specTypeSchema.ts Outdated
felixweinberger added a commit that referenced this pull request Jun 23, 2026
Comment thread packages/client/src/client/client.examples.ts
Comment thread packages/core/src/types/specTypeSchema.ts Outdated
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…eaks cf-workers), reword to 'install ajv yourself', clientGuide/server.md SEP-2106 alignment
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…peless-root stamping, result mutation, dead-surface JSDoc
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…solve, isCallToolResult widening, isError skip, opt-back snippet
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…le-level warn-once, recursive object-shape check, Ajv2020 customization docs
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…ceiling), ToolResultContent widen, migration prose alignment
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from dfdc1a7 to d05a7eb Compare June 23, 2026 12:57
felixweinberger added a commit that referenced this pull request Jun 23, 2026
felixweinberger added a commit that referenced this pull request Jun 23, 2026
…eaks cf-workers), reword to 'install ajv yourself', clientGuide/server.md SEP-2106 alignment
Comment thread packages/core/src/util/standardSchema.ts
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from d05a7eb to 65a36f3 Compare June 23, 2026 16:26
felixweinberger added a commit that referenced this pull request Jun 24, 2026
…af wire/textFallback.ts (break value cycle)
felixweinberger added a commit that referenced this pull request Jun 24, 2026
…acy wrap; drop dead Wire2025CallToolResult export
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from 2db8455 to dd1f952 Compare June 24, 2026 09:30
Comment thread packages/core/src/util/standardSchema.ts Outdated
felixweinberger added a commit that referenced this pull request Jun 24, 2026
@felixweinberger felixweinberger force-pushed the fweinberger/wire-public-separation branch from 3bd0f08 to cdaf056 Compare June 24, 2026 10:10
felixweinberger added a commit that referenced this pull request Jun 24, 2026
…te position-aware + $id-scoped, narrow projectCallToolResult surface, changeset major
felixweinberger added a commit that referenced this pull request Jun 24, 2026
… changeset documents Server.projectCallToolResult; e2e comment compile→validate
felixweinberger added a commit that referenced this pull request Jun 24, 2026
…af wire/textFallback.ts (break value cycle)
felixweinberger added a commit that referenced this pull request Jun 24, 2026
…acy wrap; drop dead Wire2025CallToolResult export
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from b0c9edc to 0ef7f54 Compare June 24, 2026 10:10
felixweinberger added a commit that referenced this pull request Jun 24, 2026
Base automatically changed from fweinberger/wire-public-separation to v2-2026-07-28 June 24, 2026 10:14
…wn, outputSchema open)

The 2025 wire codec is no longer parsed against types/schemas.ts (the
preceding refactor froze the affected schemas in wire/rev2025-11-25/), so
the neutral/public layer can now widen to the SEP-2106 shapes directly:

- CallToolResult.structuredContent / ToolResultContent.structuredContent
  → z.unknown() (any JSON value).
- Tool.outputSchema → z.looseObject({$schema?: string}) (any JSON Schema
  document, not just type:'object').

The compositions (ListToolsResult, CompatibilityCallToolResult,
SamplingMessageContentBlock, SamplingMessage,
CreateMessageResultWithTools) widen automatically. types.ts, guards.ts and
specTypeSchema.ts are unchanged — the public TypeScript types are inferred
straight from the widened schemas with no overlay machinery.

standardSchemaToJsonSchema(_, 'output') now permits non-object roots and
only stamps type:'object' on a typeless root that is provably object-shaped
(properties/required at the root, or every oneOf/anyOf/allOf member is
object-typed) so existing zod outputSchemas keep producing valid 2025 wire
data where they always did.

The 2025 spec-anchor parity pins for the affected names now target the
frozen wire schemas; the new publicTypeShapes test pins the public widening
and re-asserts the frozen 2025 reject behavior.
…ed-$schema error

The default Ajv instance is now Ajv2020 (was draft-07 Ajv), so 2020-12
keywords (prefixItems, unevaluatedProperties, …) are enforced and tool
output schemas — which SEP-2106 lets describe any JSON value — validate
under the spec-declared dialect (SEP-1613).

Both built-in providers add a single inline check before compile: if the
schema declares a $schema URI and it isn't a 2020-12 URI, throw a plain
Error with a clear message instead of letting the engine crash on an opaque
internal error or silently mis-validate. Passing a custom Ajv instance / an
explicit {draft} skips the check (caller owns dialect).

The Ajv re-export stays for opt-back to the v1-equivalent draft-07
construction; Ajv2020 is not re-exported (its type graph tips downstream
declaration bundling — #2339).
A tool whose outputSchema fails to compile (unsupported $schema dialect,
invalid pattern regex, unresolvable $ref, or any other engine error) is
now surfaced as a per-tool ProtocolError(InvalidParams) on callTool, BEFORE
the request is sent — one bad schema no longer poisons every other tool's
callTool, and the server-side handler is not executed for nothing.

The compile result is held on the response-cache substrate's stamp-keyed
name → validator index (an {ok}-discriminated union, not the raw validator)
so it inherits that substrate's invalidation lifecycle: list_changed
evicts, a refetched tools/list re-derives, resetForReconnect clears. No
parallel map; no stale-compile-error bug when the server fixes the tool by
removing the schema. The toolDefinition path is compiled in isolation and
never enters the cache, so a one-off bad definition cannot poison the
listed tool.

The HEADER_MISMATCH recovery path now re-resolves the validator against the
freshly-fetched entry (the pre-flight one was resolved from the now-evicted
cache).

structuredContent presence is checked with === undefined (SEP-2106:
0/false/''/null are legal) and validation is skipped on isError.
…utputSchema/structuredContent in {result:…}

A tool registered with a non-object output schema (z.array(...), z.string(),
discriminated/anyOf compositions, …) now serves both protocol eras. The
projection lives in the WIRE CODEC — server-side code stays era-blind:

- 2026-07-28 client: the natural schema and the natural structuredContent
  value are sent as-is (the 2026 codec's encodeResult / projectCallToolResult
  are identity for the wrap).
- ≤ 2025-11-25 client: the 2025 codec's encodeResult('tools/list', …) wraps
  the advertised outputSchema in {type:'object',properties:{result:<natural>},
  required:['result']} (same-document $ref/$dynamicRef pointers rewritten to
  the new #/properties/result root; mirrors the C# SDK's
  TransformOutputSchemaForLegacyWire), and the 2025 codec's
  projectCallToolResult wraps the matching structuredContent as
  {result:<value>}. Both follow the SAME advertised-schema root (never the
  runtime value shape) so the listing and the call cannot diverge.

The era-agnostic SEP-2106 §4.3 TextContent auto-append also moves behind
projectCallToolResult (both codecs): a {type:'text', text: JSON.stringify
(value)} block is appended when the handler returned non-object
structuredContent and no text block of its own.

Server gains a public `codec` getter so McpServer (and low-level tools/call
handlers) route the result-side projection through it; McpServer's tools/list
handler is now era-blind (encodeResult applies the wrap). _isModernEra() and
the wrap helpers are deleted from mcp.ts.

structuredContent presence in validateToolOutput is checked with
=== undefined (0/false/''/null are legal SEP-2106 values and are validated
against the schema).

lowLevelLegacyWrap.test.ts: a low-level Server (NOT McpServer) on a 2025
connection — handler returns a non-object outputSchema/structuredContent,
wire bytes carry the wrapped form. Proves the projection lives in the codec.
… expected-failures

Conformance:
- everythingClient: register the json-schema-ref-no-deref scenario (a plain
  connect → listTools → close — output schemas compile lazily on the first
  callTool and the underlying engine never fetches external refs).
- everythingServer: rewrite the json_schema_2020_12_tool inputSchema as a
  hand-authored JSON Schema (via fromJsonSchema) so the SEP-1613 keywords
  the scenario checks ($schema/$defs/$anchor/allOf/anyOf/if-then-else/
  additionalProperties) survive tools/list verbatim.
- expected-failures: burn json-schema-ref-no-deref (both legs) and
  json-schema-2020-12 (2026 leg).

e2e: new scenarios/jsonschema.test.ts + 12 requirements.ts rows covering
the SEP-1613/SEP-2106 validator posture — same-document $ref resolves;
unsupported $schema dialect surfaces as a clear InvalidParams; one
uncompilable schema (engine MissingRefError) is isolated per-tool;
non-object outputSchema/structuredContent round-trip on 2026-07-28;
2020-12 prefixItems is enforced by default; falsy structuredContent is
treated as present; the auto TextContent fallback fires (and the author
opt-out suppresses it); and on the 2025 era a non-object output schema /
structured content is wrapped in {result:…} with same-document $ref
pointers rewritten.

clientGuide.examples.ts: structuredContent narrowing example.
migration.md / migration-SKILL.md: new "JSON Schema 2020-12 posture"
section covering the Ajv2020 default (with the v1 draft-07 opt-back
recipe), the structuredContent: unknown source-level break and narrowing
pattern, the legacy {result:…} wrap for non-object outputSchema /
structuredContent (with the $ref-pointer rewrite), and the
provably-object-shaped typeless-root stamping rule. External $ref is
documented as not-dereferenced (unchanged from v1; engine MissingRefError,
surfaced per-tool on callTool) — not a new break.

client.md / server.md: structuredContent narrowing example; drop the
obsolete "use a type alias not an interface" note (structuredContent is no
longer index-signatured).

schema-validators example: new list-forecasts tool (array outputSchema /
structuredContent) demonstrating the auto TextContent fallback, the
known-server cast idiom on the modern leg, and the {result:…} unwrap on the
legacy leg.

Changesets: sep-2106-dialect-posture (minor across core/client/server);
client-response-cache-substrate updated for the per-tool InvalidParams
behavior.
…te position-aware + $id-scoped, narrow projectCallToolResult surface, changeset major
… changeset documents Server.projectCallToolResult; e2e comment compile→validate
…af wire/textFallback.ts (break value cycle)
…acy wrap; drop dead Wire2025CallToolResult export
@felixweinberger felixweinberger force-pushed the fweinberger/sep-2106-json-schema branch from 0ef7f54 to 448bf5e Compare June 24, 2026 10:15
Comment thread docs/server.md
Comment on lines 128 to 131
);
```

> [!NOTE]
> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`:
>
> ```ts
> type BmiResult = { bmi: number }; // assignable
> interface BmiResult {
> bmi: number;
> } // type error
> ```
>
> Alternatively, spread the value: `structuredContent: { ...result }`.

### `ResourceLink` outputs

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 docs/server.md (the server-authoring feature guide) gained no prose for the new SEP-2106 server-side behavior this PR introduces — non-object outputSchema roots / any-JSON-value structuredContent in registerTool, the auto-appended {type:'text', text: JSON.stringify(value)} content block (and how to opt out), and the legacy {result:…} wrap / Server.projectCallToolResult path for low-level handlers — even though the PR touched this exact spot (removing the obsolete interface-vs-type NOTE) and updated docs/client.md for the client half. A short subsection under Tools with a pointer to docs/migration.md would close the gap.

Extended reasoning...

What is missing

This PR introduces significant new server-authoring behavior: (1) registerTool now accepts an outputSchema with any JSON Schema root (e.g. z.array(...)) and structuredContent may be any JSON value; (2) when a handler returns non-object structuredContent without authoring its own type:'text' block, the SDK auto-appends {type:'text', text: JSON.stringify(value)} on every era — content the author never wrote, visible to LLM-facing hosts; (3) toward 2025-era clients the outputSchema and structuredContent are wrapped in a {result:…} envelope, and low-level Server authors must route their tools/call results through the new public Server.projectCallToolResult() themselves.

None of this appears in docs/server.md, the prose feature guide for server authors. A grep of the file shows the only outputSchema/structuredContent mentions are the pre-existing one-liner at line 103 ('optionally an outputSchema for structured return values') and the object-rooted BMI example (lines ~119/125) — there is no mention of SEP-2106 anywhere.

Why this is a gap introduced by this PR

The PR touched exactly this region of docs/server.md (lines 128–141): it deleted the now-obsolete interface-vs-type NOTE block under the BMI example (correct, since structuredContent is now unknown and the documented compile error no longer occurs) but added no replacement prose describing the new capability. Meanwhile the client-side half of the same feature was documented in this PR — docs/client.md gained the SEP-2106 'any JSON value' wording and the !== undefined + narrowing pattern — and docs/migration.md, the changeset, and examples/schema-validators all gained server-side coverage. So the server feature guide is the one prose doc left without coverage, an internal inconsistency within the PR. The repo review checklist explicitly asks for prose documentation (not just JSDoc) for new features.

Concrete walk-through of the cost

  1. A new server author (not a v1→v2 upgrader, so they never read docs/migration.md) reads docs/server.md § Tools to learn how to return structured output. They see only the object-rooted BMI example and the line 'optionally an outputSchema for structured return values'.
  2. They have a tool that naturally returns a list, so they either (a) needlessly wrap it in {items: [...]} because they believe the root must be an object, or (b) use z.array(...) and are then surprised when an SDK-injected JSON.stringify text block shows up in their tool's content (visible to the LLM host) with no documented explanation or opt-out.
  3. A low-level Server author returning non-object structuredContent from a hand-written tools/call handler never learns about server.projectCallToolResult() from the feature guide and ships wire-illegal results to 2025-era clients.

Why nothing else covers it

docs/migration.md § 'Non-object outputSchema and the legacy {result:…} wrap' covers all of this in detail, but it is the v1→v2 migration guide — its audience is upgraders, not new authors reading the canonical server guide. The changeset is release-notes material, and the examples README is discoverable only if you already know the feature exists. Earlier review rounds on docs/server.md only addressed the stale NOTE block; none asked for the SEP-2106 subsection.

How to fix

Add a short subsection under Tools in docs/server.md (a few sentences plus optionally a small example) covering:

  • outputSchema may have any JSON Schema root (SEP-2106), and structuredContent may be any JSON value;
  • when a handler returns non-object structuredContent with no text block of its own, the SDK auto-appends {type:'text', text: JSON.stringify(value)} on every era — author your own text block to opt out;
  • toward 2025-era clients the schema/value are wrapped in a {result:…} envelope automatically by McpServer; low-level tools/call handler authors should call Server.projectCallToolResult();
  • a pointer to docs/migration.md § JSON Schema 2020-12 posture for the full details.

Docs-only change; no code impact.

@felixweinberger felixweinberger merged commit 979232e into v2-2026-07-28 Jun 24, 2026
18 checks passed
@felixweinberger felixweinberger deleted the fweinberger/sep-2106-json-schema branch June 24, 2026 10:38
Comment on lines +100 to +112
if (
!this._userAjv &&
'$schema' in schema &&
typeof schema.$schema === 'string' &&
!DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, ''))
) {
const declared = schema.$schema.slice(0, 200);
throw new Error(
`JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` +
`The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` +
`Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Upgrading only the client to v2 hard-blocks structured-output tools advertised by unmodified v1.x TS-SDK servers: those servers auto-stamp $schema: http://json-schema.org/draft-07/schema# on every Zod-derived outputSchema (via zod-to-json-schema's default target), the new dialect check in AjvJsonSchemaValidator.getValidator() rejects any non-2020-12 $schema with the default engine, and callTool()'s pre-flight converts that into ProtocolError(InvalidParams) before the request is sent — pre-PR this was warn-and-skip, so the tool stayed callable. Consider best-effort compiling draft-07/2019-09 with the 2020-12 engine, degrading unsupported-dialect to per-tool skip-validation-with-warn (keeping the hard failure for ref-denied/engine errors), or at minimum documenting the new-client-vs-old-server consequence in migration.md (it currently only addresses schema authors).

Extended reasoning...

What the bug is

The new dialect check at ajvProvider.ts:100-112 throws for any $schema that is not a 2020-12 URI when the default engine is in use (_userAjv false; the equivalent check exists in cfWorkerProvider.ts). On the client, Client._compileOutputValidator() captures that throw as {ok: false, compileError}, and the new assertCompiled() pre-flight in callTool() converts it into ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …unsupported dialect…") before any request is sent. The path is era-agnostic — output-validator compilation runs on every negotiated protocol version, and the frozen 2025 wire ToolSchema's catchall preserves the $schema key from a legacy server's listing — so the tool becomes uncallable on that connection regardless of era.

Why this hits real, deployed peers (not just schema authors)

The migration guide frames the dialect rejection around three populations of schema authors upgrading their own server. But the client-side check fires on schemas advertised by a peer the client user does not control, and the most common peer in the wild stamps draft-07 automatically:

  • The v1.x TypeScript SDK (@modelcontextprotocol/sdk 1.x — pinned at 1.29.0 in this repo's lockfile as the conformance referee's dependency, with zod-to-json-schema 3.25.2 in its dependency list) converts registerTool's Zod outputSchema via zodToJsonSchema(schema, { strictUnions: true }). zod-to-json-schema's default jsonSchema7 target places "$schema": "http://json-schema.org/draft-07/schema#" at the root of the emitted document, and the v1 SDK does not strip it. So every v1 TS-SDK server registering a tool with a Zod outputSchema advertises a draft-07-stamped outputSchema — the server author never wrote a $schema at all.
  • This repo itself acknowledges that peers advertise draft-07-stamped tool schemas: the e2e requirement tools:input-schema:preserve-schema-dialect (test/e2e/scenarios/tools.test.ts ~889-912) round-trips a tool with $schema: http://json-schema.org/draft-07/schema# verbatim, and there is a corpus fixture with-explicit-draft-07-input-schema.json. Input schemas are unaffected because they are never compiled by the client; outputSchema is the one surface that is compiled — exactly where the new rejection lands.

Step-by-step proof

  1. A user upgrades only their host/client to v2 (post-PR). SUPPORTED_PROTOCOL_VERSIONS still includes 2025-06-18 / 2025-03-26 / 2024-11-05, so they connect to an existing, unmodified v1.x TS-SDK server exposing a tool with a Zod outputSchema (structured output has been a v1 feature since spec 2025-06-18 and is widely used).
  2. listTools() succeeds — every tool is listed, draft-07 stamp included (the stamp survives the wire parse via the catchall).
  3. callTool('that-tool') resolves the output validator pre-flight. _compileOutputValidator() passes the advertised schema to AjvJsonSchemaValidator.getValidator(); the dialect check at ajvProvider.ts:100-112 sees http://json-schema.org/draft-07/schema#, which is not in DRAFT_2020_12_URIS, and throws.
  4. assertCompiled() converts the captured failure into ProtocolError(InvalidParams) and the call fails without ever reaching the server. Every subsequent call to that tool on the connection fails the same way.
  5. Pre-PR (and in v1↔v1), the same schema compiled fine on the draft-07 Ajv default (validateSchema: false), and even an uncompilable schema degraded to console.warn + skip-validation — the tool stayed callable. Post-PR the only escape hatch is replacing the client's entire validator (new AjvJsonSchemaValidator(new Ajv({...}))), which downgrades the dialect for all connected servers and requires the user to first diagnose why a remote tool suddenly errors with "invalid outputSchema".

Why existing safeguards don't prevent it

  • This PR's legacy-interop machinery (the {result:…} wrap, the era-gated projections) addresses the new server ↔ old client direction; nothing addresses the reverse (new client ↔ old server), which is where this fires.
  • The dialect check has no per-tool degrade path. Unlike an unresolvable $ref (where refusing to call is arguably the safe choice), an unsupported-dialect schema only affects the client-side validation safety net — the tool itself is perfectly callable, and SEP-2106 asks implementations to handle unsupported dialects "gracefully". Hard-blocking the call is strictly worse for the user than skipping validation, or than best-effort compiling draft-07 with the 2020-12 engine (Ajv2020 with the SDK's strict:false/validateSchema:false config compiles the simple object schemas zod-to-json-schema emits without issue).
  • No test covers the draft-07-stamped-outputSchema-from-a-peer interop case: dialect.test/validators.test pin the rejection mechanism itself, and the e2e unsupported-dialect-graceful body uses a fictional v99 dialect URI — so the v1-installed-base consequence never surfaces in CI.
  • The prior review comments on the dialect posture (PR-description routing claim, the opt-back snippet fidelity, the Ajv2020 re-export, the fromJsonSchema opt-back surface) are all about prose/docs accuracy for schema authors; none weighs the consequence that the most-deployed MCP server SDK injects the draft-07 stamp itself.

Impact

This is a cross-version interop regression with the most-deployed MCP server SDK, triggered by upgrading the client alone, with no action available to the affected user other than swapping the whole validator engine — which contradicts this PR's own stated legacy-interop goal (the {result:…} wrap exists precisely so tools stay callable across versions).

Suggested fixes

  1. Special-case draft-07 (and 2019-09): compile with the default 2020-12 engine anyway — possibly with a one-time console.warn — reserving the hard unsupported-dialect failure for genuinely unknown dialect URIs; or
  2. Downgrade the unsupported-dialect outcome on the client from "tool uncallable" to "validation skipped for this tool" (warn once), keeping the hard pre-flight failure only for ref-denied / engine-compile errors; or
  3. At minimum, document the new-client-vs-old-server consequence explicitly in migration.md (the three affected populations listed there are all schema authors) and provide a per-client opt-out that does not require constructing a full custom Ajv instance.

felixweinberger added a commit that referenced this pull request Jun 24, 2026
…per-dialect validation, non-object structuredContent (#2337)
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