feat(core,server,client): SEP-2106 JSON Schema 2020-12 — $ref guard, per-dialect validation, non-object structuredContent#2337
Conversation
🦋 Changeset detectedLatest commit: 448bf5e The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…peless-root stamping, result mutation, dead-surface JSDoc
…peless-root stamping, result mutation, dead-surface JSDoc
21988e6 to
8623fba
Compare
…solve, isCallToolResult widening, isError skip, opt-back snippet
…le-level warn-once, recursive object-shape check, Ajv2020 customization docs
There was a problem hiding this comment.
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 checkif (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 typedunknownthat 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.structuredContenttounknownviaWidenStructuredContent(packages/core/src/types/types.ts:401-409) and migrates the structured-output examples to the SEP-2106 pattern: presence checked with!== undefinedand the value narrowed before property access. The PR updatespackages/client/src/client/client.examples.ts(#Client_callTool_structuredOutput) and thecallToolJSDoc accordingly, anddocs/migration.md/docs/migration-SKILL.mdexplicitly instruct users to replaceif (!result.structuredContent)with the=== undefinedform. 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.mdThe
#callTool_structuredOutputregion inexamples/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 callingstructuredContent"a machine-readable JSON object". After this PRstructuredContentis typedunknownand may legally be any JSON value — includingnull,0,false,''— which the falsy check misclassifies as absent. This is the byte-identical example the PR already migrated inclient.examples.tsand thecallToolJSDoc, 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 ownfalsy-structured-content-validatede2e fixture). Theif (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.mdinterface-vs-type-alias NOTEdocs/server.md(~lines 131-141) contains a NOTE telling authors to use a type alias rather than an interface forstructuredContent, because named interfaces lack implicit index signatures and so are not assignable to{ [key: string]: unknown }; it marksinterface BmiResult { bmi: number }as a type error and recommends thestructuredContent: { ...result }spread workaround. That was true pre-PR (the field wasRecord<string, unknown>). After this PR the handler return path uses the widened publicCallToolResult(ToolCallback's result type), sostructuredContentisunknown— any value, including a named-interface-typed object, is assignable. The PR's own new test inpackages/server/test/server/mcp.compat.test.tsdemonstrates this: evenstructuredContent: 'not-an-array'compiles, with a comment noting that runtimevalidateToolOutputis 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 inclient.examples.ts, and adjust the docs/client.md introducing sentence ("a machine-readable JSON object" → "any JSON value, typedunknown"); regenerate the rendered snippet. - Delete the docs/server.md NOTE, or replace it with the new guidance:
structuredContentis typedunknown, so TypeScript no longer checks the handler's value against the declaredoutputSchema— runtimevalidateToolOutputis 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.
- In
…ceiling), ToolResultContent widen, migration prose alignment
…ing-surface structuredContent widen
…eaks cf-workers), reword to 'install ajv yourself', clientGuide/server.md SEP-2106 alignment
…peless-root stamping, result mutation, dead-surface JSDoc
…solve, isCallToolResult widening, isError skip, opt-back snippet
…le-level warn-once, recursive object-shape check, Ajv2020 customization docs
…ceiling), ToolResultContent widen, migration prose alignment
dfdc1a7 to
d05a7eb
Compare
…ing-surface structuredContent widen
…eaks cf-workers), reword to 'install ajv yourself', clientGuide/server.md SEP-2106 alignment
d05a7eb to
65a36f3
Compare
…af wire/textFallback.ts (break value cycle)
…acy wrap; drop dead Wire2025CallToolResult export
2db8455 to
dd1f952
Compare
3bd0f08 to
cdaf056
Compare
…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
b0c9edc to
0ef7f54
Compare
…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
…n drops → wraps
0ef7f54 to
448bf5e
Compare
| ); | ||
| ``` | ||
|
|
||
| > [!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 |
There was a problem hiding this comment.
🟡 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
- A new server author (not a v1→v2 upgrader, so they never read
docs/migration.md) readsdocs/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'. - 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) usez.array(...)and are then surprised when an SDK-injectedJSON.stringifytext block shows up in their tool'scontent(visible to the LLM host) with no documented explanation or opt-out. - A low-level
Serverauthor returning non-objectstructuredContentfrom a hand-writtentools/callhandler never learns aboutserver.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:
outputSchemamay have any JSON Schema root (SEP-2106), andstructuredContentmay be any JSON value;- when a handler returns non-object
structuredContentwith notextblock of its own, the SDK auto-appends{type:'text', text: JSON.stringify(value)}on every era — author your owntextblock to opt out; - toward 2025-era clients the schema/value are wrapped in a
{result:…}envelope automatically byMcpServer; low-leveltools/callhandler authors should callServer.projectCallToolResult(); - a pointer to
docs/migration.md§ JSON Schema 2020-12 posture for the full details.
Docs-only change; no code impact.
| 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.` | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔴 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/sdk1.x — pinned at 1.29.0 in this repo's lockfile as the conformance referee's dependency, withzod-to-json-schema3.25.2 in its dependency list) convertsregisterTool's ZodoutputSchemaviazodToJsonSchema(schema, { strictUnions: true }). zod-to-json-schema's defaultjsonSchema7target 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$schemaat 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 fixturewith-explicit-draft-07-input-schema.json. Input schemas are unaffected because they are never compiled by the client;outputSchemais the one surface that is compiled — exactly where the new rejection lands.
Step-by-step proof
- A user upgrades only their host/client to v2 (post-PR).
SUPPORTED_PROTOCOL_VERSIONSstill 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 ZodoutputSchema(structured output has been a v1 feature since spec 2025-06-18 and is widely used). listTools()succeeds — every tool is listed, draft-07 stamp included (the stamp survives the wire parse via the catchall).callTool('that-tool')resolves the output validator pre-flight._compileOutputValidator()passes the advertised schema toAjvJsonSchemaValidator.getValidator(); the dialect check at ajvProvider.ts:100-112 seeshttp://json-schema.org/draft-07/schema#, which is not inDRAFT_2020_12_URIS, and throws.assertCompiled()converts the captured failure intoProtocolError(InvalidParams)and the call fails without ever reaching the server. Every subsequent call to that tool on the connection fails the same way.- 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 toconsole.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'sstrict:false/validateSchema:falseconfig 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.testpin the rejection mechanism itself, and the e2eunsupported-dialect-gracefulbody 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
- 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 - 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
- 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.
…per-dialect validation, non-object structuredContent (#2337)
Implements SEP-2106 (tool
inputSchema/outputSchemaconform to JSON Schema 2020-12;structuredContentmay 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
$schemadialects gracefully, and (c) accept any JSON value asstructuredContent. The Node default validator was draft-07 withstrict:false(2020-12 keywords silently ignored), andstructuredContentwas typed/parsed as a record.How Has This Been Tested?
validators.test.ts(Ajv2020 default viaprefixItems;$schemarejection; custom-instance bypass),standardSchema.test.ts(output-arm typeless-root handling),publicTypeShapes.test.ts(8 type-equality pins + the frozen-2025-wire invariant).test/e2e/scenarios/jsonschema.test.ts): same-document$refok, unsupported-dialect graceful, bad-schema isolates per-tool, non-object output,prefixItems(pins Ajv2020), falsystructuredContentvalidated, default-is-2020-12, primitive sc, array TextContent fallback (+ author opt-out), 2025-era wrap (schema + value +$refrewrite).json-schema-ref-no-deref(client) andjson-schema-2020-12(server) burned from expected-failures.examples/schema-validatorsruns on all four transport×era legs (run:examples):list-forecastsreturns arraystructuredContent; modern clients receive the array, legacy clients receive{result: [...]}plus the auto-injected JSON text block.=== undefinedfalsy guard each fails the suite.Breaking Changes
Two, documented in
docs/migration.md§ JSON Schema:CallToolResult.structuredContentis now typedunknown(wasRecord<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.strict:false). Absent$schemadefaults to 2020-12;$schemadeclaring anything other than 2020-12 is rejected with a typed error pointing at the custom-instance escape hatch. Pass a pre-configured Ajv instance toAjvJsonSchemaValidator(ajv)to validate other dialects.External
$refis not a new break: neither built-in engine has ever fetched network refs (Ajv withoutloadSchemadoesn't;@cfworker/json-schemahas no fetch path). An unresolved external$refraises an engine compile error, surfaced per-tool via the lazy-compile path — same as before, just now lazily isolated so it doesn't poisontools/list.Types of changes
Checklist
Additional context
types/schemas.ts, +19/−14): four leaf-field changes (structuredContent→z.unknown();outputSchema→ openlooseObject). 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, andspecTypeSchema.tscarry zero diff in this PR.createDefaultAjvInstance()→Ajv2020; both providers carry an inline$schemacheck rejecting non-2020-12 declarations. The draft-07Ajvclass stays re-exported for the documented opt-back.outputSchemafails to compile (engine error, unsupported dialect) does not poisontools/list; the failure is stored on the response cache's per-tool entry and surfaced asProtocolError(InvalidParams)when that tool is called. Compile state invalidates with the cached tool definition.{result:…}wrap lives inrev2025Codec—encodeResult('tools/list', …)projects a non-object-rootoutputSchemaas{type:'object', properties:{result: <natural>}, required:['result']}(with$refJSON-pointers rewritten under#/properties/result), andprojectCallToolResultwraps non-objectstructuredContentas{result: value}. A{type:'text', text: JSON.stringify(sc)}block is auto-appended when the handler authored none.McpServeris era-blind:tools/callcallsthis.server.codec.projectCallToolResult(…)andtools/listflows throughencodeResult, 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.