Skip to content

Commit 3e6b90d

Browse files
fix(client,core,docs): #2337 review-3 — recovery-path validator re-resolve, isCallToolResult widening, isError skip, opt-back snippet
1 parent 6365dd7 commit 3e6b90d

8 files changed

Lines changed: 68 additions & 17 deletions

File tree

.changeset/sep-2106-dialect-posture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
'@modelcontextprotocol/server': patch
55
---
66

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; pass `new AjvJsonSchemaValidator(new Ajv({ strict: false }))` to opt back. `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+
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).

docs/migration-SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -665,12 +665,12 @@ JSON Schema 2020-12 posture (SEP-1613 / SEP-2106): the default validator support
665665
`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
666666
`!== undefined`, not falsy. Non-same-document `$ref`/`$dynamicRef` is rejected at compile time with `SchemaCompileError`.
667667

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 | `new AjvJsonSchemaValidator(new Ajv({ strict: false }))` (import `Ajv` from `…/validators/ajv`) |
673-
| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
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+
| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
674674

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

docs/migration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,10 +1460,12 @@ The default validator supports **JSON Schema 2020-12 only** (the spec's only MUS
14601460
To validate other dialects, pass a pre-configured Ajv instance:
14611461

14621462
```typescript
1463-
import { Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
1463+
import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
14641464

14651465
// Opt back to the v1 (draft-07) default — accepted structurally; the $schema check is skipped.
1466-
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { jsonSchemaValidator: new AjvJsonSchemaValidator(new Ajv({ strict: false })) });
1466+
const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true });
1467+
addFormats(ajv);
1468+
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { jsonSchemaValidator: new AjvJsonSchemaValidator(ajv) });
14671469
```
14681470

14691471
#### Non-same-document `$ref` is rejected — use `#/$defs/…` or `#anchor`

packages/client/src/client/client.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2182,20 +2182,21 @@ export class Client extends Protocol<ClientContext> {
21822182
// must agree — and is compiled in isolation (never written to the cache). The cache read
21832183
// is guarded: a custom store whose `get()` rejects routes to `onerror` and degrades to
21842184
// skipping validation (same outcome as a cold cache).
2185-
const compiled =
2185+
let compiled =
21862186
options?.toolDefinition === undefined
21872187
? await this._cache
21882188
.outputValidator(params.name, tool => this._compileOutputValidator(tool))
21892189
.catch(error => void this._reportStoreError(error))
21902190
: this._compileOutputValidator(options.toolDefinition);
2191-
if (compiled !== undefined && !compiled.ok) {
2191+
const assertCompiled = (): void => {
2192+
if (compiled === undefined || compiled.ok) return;
21922193
const compileError = compiled.compileError;
21932194
const message = (compileError instanceof Error ? compileError.message : String(compileError)).slice(0, 200);
21942195
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool '${params.name}' has an invalid outputSchema: ${message}`, {
21952196
reason: compileError instanceof SchemaCompileError ? compileError.reason.kind : 'invalid-schema'
21962197
});
2197-
}
2198-
const validator = compiled?.validator;
2198+
};
2199+
assertCompiled();
21992200

22002201
// The method-keyed request() path validates the era registry's plain
22012202
// CallToolResult schema — with the result map aligned to the typed
@@ -2235,9 +2236,19 @@ export class Client extends Protocol<ClientContext> {
22352236
// `HEADER_MISMATCH` — the refetch failure is observable only
22362237
// through `onerror`.
22372238
await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_));
2239+
// Re-resolve the output validator against the freshly-fetched entry — the pre-flight
2240+
// `compiled` was resolved from the now-evicted cache and may be stale (different
2241+
// outputSchema) or absent (cold cache on the first attempt). The recovery path is only
2242+
// entered when `options.toolDefinition` is undefined, so the cache is the sole source.
2243+
// Re-run the same fail-fast compile-error check before issuing the retry.
2244+
compiled = await this._cache
2245+
.outputValidator(params.name, tool => this._compileOutputValidator(tool))
2246+
.catch(error_ => void this._reportStoreError(error_));
2247+
assertCompiled();
22382248
result = await this.request({ method: 'tools/call', params }, await buildSendOptions());
22392249
}
22402250

2251+
const validator = compiled !== undefined && compiled.ok ? compiled.validator : undefined;
22412252
if (validator) {
22422253
// If tool has outputSchema, it MUST return structuredContent (unless it's an error).
22432254
// SEP-2106: presence is `=== undefined`, not falsy — `null`/`0`/`false`/`""` are legal
@@ -2251,7 +2262,7 @@ export class Client extends Protocol<ClientContext> {
22512262
}
22522263

22532264
// Only validate structured content if present (not when there's an error)
2254-
if (result.structuredContent !== undefined) {
2265+
if (result.structuredContent !== undefined && !result.isError) {
22552266
try {
22562267
// Validate the structured content against the schema
22572268
const validationResult = validator(result.structuredContent);

packages/core/src/types/guards.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as z from 'zod/v4';
2+
13
import {
24
CallToolResultSchema,
35
InitializedNotificationSchema,
@@ -71,6 +73,13 @@ export const isJSONRPCErrorResponse = (value: unknown): value is JSONRPCErrorRes
7173
*/
7274
export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => JSONRPCResponseSchema.safeParse(value).success;
7375

76+
/**
77+
* Narrows to the public {@linkcode CallToolResult} type (`structuredContent: unknown` per
78+
* SEP-2106); the underlying era-neutral runtime schema keeps the 2025 record shape for
79+
* wire-parse byte-identity (Q10-L2), so we widen locally rather than touch `schemas.ts`.
80+
*/
81+
const PublicCallToolResultSchema = CallToolResultSchema.extend({ structuredContent: z.unknown().optional() });
82+
7483
/**
7584
* Checks if a value is a valid {@linkcode CallToolResult}.
7685
*
@@ -79,13 +88,17 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J
7988
* (e.g. `resultType`) still passes through the loose index signature. Use a
8089
* transport-level parse to validate raw wire traffic.
8190
*
91+
* Narrows to the public {@linkcode CallToolResult} type (`structuredContent: unknown` per
92+
* SEP-2106); the underlying era-neutral runtime schema keeps the 2025 record shape for
93+
* wire-parse byte-identity.
94+
*
8295
* @param value - The value to check.
8396
*
8497
* @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise.
8598
*/
8699
export const isCallToolResult = (value: unknown): value is CallToolResult => {
87100
if (typeof value !== 'object' || value === null || !('content' in value)) return false;
88-
return CallToolResultSchema.safeParse(value).success;
101+
return PublicCallToolResultSchema.safeParse(value).success;
89102
};
90103

91104
/**

packages/core/src/types/specTypeSchema.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type * as z from 'zod/v4';
1+
import * as z from 'zod/v4';
22

33
import {
44
OAuthClientInformationFullSchema,
@@ -236,6 +236,18 @@ type SpecTypeInputs = {
236236
type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync<SpecTypeInputs[K], SpecTypes[K]> };
237237
type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] };
238238

239+
/**
240+
* SEP-2106: the public `CallToolResult` / `CompatibilityCallToolResult` types widen
241+
* `structuredContent` to `unknown`; the era-neutral runtime schema in `schemas.ts` keeps the 2025
242+
* record shape for wire-parse byte-identity (Q10-L2). Override the registered runtime validators
243+
* here so `specTypeSchemas` / `isSpecType` accept the public shape without touching `schemas.ts`.
244+
*/
245+
const PublicCallToolResultSchema = schemas.CallToolResultSchema.extend({ structuredContent: z.unknown().optional() });
246+
const SCHEMA_OVERRIDES: Partial<Record<ProtocolSchemaKey, z.ZodType>> = {
247+
CallToolResultSchema: PublicCallToolResultSchema,
248+
CompatibilityCallToolResultSchema: PublicCallToolResultSchema.or(schemas.ResultSchema.extend({ toolResult: z.unknown() }))
249+
};
250+
239251
const _specTypeSchemas: Record<string, StandardSchemaV1> = {};
240252
const _isSpecType: Record<string, (value: unknown) => boolean> = {};
241253
function register(key: string, schema: z.ZodType): void {
@@ -245,7 +257,7 @@ function register(key: string, schema: z.ZodType): void {
245257
}
246258
for (const key of SPEC_SCHEMA_KEYS) {
247259
// eslint-disable-next-line import/namespace -- key is constrained to keyof typeof schemas via the satisfies clause above
248-
register(key, schemas[key]);
260+
register(key, SCHEMA_OVERRIDES[key] ?? schemas[key]);
249261
}
250262
for (const [key, schema] of Object.entries(authSchemas)) {
251263
register(key, schema);

packages/core/test/types/guards.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ describe('isCallToolResult', () => {
107107
).toBe(true);
108108
});
109109

110+
it('SEP-2106: accepts widened structuredContent (arrays / primitives)', () => {
111+
expect(isCallToolResult({ content: [], structuredContent: [1, 2, 3] })).toBe(true);
112+
expect(isCallToolResult({ content: [], structuredContent: 0 })).toBe(true);
113+
expect(isCallToolResult({ content: [], structuredContent: 'x' })).toBe(true);
114+
expect(isCallToolResult({ content: [], structuredContent: null })).toBe(true);
115+
});
116+
110117
it('returns false for non-objects', () => {
111118
expect(isCallToolResult(null)).toBe(false);
112119
expect(isCallToolResult(42)).toBe(false);

packages/core/test/types/specTypeSchema.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ describe('isSpecType', () => {
5252
expect(isSpecType.CallToolResult('string')).toBe(false);
5353
});
5454

55+
it('CallToolResult — SEP-2106 widened structuredContent (override applied)', () => {
56+
expect(isSpecType.CallToolResult({ content: [], structuredContent: [1, 2, 3] })).toBe(true);
57+
expect(isSpecType.CallToolResult({ content: [], structuredContent: 0 })).toBe(true);
58+
expect(isSpecType.CompatibilityCallToolResult({ content: [], structuredContent: 'x' })).toBe(true);
59+
});
60+
5561
it('ContentBlock — accepts text block, rejects wrong shape', () => {
5662
expect(isSpecType.ContentBlock({ type: 'text', text: 'hi' })).toBe(true);
5763
expect(isSpecType.ContentBlock({ type: 'text' })).toBe(false);

0 commit comments

Comments
 (0)