diff --git a/.changeset/streamable-http-sse-keepalive.md b/.changeset/streamable-http-sse-keepalive.md new file mode 100644 index 0000000000..705fa5f8f4 --- /dev/null +++ b/.changeset/streamable-http-sse-keepalive.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add configurable SSE keep-alive comment frames to Streamable HTTP transports and apply `createMcpHandler`'s existing `keepAliveMs` option to every HTTP SSE stream it serves. diff --git a/.changeset/validator-dialect-dispatch.md b/.changeset/validator-dialect-dispatch.md new file mode 100644 index 0000000000..e715eb4f49 --- /dev/null +++ b/.changeset/validator-dialect-dispatch.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +The default validator now honors declared 2019-09 and draft-07/06 dialects instead of rejecting them: a schema stamped `"$schema": "http://json-schema.org/draft-07/schema#"` (zod-to-json-schema's default output) validates with draft-07 semantics, and a 2019-09 stamp (zod-to-json-schema's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still validate as 2020-12, and unknown dialects still produce the typed error (now listing the supported dialects: 2020-12, 2019-09, draft-07, draft-06). diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 0b85f2fc87..b88c6f032e 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -707,10 +707,9 @@ the host side and register the result with `fromJsonSchema()`: zod-4 input via z own `z.toJSONSchema(z.object(shape), { io: 'input', target: 'draft-2020-12' })` (the conversion is runtime-structural, so a zod ≥4.2 in the host handles schemas built by a different zod-4 copy), zod-3 input via the -[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Strip -the `$schema` member from the converted output before passing it to `fromJsonSchema()` -— `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default -validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106). +[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Its +default draft-07 `$schema` stamp is fine as-is — the default validator +[honors declared draft-07/06 dialects](#json-schema-2020-12-posture-sep-1613-sep-2106). How a too-old zod surfaces depends on which entry point your code imports. With main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly @@ -1455,9 +1454,19 @@ classes — import it from one package consistently within a process. #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) -The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` -instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas -declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. +The default validator dispatches on the schema's declared `$schema`: absent or 2020-12 +validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07 +`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09 +`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or +draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other +`$schema` are rejected with `Error("…unsupported dialect…")`. Two known draft-07 engine +differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords +adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the +browser/Workers engine ignores them per spec; and the browser/Workers engine does not +resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema +keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when +that dependency triggers (surfaced as the SDK's typed validation error), while the +Node engine handles the same schema correctly. `CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to `unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is @@ -1465,13 +1474,13 @@ declaring a different `$schema` are rejected with `Error("…unsupported dialect `$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). -| v1 pattern | Mechanical fix | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | -| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | -| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` 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` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | +| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | +| draft-07 idioms **without** a declared `$schema` (a declared draft-07/06 `$schema` dispatches automatically) | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | +| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1fc325b7e4..bf96270c36 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -154,6 +154,10 @@ Rewrite the imports: The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above. +## `SSE stream disconnected: TypeError: terminated` + +HTTP SSE streams emit a `: keepalive` comment every 15 seconds by default so client body-idle timeouts and intermediaries do not terminate an otherwise idle connection. Configure the interval with `keepAliveMs` on the transport or `createMcpHandler`; set it to `0` to disable heartbeats. + ## Recap - Every heading on this page is the exact message you searched for. diff --git a/packages/core-internal/src/validators/ajvProvider.ts b/packages/core-internal/src/validators/ajvProvider.ts index 89768093a1..e33adb741f 100644 --- a/packages/core-internal/src/validators/ajvProvider.ts +++ b/packages/core-internal/src/validators/ajvProvider.ts @@ -3,21 +3,13 @@ */ import { Ajv as Draft7Ajv } from 'ajv'; +import { Ajv2019 } from 'ajv/dist/2019.js'; import { Ajv2020 } from 'ajv/dist/2020.js'; import _addFormats from 'ajv-formats'; +import { declaredDialect } from './dialects'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; -/** - * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema - * declares anything else, the default provider throws a plain `Error` with a clear message rather - * than letting the engine crash on an opaque internal error or silently mis-validate. - */ -const DRAFT_2020_12_URIS: ReadonlySet = new Set([ - 'https://json-schema.org/draft/2020-12/schema', - 'http://json-schema.org/draft/2020-12/schema' -]); - /** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */ interface AjvLike { compile: (schema: unknown) => AjvValidateFunction; @@ -35,8 +27,8 @@ interface AjvValidateFunction { /** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */ const addFormats = _addFormats as unknown as typeof _addFormats.default; -function createDefaultAjvInstance(): AjvLike { - const ajv = new Ajv2020({ +function createDefaultAjvInstance(engineClass: typeof Ajv2020 | typeof Ajv2019 | typeof Draft7Ajv): AjvLike { + const ajv = new engineClass({ strict: false, validateFormats: true, validateSchema: false, @@ -50,8 +42,13 @@ function createDefaultAjvInstance(): AjvLike { * AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv` * for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy). * - * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different - * `$schema` are rejected with a plain `Error`; pass a pre-configured Ajv instance to validate + * Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `Ajv2020` + * (SEP-1613); 2019-09 → `Ajv2019`; draft-07 or draft-06 → the classic draft-07 `Ajv` class + * (draft-07's changes over draft-06 are additive, so one engine covers both). Known draft-07 deviation: classic Ajv + * evaluates keywords adjacent to `$ref` (stricter than draft-07's ignore-siblings rule, matching + * v1's default engine), while the cfworker provider ignores them per spec. + * Schemas declaring any other `$schema` are + * rejected with a plain `Error`; pass a pre-configured Ajv instance to validate * other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type * graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12 * instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and @@ -80,15 +77,20 @@ function createDefaultAjvInstance(): AjvLike { */ export class AjvJsonSchemaValidator implements jsonSchemaValidator { private _ajv: AjvLike | undefined; - /** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */ + /** Lazy classic (draft-07) engine, built on the first draft-07/draft-06-declared schema. */ + private _ajvDraft7: AjvLike | undefined; + /** Lazy 2019-09 engine, built on the first 2019-09-declared schema. */ + private _ajv2019: AjvLike | undefined; + /** True iff the constructor received a caller-supplied engine; the `$schema` dispatch is skipped. */ private readonly _userAjv: boolean; /** * @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is * used for **every** schema regardless of its declared `$schema` (the caller owns dialect - * choice). When omitted, the provider constructs a single `Ajv2020` instance with + * choice). When omitted, the provider constructs per-dialect engines (`Ajv2020`, `Ajv2019`, + * and the classic draft-07 `Ajv` for draft-07/06-declared schemas) with * `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and - * `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so + * `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call needing each**, so * constructing the provider (e.g. as the default validator of a `Client`/`Server` that never * validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter * is typed structurally so consumers who don't pass an instance need not have `ajv` installed. @@ -98,29 +100,36 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { this._ajv = ajv; } - /** The underlying engine — the default instance is created on first use. */ + /** The underlying 2020-12 engine — the default instance is created on first use. */ private get ajv(): AjvLike { - return (this._ajv ??= createDefaultAjvInstance()); + return (this._ajv ??= createDefaultAjvInstance(Ajv2020)); } - getValidator(schema: JsonSchemaType): JsonSchemaValidator { - // Caller supplied a specific engine — do not second-guess by `$schema` - // (bring-your-own-validator means bring-your-own-dialect). - 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.` - ); + /** + * Pick the engine for a schema's declared dialect. A caller-supplied engine is used for + * every schema — do not second-guess by `$schema` (bring-your-own-validator means + * bring-your-own-dialect). Otherwise: no `$schema` or 2020-12 → `Ajv2020`; 2019-09 → + * `Ajv2019`; draft-07 or draft-06 → classic `Ajv`; anything else → `Error`. + */ + private _engineFor(schema: JsonSchemaType): AjvLike { + if (this._userAjv) { + return this.ajv; + } + const dialect = declaredDialect( + schema, + 'pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.' + ); + if (dialect === '2020-12') { + return this.ajv; + } + if (dialect === '2019-09') { + return (this._ajv2019 ??= createDefaultAjvInstance(Ajv2019)); } + return (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv)); + } - const engine = this.ajv; + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + const engine = this._engineFor(schema); const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (engine.getSchema(schema.$id) ?? engine.compile(schema)) diff --git a/packages/core-internal/src/validators/cfWorkerProvider.ts b/packages/core-internal/src/validators/cfWorkerProvider.ts index 8af5ff6393..fe876bf9b6 100644 --- a/packages/core-internal/src/validators/cfWorkerProvider.ts +++ b/packages/core-internal/src/validators/cfWorkerProvider.ts @@ -10,6 +10,7 @@ import { Validator } from '@cfworker/json-schema'; +import { declaredDialect } from './dialects'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; /** @@ -17,23 +18,23 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSche */ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; -/** - * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema - * declares anything else and no `{draft}` is forced, the provider throws a plain `Error`. - */ -const DRAFT_2020_12_URIS: ReadonlySet = new Set([ - 'https://json-schema.org/draft/2020-12/schema', - 'http://json-schema.org/draft/2020-12/schema' -]); - /** * `@cfworker/json-schema`-backed JSON Schema validator. See * `@modelcontextprotocol/{client,server}/validators/cf-worker` for the customisation entry point. * - * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different - * `$schema` are rejected with a plain `Error`. Passing an explicit `draft` to the constructor + * Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `'2020-12'` + * (SEP-1613); 2019-09 → `'2019-09'`; draft-07 or draft-06 → `'7'`. Schemas declaring any other `$schema` are rejected + * with a plain `Error`. Passing an explicit `draft` to the constructor * overrides this — that draft is used for every schema regardless of `$schema`. * + * Known draft-07 engine gap: `@cfworker/json-schema` does not treat draft-07 `dependencies` as + * a name→subschema map during `$ref` collection, so a dependency entry whose KEY collides with + * a JSON Schema keyword (`type`, `default`, `format`, `required`, `pattern`, …) and whose + * subschema contains a `$ref` throws `Unresolved $ref` at validation time when the dependency + * triggers — data-dependently, not at compile. The Node (classic Ajv) engine handles the same + * schema correctly. Callers that hit this receive the SDK's typed validation error (the throw + * is captured by the validation paths), and the deviation is pinned by a recorded-contract test. + * * @example Use with default configuration (2020-12, shortcircuit on) * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" * const validator = new CfWorkerJsonSchemaValidator(); @@ -58,14 +59,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @param options - Configuration options * @param options.shortcircuit - If `true`, stop validation after first error (default: `true`) * @param options.draft - JSON Schema draft version to force for every schema. When set, the - * `$schema` check is skipped. When omitted, the provider validates as 2020-12 and rejects - * schemas declaring a different `$schema`. + * `$schema` dispatch is skipped. When omitted, the provider dispatches on each schema's + * declared `$schema` (2020-12, 2019-09, draft-07, draft-06; absent means 2020-12) and rejects others. */ constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { this.shortcircuit = options?.shortcircuit ?? true; this.draft = options?.draft; } + /** + * Pick the engine draft for a schema's declared dialect (a caller-forced `{draft}` bypasses + * this — do not second-guess by `$schema`). No `$schema` or 2020-12 → `'2020-12'`; 2019-09 → + * `'2019-09'`; draft-07 or draft-06 → `'7'`; anything else → `Error`. + */ + private _draftFor(schema: JsonSchemaType): CfWorkerSchemaDraft { + const dialect = declaredDialect(schema, 'pass an explicit { draft } to CfWorkerJsonSchemaValidator to validate other dialects.'); + return dialect === 'draft-7' ? '7' : dialect; + } + /** * Create a validator for the given JSON Schema * @@ -75,22 +86,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { - // Caller forced a draft — use it for everything; do not second-guess by `$schema`. - if ( - this.draft === undefined && - '$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 an explicit ` + - `{ draft } to CfWorkerJsonSchemaValidator to validate other dialects.` - ); - } - - const draft = this.draft ?? '2020-12'; + const draft = this.draft ?? this._draftFor(schema); // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible const validator = new Validator(schema as ConstructorParameters[0], draft, this.shortcircuit); diff --git a/packages/core-internal/src/validators/dialects.ts b/packages/core-internal/src/validators/dialects.ts new file mode 100644 index 0000000000..01ce37ee27 --- /dev/null +++ b/packages/core-internal/src/validators/dialects.ts @@ -0,0 +1,61 @@ +/** + * Declared-dialect classification shared by the default validator providers. + */ + +import type { JsonSchemaType } from './types'; + +/** + * Canonical `$schema` URIs per supported dialect (http + https variants, trailing-`#` stripped). + */ +const DRAFT_2020_12_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); +const DRAFT_2019_09_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2019-09/schema', + 'http://json-schema.org/draft/2019-09/schema' +]); +const DRAFT_07_URIS: ReadonlySet = new Set(['https://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema']); +const DRAFT_06_URIS: ReadonlySet = new Set(['https://json-schema.org/draft-06/schema', 'http://json-schema.org/draft-06/schema']); + +/** + * Dialects the default providers dispatch on. draft-06 maps to `'draft-7'`: draft-07 only adds + * keywords over draft-06 (`if`/`then`/`else`), and enforcing them on a draft-06 schema is the + * accepted downlevel. + */ +export type DeclaredDialect = '2020-12' | '2019-09' | 'draft-7'; + +/** + * Whether a `$schema` value declares the 2019-09 dialect — the only supported dialect with + * `$recursiveRef`/`$recursiveAnchor`. Non-throwing (unlike {@linkcode declaredDialect}) so + * wire-layer callers can consult it for documents whose dialect may be unsupported. + */ +export function declares2019Dialect($schema: unknown): boolean { + return typeof $schema === 'string' && DRAFT_2019_09_URIS.has($schema.replace(/#$/, '')); +} + +/** + * Classify a schema's declared `$schema` dialect. No `$schema` (or a non-string one) means + * 2020-12. Any other dialect throws a plain `Error` with a clear message rather than letting the + * engine crash on an opaque internal error or silently mis-validate; `remedy` names the calling + * provider's escape hatch in that message. + */ +export function declaredDialect(schema: JsonSchemaType, remedy: string): DeclaredDialect { + if (!('$schema' in schema) || typeof schema.$schema !== 'string') { + return '2020-12'; + } + const declared = schema.$schema.replace(/#$/, ''); + if (DRAFT_2020_12_URIS.has(declared)) { + return '2020-12'; + } + if (DRAFT_2019_09_URIS.has(declared)) { + return '2019-09'; + } + if (DRAFT_07_URIS.has(declared) || DRAFT_06_URIS.has(declared)) { + return 'draft-7'; + } + throw new Error( + `JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). ` + + `The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}` + ); +} diff --git a/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts b/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts index 9e80dd3c31..97d6835773 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts @@ -14,6 +14,8 @@ * and never be re-derived in shared/ or server-side code. */ +import { declares2019Dialect } from '../../validators/dialects'; + /** * Whether a JSON Schema's root is non-object: either an explicit non-object * `type`, or a typeless root such as `{anyOf:[…]}`. Object-shaped typeless @@ -44,9 +46,63 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet = new Set([ 'patternProperties', '$defs', 'definitions', - 'dependentSchemas' + 'dependentSchemas', + // draft-07's dependentSchemas predecessor; its array-of-strings form is + // unaffected (string arrays contain no refs). + 'dependencies' ]); +/** + * Whether a subtree's `$id` establishes a new resolution base. A fragment-only + * `$id` (`"#item"`, the draft-07/06 spelling of 2020-12's `$anchor`) does not + * change the RFC 3986 base URI — same-document pointers inside still resolve + * against the document root and must be rewritten. + */ +function establishesNewBase(id: unknown): boolean { + return id !== undefined && !(typeof id === 'string' && id.startsWith('#')); +} + +/* + * Reference/base-affecting keyword coverage across the four supported dialects + * (2020-12, 2019-09, draft-07, draft-06). Every entry is rewritten, position-guarded, + * or N/A with the reason; legacyWrap.test.ts pins each handled row: + * - `$ref` (all dialects): JSON-Pointer forms `#`/`#/…` rewritten; other values untouched. + * - `$dynamicRef` (2020-12): pointer forms rewritten like `$ref`; plain-name form (`#name`) + * untouched — see `$anchor`. + * - `$dynamicAnchor` (2020-12): N/A — location-independent plain name; the envelope adds no + * anchors and the natural schema moves whole, so dynamic resolution is unchanged. + * - `$anchor` (2019-09/2020-12): N/A — same as `$dynamicAnchor`; `#name` refs are fragments, + * not pointers, and are never rewritten. + * - `$recursiveRef` (2019-09): handled only in documents whose DECLARED dialect is 2019-09. + * Elsewhere it is off-spec input copied verbatim — KNOWN LIMITATION: the engines already + * disagree on it there (only classic Ajv ignores it; Ajv2020 and @cfworker enforce it in + * every draft mode), so no consistent wrap behavior exists — the verbatim `#` mis-resolves + * to the envelope on the enforcing legs, while converting would manufacture a constraint + * on the classic-Ajv leg. Inherent to envelope relocation plus engine disagreement; the + * escape is the 2026 era or authoring the schema with a proper 2019-09 stamp. Within + * 2019-09: value restricted to `#`, and per core §8.2.4.2.1 a root-base ref is dynamic + * only when the DOCUMENT ROOT carries `$recursiveAnchor: true` (a non-root anchor can + * never be the initial target and is inert). Root-anchor-less documents: converted to the + * statically equivalent `$ref: '#/properties/result'`; when `$ref` co-occurs (legal, + * conjunctive in 2019-09) the conversion joins as an `allOf` entry instead. Documents + * whose root carries `$recursiveAnchor: true`: left verbatim — KNOWN LIMITATION: + * relocation cannot preserve dynamic re-resolution (a static rewrite would freeze it and + * the envelope root carries no anchor), so root-anchored recursion still mis-resolves on + * the 2025 projection. + * - `$recursiveAnchor` (2019-09): boolean, not a location — only the ROOT occurrence gates the + * conversion above; nested occurrences are copied verbatim and stay inert. + * - `$id`, URI form (all dialects): establishes a new base — subtree skipped (root and nested). + * - `$id`, fragment form (draft-07/06 anchor spelling; illegal in 2019-09/2020-12): does not + * change the base — descended into. + * - `$schema`: hoisted to the wrapper root so dialect dispatch and the graceful + * unsupported-dialect rejection see it. + * - `$vocabulary` (2019-09/2020-12): N/A — meta-schema-only keyword, inert in tool schemas. + * - Name→subschema maps (`properties`, `patternProperties`, `$defs`, `definitions`, + * `dependentSchemas`, draft-07/06 `dependencies`): entries are name positions; their keys + * are never treated as keywords. + * - Data-position keywords (`const`/`enum`/`default`/`examples`): values are instance data, + * never descended into. + */ /** * Wrap a non-object output schema in the 2025-era envelope: * `{type:'object', properties:{result:}, required:['result']}`. @@ -60,33 +116,47 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet = new Set([ * The rewrite is position-aware: data-valued keywords * (`const`/`enum`/`default`/`examples`) in keyword position are NOT descended * into; the same names appearing as property names under - * `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas` - * ARE descended into (they're subschemas). The rewrite is also `$id`-scoped: - * if the natural root carries `$id` no pointer is rewritten (same-document - * refs inside resolve against the embedded `$id` base, not the wrapper root), - * and any subtree that establishes its own `$id` is left untouched for the - * same reason. + * `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas`/ + * `dependencies` ARE descended into (they're subschemas). The rewrite is also + * `$id`-scoped: if the natural root carries a base-establishing `$id` no + * pointer is rewritten (same-document refs inside resolve against the embedded + * `$id` base, not the wrapper root), and any subtree that establishes its own + * `$id` is left untouched for the same reason. Fragment-only `$id` (`"#item"`, + * draft-07's anchor spelling) does not establish a base and IS descended into. */ export function wrapOutputSchemaForLegacy(natural: Readonly>): Record { // A root `$schema` is hoisted to the wrapper root: it's a document-level - // dialect declaration and the SEP-1613 dialect checks (both built-in - // providers) only inspect the root, so leaving it under `properties.result` - // would make a non-2020-12 schema pass the dialect check on the 2025 - // projection while the same tool is rejected on the 2026 era. + // dialect declaration and the built-in providers' dialect dispatch only + // inspects the root, so leaving it under `properties.result` would make + // the wrapper compile under the default 2020-12 engine (an opaque Ajv2020 + // compile error on draft-07 tuple-form `items` instead of the classic + // engine's tuple semantics) while the same tool dispatches to the declared + // dialect on the 2026 era — and hide an unsupported dialect from the + // graceful rejection. const $schema = typeof natural['$schema'] === 'string' ? natural['$schema'] : undefined; - // `$id` at the natural root: every same-document `#/…` ref inside resolves - // against that base URI, not against the wrapper root — skip the rewrite. - if (natural['$id'] !== undefined) { + // A base-establishing `$id` at the natural root: every same-document `#/…` + // ref inside resolves against that base URI, not against the wrapper root — + // skip the rewrite. Fragment-only `$id` keeps the document-root base. + if (establishesNewBase(natural['$id'])) { return { ...($schema !== undefined && { $schema }), type: 'object', properties: { result: natural }, required: ['result'] }; } + // `$recursiveRef` is a 2019-09-only keyword: the conversion applies solely to documents + // whose DECLARED dialect is 2019-09 (one document-level check). Elsewhere it is copied + // verbatim — converting would manufacture a constraint on the classic-Ajv leg, which + // ignores the member; the legs that enforce it are a KNOWN LIMITATION (coverage block). + // Within 2019-09, a root-base `$recursiveRef: '#'` is dynamic only when the DOCUMENT + // ROOT carries `$recursiveAnchor: true` (core §8.2.4.2.1); otherwise it is statically + // `$ref: '#'`-equivalent and is converted. See the coverage block above. + const convertRecursiveRefs = declares2019Dialect(natural['$schema']) && natural['$recursiveAnchor'] !== true; const rewriteRefs = (node: unknown, parentIsNameMap: boolean): unknown => { if (Array.isArray(node)) return node.map(item => rewriteRefs(item, false)); if (node === null || typeof node !== 'object') return node; - // A nested `$id` establishes its own resolution base for the subtree — + // A nested base-establishing `$id` owns resolution for the subtree — // same-document refs inside are no longer relative to the wrapper root. // Only applies in keyword position (a property NAMED `$id` is just a name). - if (!parentIsNameMap && (node as Record)['$id'] !== undefined) return node; + if (!parentIsNameMap && establishesNewBase((node as Record)['$id'])) return node; const out: Record = {}; + let convertedRecursion = false; for (const [k, v] of Object.entries(node)) { if (parentIsNameMap) { // Name position: `k` is an author-chosen property/def name, `v` is a @@ -94,6 +164,10 @@ export function wrapOutputSchemaForLegacy(natural: Readonly { expect(engineSlot(provider)).toBe(fake); }); - it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => { + it('still rejects unknown $schema dialects before constructing the default engine', () => { const provider = new AjvJsonSchemaValidator(); - expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow( + expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-04/schema#', type: 'object' })).toThrow( /unsupported dialect/ ); // The dialect check fires before engine construction — no engine was built for the rejected schema. expect(engineSlot(provider)).toBeUndefined(); }); + + it('a draft-07 schema builds only the draft-07 engine, not the 2020-12 one', () => { + const provider = new AjvJsonSchemaValidator(); + const validate = provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' }); + expect(validate({}).valid).toBe(true); + expect(engineSlot(provider)).toBeUndefined(); + expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeDefined(); + }); + + it('a 2019-09 schema builds only the 2019-09 engine', () => { + const provider = new AjvJsonSchemaValidator(); + const validate = provider.getValidator({ $schema: 'https://json-schema.org/draft/2019-09/schema', type: 'object' }); + expect(validate({}).valid).toBe(true); + expect(engineSlot(provider)).toBeUndefined(); + expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeUndefined(); + expect((provider as unknown as { _ajv2019: unknown })._ajv2019).toBeDefined(); + }); }); diff --git a/packages/core-internal/test/validators/validators.test.ts b/packages/core-internal/test/validators/validators.test.ts index 3318b94d10..8be62b567b 100644 --- a/packages/core-internal/test/validators/validators.test.ts +++ b/packages/core-internal/test/validators/validators.test.ts @@ -11,6 +11,7 @@ import { vi } from 'vitest'; import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider'; +import { declaredDialect } from '../../src/validators/dialects'; import type { JsonSchemaType } from '../../src/validators/types'; // Test with both AJV and CfWorker validators @@ -625,15 +626,17 @@ describe('Missing dependencies', () => { }); /** - * SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers - * validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a - * schema declaring any other `$schema` is rejected with a clear `Error`. The escape hatch is - * the existing custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`). + * The spec honors a schema's declared `$schema` dialect (absent means 2020-12 per SEP-1613). + * The built-in providers dispatch: no `$schema` / 2020-12 → the 2020-12 engine; draft-07 / + * draft-06 → a draft-07 engine (draft-07's changes over draft-06 are additive). Any other + * declared dialect is rejected with a clear `Error`. The escape hatch is the existing + * custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`). * - * Discriminator: `prefixItems` is a 2020-12 keyword that the draft-07 Ajv class silently - * ignores under `strict:false`, so it proves the default engine is `Ajv2020`. + * Discriminators: `prefixItems` is a 2020-12 keyword the draft-07 engines silently ignore + * under lenient options, and the positional `items` array is draft-07's tuple form — + * together they prove which engine ran, not merely that compile stopped throwing. */ -describe('SEP-1613 $schema dialect handling (2020-12 only)', () => { +describe('$schema dialect dispatch', () => { const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#'; const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema'; const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({ @@ -643,6 +646,13 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => { }); /** Violates `prefixItems` (positions swapped). */ const PREFIX_ITEMS_BAD: unknown = ['x', 1]; + /** Draft-07 tuple form (positional `items` array — not representable in the 2020-12-shaped type). */ + const tupleItemsSchema = ($schema: string): JsonSchemaType => + ({ + $schema, + type: 'array', + items: [{ type: 'number' }, { type: 'string' }] + }) as unknown as JsonSchemaType; describe.each(validators)('$name', ({ provider }) => { it('default → Ajv2020 / 2020-12 (prefixItems is enforced)', () => { @@ -656,17 +666,143 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => { expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); }); - it('$schema: draft-07 → graceful Error', () => { - expect(() => provider.getValidator(prefixItemsSchema(DRAFT_07_URI))).toThrow(/unsupported dialect.*2020-12 only/); + it.each([ + ['draft-07 http, trailing #', 'http://json-schema.org/draft-07/schema#'], + ['draft-07 https, no #', 'https://json-schema.org/draft-07/schema'], + ['draft-06 http', 'http://json-schema.org/draft-06/schema#'], + ['draft-06 https', 'https://json-schema.org/draft-06/schema'], + ['2019-09 https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'], + ['2019-09 http', 'http://json-schema.org/draft/2019-09/schema'] + ])('$schema %s → declared-dialect tuple semantics on `items`', (_label, uri) => { + const v = provider.getValidator(tupleItemsSchema(uri)); + expect(v([1, 'x']).valid).toBe(true); + expect(v(['x', 1]).valid).toBe(false); }); - it('$schema: 2019-09 → graceful Error', () => { - expect(() => provider.getValidator(prefixItemsSchema('https://json-schema.org/draft/2019-09/schema'))).toThrow( - /unsupported dialect/ + it.each([ + ['draft-04', 'http://json-schema.org/draft-04/schema#'], + ['version-less alias', 'http://json-schema.org/schema#'], + ['garbage', 'https://example.com/my-dialect'] + ])('$schema %s → graceful Error listing supported dialects', (_label, uri) => { + expect(() => provider.getValidator(prefixItemsSchema(uri))).toThrow( + /unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06/s ); }); }); + // The shared classifier is what both providers dispatch on — pinning it directly covers + // the CfWorker side, whose engine applies both keyword sets in either draft mode (so the + // dispatch is not observable through validation results there). + describe('declaredDialect classifier', () => { + it.each([ + ['absent', undefined], + ['https', 'https://json-schema.org/draft/2020-12/schema'], + ['http, trailing #', 'http://json-schema.org/draft/2020-12/schema#'] + ])('%s → 2020-12', (_label, uri) => { + expect(declaredDialect({ ...(uri ? { $schema: uri } : {}), type: 'object' }, 'r')).toBe('2020-12'); + }); + + it.each([ + ['https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'], + ['http', 'http://json-schema.org/draft/2019-09/schema'] + ])('2019-09 %s → 2019-09', (_label, uri) => { + expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('2019-09'); + }); + + it.each([ + ['draft-07 http #', 'http://json-schema.org/draft-07/schema#'], + ['draft-07 https', 'https://json-schema.org/draft-07/schema'], + ['draft-06 http #', 'http://json-schema.org/draft-06/schema#'], + ['draft-06 https', 'https://json-schema.org/draft-06/schema'] + ])('%s → draft-7', (_label, uri) => { + expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('draft-7'); + }); + + it('unknown → throws with the remedy appended', () => { + expect(() => declaredDialect({ $schema: 'https://example.com/dialect', type: 'object' }, 'REMEDY.')).toThrow( + /unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06; REMEDY\.$/s + ); + }); + }); + + it('AJV: declared 2019-09 selects Ajv2019 (unevaluatedProperties enforced, tuple items compiles)', () => { + // Pins the engine against both wrong-engine mutations: Ajv2020 would reject the + // `items` array form at compile, and classic Ajv ignores `unevaluatedProperties`. + // (No CfWorker leg: @cfworker/json-schema applies both keyword sets in every draft mode.) + const v = new AjvJsonSchemaValidator().getValidator({ + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] } }, + unevaluatedProperties: false + } as unknown as JsonSchemaType); + expect(v({ pair: [1, 'x'] }).valid).toBe(true); + expect(v({ pair: ['x', 1] }).valid).toBe(false); // tuple semantics live + expect(v({ pair: [1, 'x'], extra: 1 }).valid).toBe(false); // 2019-09 keyword enforced + }); + + it('recorded contract: cfworker throws on $ref inside a keyword-named draft-07 `dependencies` entry', () => { + // @cfworker/json-schema does not treat draft-07 `dependencies` as a name→subschema map + // during ref collection: an entry keyed like a keyword (`type` here) with a `$ref` + // inside is skipped, and validation THROWS `Unresolved $ref` when the dependency + // triggers — data-dependently (compile succeeds; non-triggering data validates). + // Through Client.callTool this surfaces as the SDK's TYPED validation error + // (ProtocolError InvalidParams "Failed to validate structured content: …" — the + // structuredContent validation catch wraps any engine throw), never as an unhandled + // exception. The Node engine (classic Ajv) handles the same schema correctly. This + // test records that difference so a dependency bump that changes either side is caught. + const schema: JsonSchemaType = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + definitions: { addr: { type: 'string' } }, + properties: { type: { type: 'string' }, billing: {} }, + dependencies: { type: { properties: { billing: { $ref: '#/definitions/addr' } } } } + } as unknown as JsonSchemaType; + const triggering = { type: 'card', billing: 'x' }; + + const cf = new CfWorkerJsonSchemaValidator().getValidator(schema); + expect(cf({ billing: 'x' }).valid).toBe(true); // dependency not triggered + expect(() => cf(triggering)).toThrow(/Unresolved \$ref/); + + const ajv = new AjvJsonSchemaValidator().getValidator(schema); + expect(ajv(triggering).valid).toBe(true); + expect(ajv({ type: 'card', billing: 5 }).valid).toBe(false); + }); + + it('recorded contract: the engines DIVERGE on $ref siblings under draft-07', () => { + // Draft-07 says keywords adjacent to $ref MUST be ignored. Classic Ajv (v8) + // evaluates them anyway — non-configurable, and identical to v1's default + // engine — while @cfworker follows the spec. This test records that known + // difference so a dependency bump that changes either side is caught. + const schema: JsonSchemaType = { + $schema: DRAFT_07_URI, + type: 'object', + definitions: { name: { type: 'string' } }, + properties: { a: { $ref: '#/definitions/name', maxLength: 2 } }, + required: ['a'] + } as unknown as JsonSchemaType; + const data = { a: 'hello' }; + + // Node engine: maxLength next to $ref is enforced → rejected (stricter than spec). + expect(new AjvJsonSchemaValidator().getValidator(schema)(data).valid).toBe(false); + // cfworker engine: sibling ignored per draft-07 → accepted. + expect(new CfWorkerJsonSchemaValidator().getValidator(schema)(data).valid).toBe(true); + }); + + it('AJV: declared draft-07 selects the draft-07 ENGINE (prefixItems ignored, items enforced)', () => { + // Contradictory keywords: under the classic engine the `items` tuple wins and + // `prefixItems` is unknown; `Ajv2020` would enforce `prefixItems` and reject the `items` + // array form at compile — so [1,'x'] passing pins the engine. (No CfWorker leg: + // @cfworker/json-schema applies both keyword sets in either draft mode.) + const v = new AjvJsonSchemaValidator().getValidator({ + $schema: DRAFT_07_URI, + type: 'array', + items: [{ type: 'number' }, { type: 'string' }], + prefixItems: [{ type: 'string' }, { type: 'number' }] + } as unknown as JsonSchemaType); + expect(v([1, 'x']).valid).toBe(true); + expect(v(['x', 1]).valid).toBe(false); + }); + it('AJV: custom Ajv instance bypasses the $schema check (caller owns dialect)', () => { // A draft-07 Ajv passed explicitly: even with `$schema: draft-07`, the provider does not // throw — and `prefixItems` is unknown to draft-07 Ajv and silently ignored. diff --git a/packages/core-internal/test/wire/legacyWrap.test.ts b/packages/core-internal/test/wire/legacyWrap.test.ts index dfca0fa6eb..cbb6f4b856 100644 --- a/packages/core-internal/test/wire/legacyWrap.test.ts +++ b/packages/core-internal/test/wire/legacyWrap.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import type { JsonSchemaType } from '../../src/validators/types'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; import { wrapOutputSchemaForLegacy } from '../../src/wire/rev2025-11-25/legacyWrap'; import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec'; @@ -156,3 +158,235 @@ describe('rev2025Codec.projectCallToolResult: value-shape wrap', () => { expect(out.structuredContent).toEqual({ result: { a: 1 } }); }); }); + +describe('wrapOutputSchemaForLegacy: draft-07 idioms (declared-dialect schemas flow since the validators dispatch on $schema)', () => { + const DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; + + it('`dependencies` is a name→subschema map: entries keyed like data keywords (default/…) are still rewritten', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: DRAFT_07, + anyOf: [{ type: 'object', dependencies: { default: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }], + definitions: { rule: { type: 'string' } } + }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'default')).toEqual({ + $ref: '#/properties/result/definitions/rule' + }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({ + $ref: '#/properties/result/definitions/rule' + }); + }); + + it('a `dependencies` entry keyed `$id` is a name position — it does not suppress rewriting of the map', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: DRAFT_07, + anyOf: [{ type: 'object', dependencies: { $id: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }], + definitions: { rule: { type: 'string' } } + }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', '$id')).toEqual({ + $ref: '#/properties/result/definitions/rule' + }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({ + $ref: '#/properties/result/definitions/rule' + }); + }); + + it('draft-07 array-of-strings `dependencies` entries pass through untouched', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: DRAFT_07, + anyOf: [{ type: 'object', dependencies: { a: ['b', 'c'] } }] + }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'a')).toEqual(['b', 'c']); + }); + + it('a fragment-only nested $id (draft-07 anchor spelling) does not establish a new base — inner refs are rewritten', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: DRAFT_07, + type: 'array', + items: { $id: '#item', type: 'object', properties: { node: { $ref: '#/definitions/Node' } } }, + definitions: { Node: { type: 'number' } } + }); + expect(dig(wrapped, 'properties', 'result', 'items', '$id')).toBe('#item'); + expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'node')).toEqual({ + $ref: '#/properties/result/definitions/Node' + }); + }); + + it('a fragment-only ROOT $id does not suppress the rewrite either', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: DRAFT_07, + $id: '#root', + type: 'array', + items: { $ref: '#/definitions/Node' }, + definitions: { Node: { type: 'number' } } + }); + expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $ref: '#/properties/result/definitions/Node' }); + }); + + it('a URI-valued $id still suppresses the rewrite (nested and root)', () => { + const nested = wrapOutputSchemaForLegacy({ + type: 'array', + items: { $id: 'https://example.com/item', properties: { node: { $ref: '#/definitions/Node' } } }, + definitions: { Node: { type: 'number' } } + }); + expect(dig(nested, 'properties', 'result', 'items', 'properties', 'node')).toEqual({ $ref: '#/definitions/Node' }); + + const root = wrapOutputSchemaForLegacy({ + $id: 'https://example.com/root', + type: 'array', + items: { $ref: '#/definitions/Node' }, + definitions: { Node: { type: 'number' } } + }); + expect(dig(root, 'properties', 'result', 'items')).toEqual({ $ref: '#/definitions/Node' }); + }); +}); + +describe('wrapOutputSchemaForLegacy: 2019-09 recursion ($recursiveRef/$recursiveAnchor)', () => { + const URI_2019 = 'https://json-schema.org/draft/2019-09/schema'; + + it('anchor-less $recursiveRef:"#" is converted to a static $ref at the relocated root', () => { + // 2019-09 restricts $recursiveRef to "#"; with no $recursiveAnchor in the document + // it is equivalent to $ref:"#", so the wrap converts it to the rewritten pointer. + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' }); + }); + + it('converted recursion actually validates recursive values (engine leg)', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] } + }); + const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType); + expect(v({ result: [1, [2, 3]] }).valid).toBe(true); + expect(v({ result: [1, 'x'] }).valid).toBe(false); + }); + + it('with a $recursiveAnchor in the document, $recursiveRef is left verbatim (documented limitation)', () => { + // Dynamic re-resolution cannot be preserved under relocation: a static rewrite would + // freeze the ref, and the envelope root carries no anchor. Left as authored. + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + $recursiveAnchor: true, + type: 'array', + items: { $recursiveRef: '#' } + }); + expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $recursiveRef: '#' }); + expect(dig(wrapped, 'properties', 'result', '$recursiveAnchor')).toBe(true); + }); + + it('properties NAMED $recursiveRef/$recursiveAnchor are name positions — no conversion, no anchor detection', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { + type: 'object', + // a property literally named $recursiveAnchor (boolean-schema `true`) must + // not suppress conversion... + properties: { $recursiveAnchor: true, $recursiveRef: { type: 'string' } }, + anyOf: [{ $recursiveRef: '#' }, { type: 'number' }] + } + }); + // ...and the keyword-position occurrence still converts. + expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 0)).toEqual({ $ref: '#/properties/result' }); + // Name-position entries are untouched. + expect(dig(wrapped, 'properties', 'result', 'items', 'properties', '$recursiveRef')).toEqual({ type: 'string' }); + }); + + it('plain-name anchor refs ("#name") are never rewritten; patternProperties/dependentSchemas are name maps', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { + type: 'object', + patternProperties: { '^d': { $ref: '#/$defs/D' } }, + dependentSchemas: { default: { $ref: '#/$defs/D' } }, + properties: { a: { $ref: '#node' } } + }, + $defs: { D: { type: 'string' }, N: { $anchor: 'node', type: 'number' } } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'patternProperties', '^d')).toEqual({ $ref: '#/properties/result/$defs/D' }); + expect(dig(wrapped, 'properties', 'result', 'items', 'dependentSchemas', 'default')).toEqual({ + $ref: '#/properties/result/$defs/D' + }); + // "#node" is a location-independent plain-name anchor fragment, not a JSON Pointer. + expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'a')).toEqual({ $ref: '#node' }); + }); +}); + +describe('wrapOutputSchemaForLegacy: $recursiveRef gate precision (2019-09 core §8.2.4.2.1)', () => { + const URI_2019 = 'https://json-schema.org/draft/2019-09/schema'; + + it('a NON-ROOT $recursiveAnchor is inert for a root-base ref — conversion still applies', () => { + // Only a root $recursiveAnchor makes a root-base $recursiveRef dynamic; an anchor + // under $defs can never be the initial target and must not suppress conversion. + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }, + $defs: { unused: { $recursiveAnchor: true, type: 'string' } } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' }); + + const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType); + expect(v({ result: [1, [2, 3]] }).valid).toBe(true); + expect(v({ result: [1, 'x'] }).valid).toBe(false); + }); + + it('$ref and $recursiveRef co-occur conjunctively — conversion joins via allOf, both enforced', () => { + // 2019-09 in-place applicators: both $ref and $recursiveRef apply to the same object. + // The node keeps its (rewritten) $ref; the recursion converts into an allOf entry. + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { $ref: '#/$defs/notHuge', $recursiveRef: '#' }, + $defs: { notHuge: { maxItems: 3 } } + }); + const items = dig(wrapped, 'properties', 'result', 'items') as Record; + expect(items['$ref']).toBe('#/properties/result/$defs/notHuge'); + expect(items['$recursiveRef']).toBeUndefined(); + expect(items['allOf']).toEqual([{ $ref: '#/properties/result' }]); + + const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType); + expect(v({ result: [[], [[]]] }).valid).toBe(true); // nested arrays, all ≤3 items + expect(v({ result: [[[], [], [], []]] }).valid).toBe(false); // violates $ref (maxItems) + expect(v({ result: [['x']] }).valid).toBe(false); // violates recursion (inner non-array) + }); + + it('co-occurrence with an existing allOf appends, not clobbers', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: URI_2019, + type: 'array', + items: { $ref: '#/$defs/a', $recursiveRef: '#', allOf: [{ $ref: '#/$defs/b' }] }, + $defs: { a: { minItems: 0 }, b: { maxItems: 3 } } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'allOf')).toEqual([ + { $ref: '#/properties/result/$defs/b' }, + { $ref: '#/properties/result' } + ]); + }); +}); + +describe('wrapOutputSchemaForLegacy: $recursiveRef conversion is scoped to 2019-09-declared documents', () => { + // $recursiveRef is a 2019-09-only keyword: outside a 2019-09 stamp the wrap copies it + // verbatim — converting would manufacture a constraint on the classic-Ajv leg, which + // ignores the member (Ajv2020 and @cfworker enforce it in every mode; a KNOWN + // LIMITATION, see legacyWrap.ts). These rows pin the WRAP contract only, deliberately + // without an engine leg: off-spec input the engines disagree on has no consistent + // validation outcome to assert (an engine leg would fail on the three enforcing legs). + it.each([ + ['2020-12', 'https://json-schema.org/draft/2020-12/schema'], + ['draft-07', 'http://json-schema.org/draft-07/schema#'], + ['absent', undefined] + ])('%s document: $recursiveRef member passes through verbatim', (_label, uri) => { + const wrapped = wrapOutputSchemaForLegacy({ + ...(uri ? { $schema: uri } : {}), + type: 'array', + items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $recursiveRef: '#' }); + }); +}); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index f17deaa860..a484869944 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -59,13 +59,14 @@ import { } from '@modelcontextprotocol/core-internal'; import { invoke } from './invoke'; -import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter'; +import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter'; import { McpServer } from './mcp'; import type { PerRequestResponseMode } from './perRequestTransport'; import type { Server } from './server'; import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus'; +import { DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; import { WebStandardStreamableHTTPServerTransport } from './streamableHttp'; /* ------------------------------------------------------------------------ * @@ -194,8 +195,8 @@ export interface CreateMcpHandlerOptions { */ maxSubscriptions?: number; /** - * SSE comment-frame keepalive interval for `subscriptions/listen` streams, - * in milliseconds. Set to `0` to disable. + * SSE comment-frame keepalive interval for every SSE stream this handler + * serves. In modern `auto` mode it starts after SSE upgrade. Set to `0` to disable. * @default 15000 */ keepAliveMs?: number; @@ -306,7 +307,11 @@ function internalServerErrorResponse(id: RequestId | null = null): Response { * The entry passes its own `onerror` here when expanding the default, so * legacy-leg failures are never silently swallowed. */ -export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { +function createLegacyStatelessFallback( + factory: McpServerFactory, + onerror?: (error: Error) => void, + keepAliveMs?: number +): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); @@ -317,7 +322,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), requestInfo: request }); - const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + ...(keepAliveMs !== undefined && { keepAliveMs }) + }); await product.connect(transport); const teardown = () => { @@ -390,6 +398,10 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er }; } +export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { + return createLegacyStatelessFallback(factory, onerror); +} + /* ------------------------------------------------------------------------ * * The entry's classification step (shared with isLegacyRequest) * ------------------------------------------------------------------------ */ @@ -619,7 +631,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa const listenRouter = createListenRouter({ bus, maxSubscriptions: options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS, - keepAliveMs: options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS, + keepAliveMs: options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS, onerror: reportError }); if (responseMode === 'json') { @@ -632,7 +644,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // The default posture is the stateless fallback; 'reject' is the only way // to turn legacy serving off (modern-only strict). - const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); + const legacyHandler: LegacyHttpHandler | undefined = + legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs); async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise { const claimedRevision = route.classification.revision; @@ -778,7 +791,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa classification: route.classification, request, ...(authInfo !== undefined && { authInfo }), - ...(responseMode !== undefined && { responseMode }) + ...(responseMode !== undefined && { responseMode }), + ...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs }) }); if (route.messageKind === 'notification') { // Notification exchanges have no terminal response to ride the diff --git a/packages/server/src/server/invoke.ts b/packages/server/src/server/invoke.ts index 6966968604..1a6984926c 100644 --- a/packages/server/src/server/invoke.ts +++ b/packages/server/src/server/invoke.ts @@ -35,6 +35,8 @@ export interface InvokeContext { authInfo?: AuthInfo; /** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */ responseMode?: PerRequestResponseMode; + /** SSE keep-alive interval for the exchange. */ + keepAliveMs?: number; } /** @@ -58,7 +60,8 @@ export async function invoke( ): Promise { const transport = new PerRequestHTTPServerTransport({ classification: ctx.classification, - ...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }) + ...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }), + ...(ctx.keepAliveMs !== undefined && { keepAliveMs: ctx.keepAliveMs }) }); await server.connect(transport); return transport.handleMessage(message, { diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 96dcb16beb..40c4a38cf2 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -34,9 +34,7 @@ import { codecForVersion, MODERN_WIRE_REVISION, SERVER_INFO_META_KEY, SUBSCRIPTI import type { ServerEventBus } from './serverEventBus'; import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus'; - -/** Default SSE comment-frame keepalive interval for listen streams. */ -export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000; +import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; /** Default capacity guard: refuse a new subscription when this many are already open. */ export const DEFAULT_MAX_SUBSCRIPTIONS = 1024; @@ -124,7 +122,7 @@ export interface ListenRouter { export function createListenRouter(options: ListenRouterOptions): ListenRouter { const { bus, onerror } = options; const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS; - const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS; + const keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS; const open = new Set<(graceful: boolean) => void>(); @@ -188,7 +186,11 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { ); } closed = true; - unsubscribe?.(); + try { + unsubscribe?.(); + } catch (error) { + onerror?.(error instanceof Error ? error : new Error(String(error))); + } if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); abortCleanup?.(); open.delete(teardown); @@ -218,14 +220,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { writeNotification(note.method, note.params); }); - if (keepAliveMs > 0) { - keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs); - // Do not hold the event loop open on idle subscriptions. Node's - // setInterval returns a Timeout with .unref(); browsers/Workers - // return a number — the cast is an environment shim, not a - // workaround for SDK typing. - (keepAliveTimer as { unref?: () => void }).unref?.(); - } + keepAliveTimer = armSseKeepAlive(keepAliveMs, () => writeFrame(': keepalive\n\n')); open.add(teardown); }, @@ -251,7 +246,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { status: 200, headers: { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' } diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index 5003946404..5c17f9c455 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -58,6 +58,8 @@ import { SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; + /** * How the transport shapes its HTTP response for a request: * @@ -79,6 +81,8 @@ export interface PerRequestHTTPServerTransportOptions { classification: MessageClassification; /** Response shaping for the exchange; defaults to `auto`. */ responseMode?: PerRequestResponseMode; + /** SSE keep-alive interval in milliseconds; defaults to `15000`, `0` disables. */ + keepAliveMs?: number; } /** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */ @@ -107,6 +111,7 @@ interface SseSink { controller: ReadableStreamDefaultController; encoder: InstanceType; closed: boolean; + keepAliveTimer?: ReturnType; } /** @@ -140,10 +145,12 @@ export class PerRequestHTTPServerTransport implements Transport { private _deferredResponse?: DeferredResponse; private _sse?: SseSink; private _abortCleanup?: () => void; + private readonly _keepAliveMs: number; constructor(options: PerRequestHTTPServerTransportOptions) { this._classification = options.classification; this._responseMode = options.responseMode ?? 'auto'; + this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS; } async start(): Promise { @@ -343,6 +350,9 @@ export class PerRequestHTTPServerTransport implements Transport { this._abortCleanup?.(); this._abortCleanup = undefined; + if (this._sse?.keepAliveTimer !== undefined) { + clearInterval(this._sse.keepAliveTimer); + } if (this._sse !== undefined && !this._sse.closed) { this._sse.closed = true; try { @@ -382,13 +392,14 @@ export class PerRequestHTTPServerTransport implements Transport { } }); this._sse = { controller, encoder: new TextEncoder(), closed: false }; + this._sse.keepAliveTimer = armSseKeepAlive(this._keepAliveMs, () => this.writeCommentFrame('keepalive')); this.settleResponse( new Response(readable, { status: 200, headers: { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', // Disable proxy buffering so streamed messages are // delivered as they are written. @@ -399,6 +410,9 @@ export class PerRequestHTTPServerTransport implements Transport { } private finalizeStream(): void { + if (this._sse?.keepAliveTimer !== undefined) { + clearInterval(this._sse.keepAliveTimer); + } if (this._sse !== undefined && !this._sse.closed) { this._sse.closed = true; try { diff --git a/packages/server/src/server/sseKeepAlive.ts b/packages/server/src/server/sseKeepAlive.ts new file mode 100644 index 0000000000..25df11871c --- /dev/null +++ b/packages/server/src/server/sseKeepAlive.ts @@ -0,0 +1,15 @@ +/** Default interval between SSE keep-alive comment frames. */ +export const DEFAULT_SSE_KEEP_ALIVE_MS = 15_000; + +const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; + +/** Arms an unref'd timer, or disables keep-alive for invalid delays. */ +export function armSseKeepAlive(intervalMs: number, onTick: () => void): ReturnType | undefined { + if (!Number.isFinite(intervalMs) || intervalMs < 1) { + return undefined; + } + + const timer = setInterval(onTick, Math.min(intervalMs, MAX_TIMER_DELAY_MS)); + (timer as { unref?: () => void }).unref?.(); + return timer; +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 7da5fb853c..c0f48560a2 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -19,6 +19,8 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; + export type StreamId = string; export type EventId = string; @@ -148,6 +150,12 @@ export interface WebStandardStreamableHTTPServerTransportOptions { */ retryInterval?: number; + /** + * Interval in milliseconds between SSE keep-alive comment frames. + * Defaults to `15000`; set to `0` to disable. + */ + keepAliveMs?: number; + /** * List of protocol versions that this transport will accept. * Used to validate the `mcp-protocol-version` header in incoming requests. @@ -247,6 +255,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _enableDnsRebindingProtection: boolean; private _retryInterval?: number; private _supportedProtocolVersions: string[]; + private _keepAliveMs: number; sessionId?: string; onclose?: () => void; @@ -264,6 +273,23 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; this._retryInterval = options.retryInterval; this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS; + } + + private startKeepAlive( + controller: ReadableStreamDefaultController, + encoder: InstanceType + ): ReturnType | undefined { + if (this._closed) return undefined; + + const timer = armSseKeepAlive(this._keepAliveMs, () => { + try { + controller.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + if (timer !== undefined) clearInterval(timer); + } + }); + return timer; } /** @@ -352,6 +378,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Returns a `Response` object (Web Standard) */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { + if (this._closed) { + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + // Validate request headers for DNS rebinding protection const validationError = this.validateRequestHeaders(req); if (validationError) { @@ -462,6 +492,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const encoder = new TextEncoder(); let streamController: ReadableStreamDefaultController; + // Captured by cancel/cleanup before it is assigned after stream setup. + // eslint-disable-next-line prefer-const + let keepAliveTimer: ReturnType | undefined; // Create a ReadableStream with a controller we can use to push SSE events const readable = new ReadableStream({ @@ -469,6 +502,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamController = controller; }, cancel: () => { + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); // Stream was cancelled by client. Only drop the mapping when // it still points at THIS controller — a stale cancel must not // delete a successor stream registered by a later GET/resume. @@ -481,7 +515,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const headers: Record = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' }; // After initialization, always include the session ID if we have one @@ -494,6 +529,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { controller: streamController!, encoder, cleanup: () => { + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); this._streamMapping.delete(this._standaloneSseStreamId); try { streamController!.close(); @@ -503,6 +539,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } }); + keepAliveTimer = this.startKeepAlive(streamController!, encoder); return new Response(readable, { headers }); } @@ -537,7 +574,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const headers: Record = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' }; if (this.sessionId !== undefined) { @@ -547,6 +585,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Create a ReadableStream with controller for SSE const encoder = new TextEncoder(); let streamController: ReadableStreamDefaultController; + let keepAliveTimer: ReturnType | undefined; + let cancelled = false; // Captured by the cancel closure below before it's assigned (after // replayEventsAfter resolves) — must be `let`. // eslint-disable-next-line prefer-const @@ -557,6 +597,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamController = controller; }, cancel: () => { + cancelled = true; + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); // Stream was cancelled by client — drop the mapping so a // subsequent reconnect with the same Last-Event-ID is not // refused with 409 by the conflict check above. Only delete @@ -585,11 +627,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } }); + if (this._closed || cancelled) { + try { + streamController!.close(); + } catch { + // Controller already closed/cancelled. + } + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + + this._streamMapping.get(replayedStreamId)?.cleanup(); this._streamMapping.set(replayedStreamId, { controller: streamController!, encoder, replayedEventIds, cleanup: () => { + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); this._streamMapping.delete(replayedStreamId!); try { streamController!.close(); @@ -618,6 +671,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } + if (this._streamMapping.get(replayedStreamId)?.controller === streamController!) { + keepAliveTimer = this.startKeepAlive(streamController!, encoder); + } return new Response(readable, { headers }); } catch (error) { this.onerror?.(error as Error); @@ -728,6 +784,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); } + if (this._closed) { + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ // The schema-validated guard (types/guards.ts → types/schemas.ts — @@ -770,6 +830,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } + if (this._closed) { + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + // check if it contains requests const hasRequests = messages.some(element => isJSONRPCRequest(element)); @@ -818,12 +882,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // SSE streaming mode - use ReadableStream with controller for more reliable data pushing const encoder = new TextEncoder(); let streamController: ReadableStreamDefaultController; + let keepAliveTimer: ReturnType | undefined; const readable = new ReadableStream({ start: controller => { streamController = controller; }, cancel: () => { + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); // Stream was cancelled by client. Only drop the mapping // when it still points at THIS controller — a stale cancel // (firing after a Last-Event-ID reconnect registered a @@ -837,8 +903,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const headers: Record = { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive' + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' }; // After initialization, always include the session ID if we have one @@ -854,6 +921,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { controller: streamController!, encoder, cleanup: () => { + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); this._streamMapping.delete(streamId); try { streamController!.close(); @@ -891,6 +959,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready + if (this._streamMapping.get(streamId)?.controller === streamController!) { + keepAliveTimer = this.startKeepAlive(streamController!, encoder); + } return new Response(readable, { status: 200, headers }); } catch (error) { // return JSON-RPC formatted error @@ -912,9 +983,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return protocolError; } - await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); - await this.close(); - return new Response(null, { status: 200 }); + try { + await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); + return new Response(null, { status: 200 }); + } finally { + await this.close(); + } } /** diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 232f781926..ded506e57c 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -820,3 +820,57 @@ describe('createMcpHandler — close()', () => { // Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged. const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' }); void createMcpHandler(zeroArgFactory); + +describe('createMcpHandler — keepAliveMs', () => { + function gatedFactory(): { factory: () => McpServer; release: () => void } { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const factory = (): McpServer => { + const s = new McpServer({ name: 'ka', version: '1.0.0' }); + s.registerTool('gated', { inputSchema: z.object({}) }, async () => { + await gate; + return { content: [{ type: 'text', text: 'done' }] }; + }); + return s; + }; + return { factory, release }; + } + + it('threads keepAliveMs into the modern per-request exchange stream', async () => { + vi.useFakeTimers(); + try { + const { factory, release } = gatedFactory(); + const handler = createMcpHandler(factory, { responseMode: 'sse', keepAliveMs: 1_000 }); + const responsePromise = handler.fetch(postRequest(modernToolsCall('gated', {}))); + await vi.advanceTimersByTimeAsync(1_000); + release(); + const response = await responsePromise; + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const text = await response.text(); + expect(text).toContain(': keepalive'); + } finally { + vi.useRealTimers(); + } + }); + + it('threads keepAliveMs into the legacy stateless fallback per-request transport', async () => { + vi.useFakeTimers(); + try { + const { factory, release } = gatedFactory(); + const handler = createMcpHandler(factory, { keepAliveMs: 1_000 }); + const responsePromise = handler.fetch( + postRequest({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'gated', arguments: {} } }) + ); + await vi.advanceTimersByTimeAsync(1_000); + release(); + const response = await responsePromise; + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const text = await response.text(); + expect(text).toContain(': keepalive'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 2fa5000742..7a0d2c8675 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -13,10 +13,11 @@ import { PROTOCOL_VERSION_META_KEY, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core-internal'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createMcpHandler } from '../../src/server/createMcpHandler'; import { McpServer } from '../../src/server/mcp'; +import type { ServerEventBus } from '../../src/server/serverEventBus'; const ENVELOPE = { [PROTOCOL_VERSION_META_KEY]: '2026-07-28', @@ -105,6 +106,7 @@ describe('createMcpHandler — subscriptions/listen', () => { ); const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-cache, no-transform'); const [ack] = await readMessages(response, 1); // The factory is consulted exactly once (capabilities probe only); the // instance is never connected and is closed immediately after the @@ -116,6 +118,44 @@ describe('createMcpHandler — subscriptions/listen', () => { await handler.close(); }); + it.each([0.5, Number.NaN, Number.POSITIVE_INFINITY])('disables invalid keepAliveMs %s', async keepAliveMs => { + vi.useFakeTimers(); + try { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs }); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + const reader = response.body!.getReader(); + await reader.read(); + expect(vi.getTimerCount()).toBe(0); + await reader.cancel(); + await handler.close(); + } finally { + vi.useRealTimers(); + } + }); + + it('cleans up when a custom bus unsubscribe throws', async () => { + vi.useFakeTimers(); + try { + const onerror = vi.fn(); + const bus: ServerEventBus = { + publish() {}, + subscribe: () => () => { + throw new Error('unsubscribe failed'); + } + }; + const handler = createMcpHandler(trivialFactory(), { bus, keepAliveMs: 1_000, onerror }); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + const reader = response.body!.getReader(); + await reader.read(); + await reader.cancel(); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'unsubscribe failed' })); + expect(vi.getTimerCount()).toBe(0); + await handler.close(); + } finally { + vi.useRealTimers(); + } + }); + it('ack is the first frame, stamped with the listen id verbatim, carrying the honored subset', async () => { const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); const response = await handler.fetch(listenRequest('sub-42', { toolsListChanged: true, promptsListChanged: false })); diff --git a/packages/server/test/server/perRequestStreaming.test.ts b/packages/server/test/server/perRequestStreaming.test.ts index 6d350ed2ef..b9ac07e09a 100644 --- a/packages/server/test/server/perRequestStreaming.test.ts +++ b/packages/server/test/server/perRequestStreaming.test.ts @@ -11,7 +11,7 @@ import { PROTOCOL_VERSION_META_KEY, setNegotiatedProtocolVersion } from '@modelcontextprotocol/core-internal'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { PerRequestResponseMode } from '../../src/server/perRequestTransport'; import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport'; @@ -46,14 +46,16 @@ interface StreamingSetup { async function setup( handler: (ctx: ServerContext) => Promise, - responseMode?: PerRequestResponseMode + responseMode?: PerRequestResponseMode, + keepAliveMs?: number ): Promise { const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx)); setNegotiatedProtocolVersion(server, MODERN_REVISION); const transport = new PerRequestHTTPServerTransport({ classification: MODERN, - ...(responseMode !== undefined && { responseMode }) + ...(responseMode !== undefined && { responseMode }), + ...(keepAliveMs !== undefined && { keepAliveMs }) }); await server.connect(transport); return { server, transport }; @@ -92,7 +94,7 @@ describe('lazy upgrade matrix', () => { const response = await transport.handleMessage(toolsCall()); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/event-stream'); - expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('cache-control')).toBe('no-cache, no-transform'); expect(response.headers.get('x-accel-buffering')).toBe('no'); const frames = await sseFrames(response); @@ -249,3 +251,58 @@ describe('disconnect is cancellation', () => { expect(observedSignal?.aborted).toBe(true); }); }); + +describe('keep-alive', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes keep-alive comment frames while a forced-sse exchange is streaming', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { transport } = await setup(async () => { + await gate; + return { content: [] }; + }, 'sse'); + + const responsePromise = transport.handleMessage(toolsCall()); + // The stream opened at dispatch end; the handler now idles past the + // default interval with no mid-call output. + await vi.advanceTimersByTimeAsync(15_000); + release(); + const response = await responsePromise; + const frames = await sseFrames(response); + expect(frames[0]).toBe(': keepalive'); + + // The exchange completed and closed the transport: no timer survives. + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not write keep-alive frames when keepAliveMs is 0', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { transport } = await setup( + async () => { + await gate; + return { content: [] }; + }, + 'sse', + 0 + ); + + const responsePromise = transport.handleMessage(toolsCall()); + await vi.advanceTimersByTimeAsync(60_000); + release(); + const response = await responsePromise; + const frames = await sseFrames(response); + expect(frames.some(frame => frame.startsWith(': keepalive'))).toBe(false); + }); +}); diff --git a/packages/server/test/server/sseKeepAlive.test.ts b/packages/server/test/server/sseKeepAlive.test.ts new file mode 100644 index 0000000000..0310849429 --- /dev/null +++ b/packages/server/test/server/sseKeepAlive.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { armSseKeepAlive } from '../../src/server/sseKeepAlive'; + +describe('armSseKeepAlive', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it.each([0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])('disables invalid delay %s', delay => { + expect(armSseKeepAlive(delay, () => {})).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('ticks at the configured interval', async () => { + const tick = vi.fn(); + const timer = armSseKeepAlive(1_000, tick)!; + await vi.advanceTimersByTimeAsync(3_000); + expect(tick).toHaveBeenCalledTimes(3); + clearInterval(timer); + }); + + it('clamps overflowing delays instead of creating a 1ms timer', async () => { + const tick = vi.fn(); + const timer = armSseKeepAlive(2 ** 31, tick)!; + await vi.advanceTimersByTimeAsync(60_000); + expect(tick).not.toHaveBeenCalled(); + clearInterval(timer); + }); +}); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index beca451113..9ec6baf46c 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1407,3 +1407,143 @@ describe('Zod v4', () => { }); }); }); + +describe('WebStandardStreamableHTTPServerTransport SSE keep-alive', () => { + async function createTransport(options?: { keepAliveMs?: number }): Promise<{ + transport: WebStandardStreamableHTTPServerTransport; + sessionId: string; + }> { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), ...options }); + await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport); + const initResponse = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize)); + expect(initResponse.status).toBe(200); + return { transport, sessionId: initResponse.headers.get('mcp-session-id') as string }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should write keep-alive comment frames to an idle standalone GET stream', async () => { + const { transport, sessionId } = await createTransport(); + + const response = await transport.handleRequest(createRequest('GET', undefined, { sessionId })); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-cache, no-transform'); + expect(response.headers.get('x-accel-buffering')).toBe('no'); + + const reader = response.body!.getReader(); + await vi.advanceTimersByTimeAsync(15000); + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n'); + + await transport.close(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('should not write keep-alive frames when keepAliveMs is 0', async () => { + const { transport, sessionId } = await createTransport({ keepAliveMs: 0 }); + + const response = await transport.handleRequest(createRequest('GET', undefined, { sessionId })); + const reader = response.body!.getReader(); + + await vi.advanceTimersByTimeAsync(60000); + const raced = await Promise.race([reader.read(), Promise.resolve('pending')]); + expect(raced).toBe('pending'); + + await transport.close(); + }); + + it('should write keep-alive frames on a POST SSE stream while a request is pending', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + let resolveTool: (() => void) | undefined; + mcpServer.registerTool('slow', { description: 'never resolves until released' }, async (): Promise => { + await new Promise(resolve => { + resolveTool = resolve; + }); + return { content: [{ type: 'text', text: 'done' }] }; + }); + await mcpServer.connect(transport); + + const initResponse = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize)); + const sessionId = initResponse.headers.get('mcp-session-id') as string; + + const response = await transport.handleRequest( + createRequest( + 'POST', + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'slow', arguments: {} }, id: 'call-1' } as JSONRPCMessage, + { + sessionId + } + ) + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-cache, no-transform'); + expect(response.headers.get('x-accel-buffering')).toBe('no'); + const reader = response.body!.getReader(); + + await vi.advanceTimersByTimeAsync(15000); + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toBe(': keepalive\n\n'); + + resolveTool?.(); + await transport.close(); + }); + + it('should not initialize after close races request body parsing', async () => { + const onsessioninitialized = vi.fn(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized + }); + await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport); + + let releaseBody!: () => void; + const body = new ReadableStream({ + start(controller) { + releaseBody = () => { + controller.enqueue(new TextEncoder().encode(JSON.stringify(TEST_MESSAGES.initialize))); + controller.close(); + }; + } + }); + const pending = transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json' }, + body, + duplex: 'half' + }) + ); + + await transport.close(); + releaseBody(); + expect((await pending).status).toBe(404); + expect(onsessioninitialized).not.toHaveBeenCalled(); + }); + + it('should not register a stream after close races session initialization', async () => { + let releaseInitialization!: () => void; + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: () => + new Promise(resolve => { + releaseInitialization = resolve; + }) + }); + await new McpServer({ name: 'test-server', version: '1.0.0' }).connect(transport); + + const pending = transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize)); + await vi.advanceTimersByTimeAsync(0); + await transport.close(); + releaseInitialization(); + + expect((await pending).status).toBe(404); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f5941b410..839b152070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1943,8 +1943,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.9 - version: 0.2.0-alpha.9(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.10 + version: 0.2.0-alpha.10(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3228,8 +3228,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.9': - resolution: {integrity: sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==} + '@modelcontextprotocol/conformance@0.2.0-alpha.10': + resolution: {integrity: sha512-0V/HZDdWHcg6j0zVBzBsXcPZ571IVi6umKgTpnBhtTx/jm/LONmGF6cIWL2k4Xjyps0OiHV6B37nj2s0pUg0nQ==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -7851,7 +7851,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.9(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.10(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 1213f40b35..e6fbde1ced 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -28,17 +28,8 @@ client: [] # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- # (empty: SEP-2468/2352/2350/837 burned by the auth bundle; SEP-2106 burned earlier) -server: +server: [] # --- Carried-forward scenarios (also run by the 2025 legs) --- # (json-schema-2020-12 burned by the SEP-2106 fixture; # sep-2164-resource-not-found burned by the spec#2907 error-code renumber + # alpha.5 referee.) - # - # --- spec PR #3002 — referee pinned at alpha.9 asserts the OLD shape --- - # Same three failing checks as the 2025-leg baseline entry - # (sep-2575-request-meta-invalid-missing-client-info, the - # missing-client-info iteration of sep-2575-http-server-meta-invalid-400, - # and sep-2575-server-implements-discover which requires body serverInfo): - # this SDK follows the final revision. Remove when the pin bumps to a - # conformance release that incorporates #3002 (alpha.10+). - - server-stateless diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 8e88e75cce..6711cdc30f 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,7 +2,7 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.9). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.10). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # @@ -17,25 +17,24 @@ # corresponding scenarios start passing and MUST be removed from this list (the # runner fails on stale entries), so the baseline burns down per milestone. -client: [] +client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # (empty: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the + # (none: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the # last referee-side gap — conformance#361 callback-iss — closed at alpha.6) + # + # --- SEP-1932 (DPoP) / SEP-1933 (WIF) — extension-tagged auth scenarios, new in the alpha.10 referee --- + # The OAuth client implements neither DPoP proofs (RFC 9449) nor the + # urn:ietf:params:oauth:grant-type:jwt-bearer grant, so every check in + # these scenarios fails. Client-side extension scenarios are selected only + # by `--suite all`; the 2026 leg cannot flag them stale (extension + # scenarios never match a --spec-version filter). + - auth/dpop + - auth/dpop-nonce + - auth/wif-jwt-bearer server: - # --- spec PR #3002 — referee pinned at alpha.9 asserts the OLD shape --- - # The alpha.9 `server-stateless` scenario still enforces the pre-#3002 - # spec: clientInfo required in the envelope, serverInfo a DiscoverResult - # body field. This SDK follows the final revision (clientInfo optional; - # identity in the result _meta), so exactly three checks fail: - # - sep-2575-request-meta-invalid-missing-client-info - # - sep-2575-http-server-meta-invalid-400 (the missing-client-info iteration) - # - sep-2575-server-implements-discover (requires body serverInfo) - # Remove this entry when the pin bumps to a conformance release that - # incorporates #3002 (alpha.10+). - - server-stateless # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- - # Extension-tagged scenarios; selected only by `--suite all` (the alpha.9 referee + # Extension-tagged scenarios; selected only by `--suite all` (the alpha.10 referee # has no server-side `--suite extensions`). The active/draft/2026 legs never select # them, so they cannot flag these entries as stale. `tasks-status-notifications` is # intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending diff --git a/test/conformance/package.json b/test/conformance/package.json index cf120715df..679600a644 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -38,7 +38,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.10", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index c81783d509..76b597c5eb 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -516,7 +516,7 @@ export const REQUIREMENTS: Record = { 'client:jsonschema:unsupported-dialect-graceful': { source: 'sdk', behavior: - 'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.' + 'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise (2020-12, 2019-09, draft-07, and draft-06 are supported) is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect" message instead of having the underlying engine fail opaquely.' }, 'client:jsonschema:bad-schema-isolates-tool': { source: 'sdk', diff --git a/test/integration/test/client/outputSchemaDialect.test.ts b/test/integration/test/client/outputSchemaDialect.test.ts new file mode 100644 index 0000000000..9d79b29001 --- /dev/null +++ b/test/integration/test/client/outputSchemaDialect.test.ts @@ -0,0 +1,164 @@ +/** + * Ecosystem servers advertise tool schemas stamped with a draft-07 `$schema` + * (zod-to-json-schema's default output — e.g. the official Filesystem server). + * The spec honors the declared dialect (absent means 2020-12), so the default + * validator must dispatch on it instead of rejecting pre-wire with InvalidParams. + * Unknown dialects still produce the typed error. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { JsonSchemaType } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { fromJsonSchema, McpServer, Server } from '@modelcontextprotocol/server'; + +/** zod-to-json-schema default output shape, lifted from the official Filesystem server. */ +const FILESYSTEM_STYLE_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + content: { type: 'string' }, + encoding: { type: 'string', enum: ['utf8', 'base64'] } + }, + required: ['content'], + additionalProperties: false +} as const; + +/** Draft-07 tuple form: positional `items` array (2020-12 moved this to `prefixItems`). */ +const DRAFT_07_TUPLE_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] } + }, + required: ['pair'] +} as const; + +/** zod-to-json-schema `target: 'openAi'` output shape (also its `target: '2019-09'`). */ +const OPENAI_TARGET_SCHEMA = { + $schema: 'https://json-schema.org/draft/2019-09/schema#', + type: 'object', + properties: { + summary: { type: 'string' }, + score: { type: 'number' } + }, + required: ['summary'], + additionalProperties: false +} as const; + +/** + * A real low-level Server advertising a verbatim outputSchema without compiling it — + * the shape of a non-SDK ecosystem server. `structuredContent` comes from `results` + * keyed by tool name. + */ +async function connectPair( + outputSchema: unknown, + structuredContent: () => unknown +): Promise<{ client: Client; close: () => Promise }> { + const server = new Server({ name: 'ecosystem-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/list', async () => ({ + tools: [{ name: 'read_text_file', inputSchema: { type: 'object' }, outputSchema: outputSchema as JsonSchemaType }] + })); + server.setRequestHandler('tools/call', async () => ({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: structuredContent() as Record + })); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + // Populate the tools/list cache — output validation derives from the cached entry. + await client.listTools(); + return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) }; +} + +describe('declared-dialect tool schemas end to end', () => { + test('draft-07 outputSchema (Filesystem-server shape) validates instead of failing pre-wire', async () => { + const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ content: 'hello', encoding: 'utf8' })); + + await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ + structuredContent: { content: 'hello', encoding: 'utf8' } + }); + + await close(); + }); + + test('a VIOLATING result against a draft-07 outputSchema still fails validation', async () => { + // `content` missing — the draft-07 engine must actually run, not pass through. + const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ encoding: 'utf8' })); + + await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/); + + await close(); + }); + + test('draft-07 tuple `items` gets draft-07 positional semantics', async () => { + let pair: unknown = [1, 'x']; + const { client, close } = await connectPair(DRAFT_07_TUPLE_SCHEMA, () => ({ pair })); + + await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ structuredContent: { pair: [1, 'x'] } }); + + pair = ['x', 1]; // violates the positional item schemas + await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/); + + await close(); + }); + + test('2019-09 outputSchema (zod-to-json-schema openAi target shape) validates instead of failing pre-wire', async () => { + const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ summary: 'ok', score: 1 })); + + await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ + structuredContent: { summary: 'ok', score: 1 } + }); + + await close(); + }); + + test('a VIOLATING result against a 2019-09 outputSchema still fails validation', async () => { + // `summary` missing — the 2019-09 engine must actually run, not pass through. + const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ score: 1 })); + + await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/); + + await close(); + }); + + test('unknown dialect still fails pre-wire with the typed error', async () => { + const { client, close } = await connectPair( + { ...FILESYSTEM_STYLE_SCHEMA, $schema: 'http://json-schema.org/draft-04/schema#' }, + () => ({ content: 'hello' }) + ); + + await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/invalid outputSchema.*unsupported dialect/s); + + await close(); + }); + + test('fromJsonSchema registers a draft-07 inputSchema and enforces it server-side', async () => { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + mcpServer.registerTool( + 'echo', + { + inputSchema: fromJsonSchema<{ content: string; encoding?: 'utf8' | 'base64' }>( + FILESYSTEM_STYLE_SCHEMA as unknown as JsonSchemaType + ) + }, + async args => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect(client.callTool({ name: 'echo', arguments: { content: 'hi' } })).resolves.toMatchObject({ + content: [{ type: 'text', text: expect.stringContaining('hi') }] + }); + + // Violates the draft-07 schema (`content` missing) — the server reports an input validation error. + await expect(client.callTool({ name: 'echo', arguments: { encoding: 'utf8' } })).resolves.toMatchObject({ + isError: true, + content: [{ type: 'text', text: expect.stringContaining("must have required property 'content'") }] + }); + + await Promise.all([client.close(), clientTransport.close(), serverTransport.close()]); + }); +}); diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 5963229f0a..13fb77e944 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -986,3 +986,32 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo ).rejects.toThrow(/^Elicitation response content does not match requested schema/); }); } + +describe('declared-dialect requestedSchema (default validator)', () => { + test('draft-07-stamped requestedSchema validates accepted content with a real engine', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const requestedSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { name: { type: 'string', minLength: 1 } }, + required: ['name'] + } as const; + + let content: Record = { name: 'John' }; + client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content })); + + await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({ + action: 'accept', + content: { name: 'John' } + }); + + content = { name: '' }; // violates minLength — the draft-07 engine actually runs + await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).rejects.toThrow( + /does not match requested schema/ + ); + }); +});