Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0e72d7e
feat(core): SEP-2106 $ref/bounds guard, graceful-dialect error, per-d…
felixweinberger Jun 22, 2026
34932db
fix(client): lazy outputSchema compile — one bad schema does not pois…
felixweinberger Jun 22, 2026
b2b697a
feat(core): SEP-2106 output-root and structuredContent widen
felixweinberger Jun 22, 2026
a38b284
feat(server,client): SEP-2106 runtime — falsy fixes, TextContent fall…
felixweinberger Jun 22, 2026
e047e48
test(conformance,e2e): SEP-2106 fixture handlers + e2e rows; burn jso…
felixweinberger Jun 22, 2026
a7a73c3
docs(migration,examples): SEP-2106/1613 migration guide, changesets, …
felixweinberger Jun 22, 2026
ffe719d
feat(core): simplify to 2020-12-only JSON Schema validation
felixweinberger Jun 22, 2026
fdc6910
fix(client,core,server): #2337 review — stale compile-error cache, ty…
felixweinberger Jun 22, 2026
4941ea2
docs(migration): note typeless-root outputSchema correctness trade-of…
felixweinberger Jun 22, 2026
6365dd7
fix(core): stamp type:'object' on object-only union output roots; SUB…
felixweinberger Jun 22, 2026
3e6b90d
fix(client,core,docs): #2337 review-3 — recovery-path validator re-re…
felixweinberger Jun 22, 2026
d26e218
fix(core,server): #2337 review-4 — Tool outputSchema type widen, modu…
felixweinberger Jun 22, 2026
e2ad5f0
fix(core,docs): #2337 review-5 + CI — drop Ajv2020 re-export (tsdown …
felixweinberger Jun 22, 2026
b494000
fix(core,client,server): #2337 review-6 — ajv optional peerDep, sampl…
felixweinberger Jun 23, 2026
d05a7eb
fix(client,server,docs): #2337 review-7 + CI — revert ajv peerDep (br…
felixweinberger Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/client-response-cache-substrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

`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.

**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.
**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: …")` with `data.reason` set to the structured kind, 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.
7 changes: 7 additions & 0 deletions .changeset/sep-2106-dialect-posture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': patch
---

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)`. `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). McpServer drops a non-object `outputSchema` from the legacy (≤ 2025-11-25) `tools/list` projection with a warn-once, and 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).
7 changes: 7 additions & 0 deletions .changeset/sep-2106-ref-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

SEP-2106 (security tightening): the built-in JSON Schema validator providers refuse to compile a schema carrying a non-same-document `$ref`/`$dynamicRef` (anything other than `""`, `#/$defs/…`, or `#anchor`), a `$schema` dialect URI the engine does not recognise, or composition exceeding the depth/subschema-count bounds, throwing `SchemaCompileError` with a structured `reason`. External-reference dereferencing has never been on a network path in this SDK; this makes the rejection explicit and structured. On the client, one tool with a refused output schema no longer poisons `tools/list` — every tool stays listed and the compile failure surfaces lazily as `ProtocolError(InvalidParams, …, { reason })` when that tool is called. `assertSchemaSafeToCompile`, `isSameDocumentRef`, and `SchemaCompileError` are exported for custom `jsonSchemaValidator` implementations to inherit the same defaults.
12 changes: 8 additions & 4 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,17 +269,21 @@ const result = await client.callTool({
console.log(result.content);
```

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:
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:

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

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

Expand Down
14 changes: 13 additions & 1 deletion docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound

`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).

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.
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)` with `data.reason` set to the structured kind, 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.

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
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
Expand Down Expand Up @@ -661,6 +661,18 @@ Validator behavior:
`@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface.

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
`SchemaCompileError{kind:'unsupported-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. Non-same-document `$ref`/`$dynamicRef` is rejected at compile time with `SchemaCompileError`.

| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
| 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`) |
| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |

## 15. Migration Steps (apply in this order)

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