Skip to content

Commit 65a36f3

Browse files
docs: SEP-2106/1613 migration + changesets + schema-validators 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.
1 parent c4c95a2 commit 65a36f3

9 files changed

Lines changed: 149 additions & 26 deletions

File tree

.changeset/client-response-cache-substrate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
`Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws `SdkError` with `SdkErrorCode.ListPaginationExceeded` on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. Entries are automatically scoped by the connected server's identity and (when set) the consumer-supplied `cachePartition`, so a shared store does not collide across servers or principals; evictions are likewise scoped to the connected server's partitions.
66

7-
**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only (previously `listTools()` threw). A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level.
7+
**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. `listTools()` no longer throws on an uncompilable `outputSchema` (every tool stays listed; the compile failure is captured per-tool); calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` before the request is sent — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/client': minor
4+
'@modelcontextprotocol/server': minor
5+
---
6+
7+
SEP-1613 / SEP-2106 (JSON Schema 2020-12 posture): the Node default JSON Schema validator is now `Ajv2020` (true draft 2020-12) instead of the draft-07 `Ajv` class — `$defs`/`prefixItems`/`unevaluatedProperties`/`dependentRequired` are now enforced where they were previously silently ignored; to opt back, construct the draft-07 instance with the v1 defaults — `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv);` — and pass `new AjvJsonSchemaValidator(ajv)`. Schemas declaring a `$schema` other than 2020-12 are rejected with a clear error rather than mis-validating. `outputSchema` may now have a non-object root and `CallToolResult.structuredContent` is widened to `unknown` (a deliberate source-level break for typed consumers — see the migration guide for the narrowing pattern). Toward 2025-era clients McpServer wraps a non-object `outputSchema` (and the matching `structuredContent`) in a `{result: …}` envelope so the tool stays callable, with same-document `$ref` pointers rewritten to keep resolving; it also auto-appends a `TextContent` JSON serialisation when a handler returns non-object `structuredContent` without its own text block. The `structuredContent` presence check is `!== undefined` (not falsy) on both sides. Thanks @mattzcarey (#2249).

docs/client.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,17 +269,21 @@ const result = await client.callTool({
269269
console.log(result.content);
270270
```
271271

272-
Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM:
272+
Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM:
273273

274274
```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput"
275275
const result = await client.callTool({
276276
name: 'calculate-bmi',
277277
arguments: { weightKg: 70, heightM: 1.75 }
278278
});
279279

280-
// Machine-readable output for the client application
281-
if (result.structuredContent) {
282-
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
280+
// Machine-readable output for the client application. SEP-2106: structuredContent is
281+
// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use.
282+
if (result.structuredContent !== undefined) {
283+
const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 }
284+
if (typeof sc === 'object' && sc !== null && 'bmi' in sc) {
285+
console.log(sc.bmi);
286+
}
283287
}
284288
```
285289

docs/migration-SKILL.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound
574574

575575
`Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` / `readResource()` now honour the server-stamped SEP-2549 `ttlMs`/`cacheScope`: a still-fresh cached entry is returned without a round trip. Opt-in by server hint — a server that sends `ttlMs: 0` (the SDK's default stamp) sees no behaviour change. Per-call override: pass `{ cacheMode: 'refresh' }` (always fetch and re-store) or `{ cacheMode: 'bypass' }` (fetch without touching the cache). Server `ttlMs` is clamped at 24 h (`MAX_CACHE_TTL_MS`). Entries are automatically scoped by connected-server identity; new `ClientOptions.cachePartition` (per-principal slot for `'private'`-scoped entries on a shared `responseCacheStore`; default `''`) and `ClientOptions.defaultCacheTtlMs` (TTL when the result lacks one, e.g. legacy-era responses; default `0`). `ResponseCacheStore` gained `delete(key)` (driven by `notifications/resources/updated`); `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512).
576576

577-
Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only.
577+
Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry); `listTools()` no longer throws on an uncompilable `outputSchema` — every tool stays listed and the compile failure is captured per-tool. Calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` before the request is sent (validation is never silently skipped). Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only.
578578

579579
No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed
580580
there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt
@@ -661,6 +661,18 @@ Validator behavior:
661661
`@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
662662
- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface.
663663

664+
JSON Schema 2020-12 posture (SEP-1613 / SEP-2106): the default validator supports JSON Schema 2020-12 only (the spec's only MUST) — on Node it is now `Ajv2020` instead of draft-07 `Ajv`. Schemas declaring a different `$schema` are rejected with a clear `Error("…unsupported
665+
dialect…")`; to validate other dialects, pass a pre-configured Ajv instance: `new AjvJsonSchemaValidator(new Ajv({...}))`. `CallToolResult.structuredContent` is typed `unknown` (was `{ [k: string]: unknown }`). The presence check is `!== undefined`, not falsy. External `$ref`
666+
is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). Toward 2025-era clients a non-object `outputSchema`/`structuredContent` is wrapped in a `{result:…}` envelope.
667+
668+
| v1 pattern | Mechanical fix |
669+
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
670+
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
671+
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
672+
| relying on default `Ajv` being draft-07 | `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv); new AjvJsonSchemaValidator(ajv)` (import `Ajv`, `addFormats` from `…/validators/ajv`) |
673+
| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
674+
| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
675+
664676
## 15. Migration Steps (apply in this order)
665677

666678
1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages

0 commit comments

Comments
 (0)