Skip to content

Commit e2ad5f0

Browse files
fix(core,docs): #2337 review-5 + CI — drop Ajv2020 re-export (tsdown ceiling), ToolResultContent widen, migration prose alignment
1 parent d26e218 commit e2ad5f0

11 files changed

Lines changed: 75 additions & 39 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: …")` 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.

docs/migration-SKILL.md

Lines changed: 2 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)` 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.
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
@@ -670,6 +670,7 @@ JSON Schema 2020-12 posture (SEP-1613 / SEP-2106): the default validator support
670670
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
671671
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
672672
| 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 |
673674
| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
674675

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

docs/migration.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,10 @@ New `ClientOptions`:
594594

595595
The `ResponseCacheStore` interface gained `delete(key)` (the per-URI invalidation `notifications/resources/updated` drives) — custom stores written against the alpha substrate need to add it. The default `InMemoryResponseCacheStore` is now bounded (default 512 entries, oldest-first eviction; configurable via `{ maxEntries }`).
596596

597-
**Output-schema validator lifecycle (every era):** 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
598-
and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is
599-
unchanged at the wire level but is observably different at the validator-lifecycle level.
597+
**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now
598+
`listTools()` succeeds (every tool stays listed) and the compile failure is captured per-tool. Calling `callTool()` on the affected tool then throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` with `data.reason` set to the structured kind, **before
599+
the request is sent** — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at
600+
the validator-lifecycle level.
600601

601602
### `InMemoryTransport` moved
602603

@@ -1454,7 +1455,7 @@ The default validator supports **JSON Schema 2020-12 only** (the spec's only MUS
14541455

14551456
- **You declared 2020-12 keywords (`$defs`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`) in a server schema and they were silently ignored.** They are now enforced. If a previously "passing" tool input or output starts failing validation, the schema was always
14561457
wrong on the wire — fix the schema or the data.
1457-
- **You authored draft-07 idioms via `fromJsonSchema()`** (e.g. tuple `items: [...]` instead of `prefixItems`, draft-07 `definitions`). Port to 2020-12 spelling, or pass a draft-07 Ajv instance per the opt-back below.
1458+
- **You authored draft-07 idioms via `fromJsonSchema()`** (e.g. tuple `items: [...]` instead of `prefixItems`, draft-07 `definitions`). Port to 2020-12 spelling, or pass a draft-07 Ajv instance **as the second argument**`fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — built per the opt-back recipe below. The `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema()`-authored schemas (`fromJsonSchema()` compiles eagerly with the package-level default unless a validator is passed directly).
14581459
- **You imported `Ajv` from the SDK's validator subpath and relied on the re-export being the draft-07 class.** It still is — `Ajv` remains the draft-07 class (re-exported for the opt-back), but it is **no longer** what the SDK uses by default.
14591460

14601461
To validate other dialects, pass a pre-configured Ajv instance:
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
/**
2-
* Customisation entry point for the AJV validator. Re-exports `Ajv2020`, `Ajv`, and `addFormats`
3-
* from the SDK's bundled copy, so customising the validator needs no extra installs. Use `Ajv2020`
4-
* for a custom instance — `Ajv` is the draft-07 class (kept for opting back to the pre-SEP-1613
5-
* default) and would silently downgrade dialect.
2+
* Customisation entry point for the AJV validator. Re-exports `Ajv` and `addFormats` from the
3+
* SDK's bundled copy. For a custom 2020-12 instance, import `Ajv2020` directly from
4+
* `'ajv/dist/2020.js'` (`ajv` is a peer dependency of this subpath; `Ajv2020` is not re-exported
5+
* here because its type graph tips downstream declaration bundling — tracked in #2339). The
6+
* re-exported `Ajv` is the **draft-07** class, kept for opting back to the pre-SEP-1613 default,
7+
* and would silently downgrade dialect if used for routine customisation.
68
*
79
* @example
810
* ```ts
9-
* import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';
11+
* import { Ajv2020 } from 'ajv/dist/2020.js';
12+
* import { addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';
1013
*
1114
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
1215
* addFormats(ajv);
1316
* const validator = new AjvJsonSchemaValidator(ajv);
1417
* ```
1518
*/
16-
export { addFormats, Ajv, Ajv2020, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';
19+
export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core/validators/ajv';

packages/core/src/types/specTypeSchema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,8 @@ type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync<SpecTyp
237237
type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] };
238238

239239
/**
240-
* SEP-2106: the public `CallToolResult` / `CompatibilityCallToolResult` types widen
241-
* `structuredContent` to `unknown`, and the public `Tool` / `ListToolsResult` types widen
240+
* SEP-2106: the public `CallToolResult` / `CompatibilityCallToolResult` / `ToolResultContent` types
241+
* widen `structuredContent` to `unknown`, and the public `Tool` / `ListToolsResult` types widen
242242
* `outputSchema` to a loose JSON Schema document; the era-neutral runtime schema in `schemas.ts`
243243
* keeps the 2025 record / `type:'object'` shapes for wire-parse byte-identity (Q10-L2). Override the
244244
* registered runtime validators here so `specTypeSchemas` / `isSpecType` accept the public shape
@@ -251,6 +251,7 @@ const PublicToolSchema = schemas.ToolSchema.extend({
251251
const SCHEMA_OVERRIDES: Partial<Record<ProtocolSchemaKey, z.ZodType>> = {
252252
CallToolResultSchema: PublicCallToolResultSchema,
253253
CompatibilityCallToolResultSchema: PublicCallToolResultSchema.or(schemas.ResultSchema.extend({ toolResult: z.unknown() })),
254+
ToolResultContentSchema: schemas.ToolResultContentSchema.extend({ structuredContent: z.unknown().optional() }),
254255
ToolSchema: PublicToolSchema,
255256
ListToolsResultSchema: schemas.ListToolsResultSchema.extend({ tools: z.array(PublicToolSchema) })
256257
};

packages/core/src/types/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ export type ToolUseContent = Infer<typeof ToolUseContentSchema>;
407407
* in the specification for at least twelve months. Migrate to calling LLM
408408
* provider APIs directly.
409409
*/
410-
export type ToolResultContent = Infer<typeof ToolResultContentSchema>;
410+
export type ToolResultContent = WidenStructuredContent<Infer<typeof ToolResultContentSchema>>;
411411
export type EmbeddedResource = Infer<typeof EmbeddedResourceSchema>;
412412
export type ResourceLink = Infer<typeof ResourceLinkSchema>;
413413
export type ContentBlock = Infer<typeof ContentBlockSchema>;

packages/core/src/validators/ajvProvider.examples.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
* @module
88
*/
99

10-
import { addFormats, Ajv2020, AjvJsonSchemaValidator } from './ajvProvider.js';
10+
import { Ajv2020 } from 'ajv/dist/2020.js';
11+
12+
import { addFormats, AjvJsonSchemaValidator } from './ajvProvider.js';
1113

1214
/**
1315
* Example: Default AJV instance.
@@ -22,11 +24,13 @@ function AjvJsonSchemaValidator_default() {
2224
/**
2325
* Example: Custom AJV instance.
2426
*
25-
* Use the re-exported `Ajv2020` class so the custom instance keeps validating JSON Schema 2020-12
26-
* (SEP-1613). Passing `new Ajv(...)` (the draft-07 class) would silently downgrade dialect.
27+
* Import `Ajv2020` directly from `'ajv/dist/2020.js'` (`ajv` is a peer dependency of this subpath)
28+
* so the custom instance keeps validating JSON Schema 2020-12 (SEP-1613). Passing `new Ajv(...)`
29+
* (the draft-07 class) would silently downgrade dialect.
2730
*/
2831
function AjvJsonSchemaValidator_customInstance() {
2932
//#region AjvJsonSchemaValidator_customInstance
33+
// import { Ajv2020 } from 'ajv/dist/2020.js';
3034
const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
3135
const validator = new AjvJsonSchemaValidator(ajv);
3236
//#endregion AjvJsonSchemaValidator_customInstance
@@ -36,11 +40,13 @@ function AjvJsonSchemaValidator_customInstance() {
3640
/**
3741
* Example: Custom AJV instance with formats registered.
3842
*
39-
* `Ajv2020` and `addFormats` are re-exported from this module so customising the validator
40-
* requires no extra `package.json` dependencies — both come from the SDK's bundled copy.
43+
* `addFormats` is re-exported from this module; `Ajv2020` is imported directly from
44+
* `'ajv/dist/2020.js'` (peer dependency — its type graph tips downstream declaration bundling so
45+
* it is not re-exported; tracked in #2339).
4146
*/
4247
function AjvJsonSchemaValidator_withFormats() {
4348
//#region AjvJsonSchemaValidator_withFormats
49+
// import { Ajv2020 } from 'ajv/dist/2020.js';
4450
const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
4551
addFormats(ajv);
4652
const validator = new AjvJsonSchemaValidator(ajv);

packages/core/src/validators/ajvProvider.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,13 @@ function createDefaultAjvInstance(): AjvLike {
4747

4848
/**
4949
* AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv`
50-
* for the customisation entry point (re-exports `Ajv2020`, `Ajv`, and `addFormats` from the
51-
* bundled copy).
50+
* for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy).
5251
*
5352
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
5453
* `$schema` are rejected with `SchemaCompileError{kind:'unsupported-dialect'}`; pass a
55-
* pre-configured Ajv instance to validate other dialects. When passing a custom instance, use the
56-
* re-exported `Ajv2020` class — `new Ajv(...)` is the draft-07 class and would silently downgrade
57-
* dialect.
54+
* pre-configured Ajv instance to validate other dialects. When passing a custom instance, import
55+
* `Ajv2020` directly from `'ajv/dist/2020.js'` (`ajv` is a peer dependency of this subpath) —
56+
* `new Ajv(...)` is the draft-07 class and would silently downgrade dialect.
5857
*
5958
* @example Use with default configuration
6059
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default"
@@ -63,12 +62,14 @@ function createDefaultAjvInstance(): AjvLike {
6362
*
6463
* @example Use with a custom AJV instance
6564
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance"
65+
* // import { Ajv2020 } from 'ajv/dist/2020.js';
6666
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
6767
* const validator = new AjvJsonSchemaValidator(ajv);
6868
* ```
6969
*
7070
* @example Register ajv-formats
7171
* ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_withFormats"
72+
* // import { Ajv2020 } from 'ajv/dist/2020.js';
7273
* const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true });
7374
* addFormats(ajv);
7475
* const validator = new AjvJsonSchemaValidator(ajv);
@@ -148,14 +149,23 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
148149
}
149150

150151
/**
151-
* Draft-07 AJV class, re-exported for consumers who need to opt back to the pre-SEP-1613 default
152-
* (`new AjvJsonSchemaValidator(new Ajv({ strict: false }))`).
152+
* Draft-07 AJV class, re-exported for consumers who need to opt back to the pre-SEP-1613 default.
153+
* The full v1-equivalent construction is:
154+
*
155+
* ```ts
156+
* const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true });
157+
* addFormats(ajv);
158+
* new AjvJsonSchemaValidator(ajv);
159+
* ```
160+
*
161+
* (omitting `validateSchema: false` makes a 2020-12-stamped `$schema` fail with an opaque
162+
* "no schema with key or ref …" engine error; omitting `addFormats` silently drops `format`
163+
* validation that the v1 default had).
164+
*
165+
* `Ajv2020` is **not** re-exported here (its type graph tips downstream declaration bundling;
166+
* tracked in #2339); import it directly from `'ajv/dist/2020.js'` — `ajv` is a peer dependency
167+
* of this subpath.
153168
*/
154169
export { Ajv } from 'ajv';
155-
/**
156-
* 2020-12 AJV class — the dialect the default provider uses (SEP-1613). Re-exported so a custom
157-
* instance can be passed without an extra `ajv` install and without silently downgrading to draft-07.
158-
*/
159-
export { Ajv2020 } from 'ajv/dist/2020.js';
160170
/** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */
161171
export { addFormats };

packages/core/test/spec.types.2025-11-25.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,10 @@ const sdkTypeChecks = {
569569
},
570570
ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => {
571571
sdk = spec;
572-
spec = sdk;
572+
// SEP-2106: the public SDK type widens `structuredContent` to `unknown`; the 2025 spec
573+
// type pins it at `{ [key: string]: unknown }`. spec→sdk holds (record ⊂ unknown); the
574+
// reverse intentionally does not.
575+
spec = sdk as typeof sdk & { structuredContent?: { [key: string]: unknown } };
573576
},
574577
SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => {
575578
sdk = spec;

0 commit comments

Comments
 (0)