diff --git a/.changeset/client-response-cache-substrate.md b/.changeset/client-response-cache-substrate.md index 9d957d5f0c..3bd0979d6c 100644 --- a/.changeset/client-response-cache-substrate.md +++ b/.changeset/client-response-cache-substrate.md @@ -4,4 +4,4 @@ `Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws `SdkError` with `SdkErrorCode.ListPaginationExceeded` on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. Entries are automatically scoped by the connected server's identity and (when set) the consumer-supplied `cachePartition`, so a shared store does not collide across servers or principals; evictions are likewise scoped to the connected server's partitions. -**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only (previously `listTools()` threw). A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. +**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. `listTools()` no longer throws on an uncompilable `outputSchema` (every tool stays listed; the compile failure is captured per-tool); calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` with `data.reason` set to the structured kind, before the request is sent — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. diff --git a/.changeset/sep-2106-dialect-posture.md b/.changeset/sep-2106-dialect-posture.md new file mode 100644 index 0000000000..ac2e84d420 --- /dev/null +++ b/.changeset/sep-2106-dialect-posture.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': patch +--- + +SEP-1613 / SEP-2106 (JSON Schema 2020-12 posture): the Node default JSON Schema validator is now `Ajv2020` (true draft 2020-12) instead of the draft-07 `Ajv` class — `$defs`/`prefixItems`/`unevaluatedProperties`/`dependentRequired` are now enforced where they were previously silently ignored; to opt back, construct the draft-07 instance with the v1 defaults — `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv);` — and pass `new AjvJsonSchemaValidator(ajv)`. `outputSchema` may now have a non-object root and `CallToolResult.structuredContent` is widened to `unknown` (a deliberate source-level break for typed consumers — see the migration guide for the narrowing pattern). McpServer drops a non-object `outputSchema` from the legacy (≤ 2025-11-25) `tools/list` projection with a warn-once, and auto-appends a `TextContent` JSON serialisation when a handler returns non-object `structuredContent` without its own text block. The `structuredContent` presence check is `!== undefined` (not falsy) on both sides. Thanks @mattzcarey (#2249). diff --git a/.changeset/sep-2106-ref-bounds.md b/.changeset/sep-2106-ref-bounds.md new file mode 100644 index 0000000000..ecdb1e64e9 --- /dev/null +++ b/.changeset/sep-2106-ref-bounds.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +SEP-2106 (security tightening): the built-in JSON Schema validator providers refuse to compile a schema carrying a non-same-document `$ref`/`$dynamicRef` (anything other than `""`, `#/$defs/…`, or `#anchor`), a `$schema` dialect URI the engine does not recognise, or composition exceeding the depth/subschema-count bounds, throwing `SchemaCompileError` with a structured `reason`. External-reference dereferencing has never been on a network path in this SDK; this makes the rejection explicit and structured. On the client, one tool with a refused output schema no longer poisons `tools/list` — every tool stays listed and the compile failure surfaces lazily as `ProtocolError(InvalidParams, …, { reason })` when that tool is called. `assertSchemaSafeToCompile`, `isSameDocumentRef`, and `SchemaCompileError` are exported for custom `jsonSchemaValidator` implementations to inherit the same defaults. diff --git a/docs/client.md b/docs/client.md index d87dd52050..25c231d432 100644 --- a/docs/client.md +++ b/docs/client.md @@ -269,7 +269,7 @@ const result = await client.callTool({ console.log(result.content); ``` -Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: +Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM: ```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ @@ -277,9 +277,13 @@ const result = await client.callTool({ arguments: { weightKg: 70, heightM: 1.75 } }); -// Machine-readable output for the client application -if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } +// Machine-readable output for the client application. SEP-2106: structuredContent is +// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. +if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } ``` diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 1161dde262..139f09781a 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -574,7 +574,7 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound `Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` / `readResource()` now honour the server-stamped SEP-2549 `ttlMs`/`cacheScope`: a still-fresh cached entry is returned without a round trip. Opt-in by server hint — a server that sends `ttlMs: 0` (the SDK's default stamp) sees no behaviour change. Per-call override: pass `{ cacheMode: 'refresh' }` (always fetch and re-store) or `{ cacheMode: 'bypass' }` (fetch without touching the cache). Server `ttlMs` is clamped at 24 h (`MAX_CACHE_TTL_MS`). Entries are automatically scoped by connected-server identity; new `ClientOptions.cachePartition` (per-principal slot for `'private'`-scoped entries on a shared `responseCacheStore`; default `''`) and `ClientOptions.defaultCacheTtlMs` (TTL when the result lacks one, e.g. legacy-era responses; default `0`). `ResponseCacheStore` gained `delete(key)` (driven by `notifications/resources/updated`); `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). -Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry) and non-throwing (an uncompilable `outputSchema` is `console.warn`-ed and validation is skipped for that tool only); `listTools()` no longer throws on an uncompilable `outputSchema`. Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. +Output-schema validator compilation is now lazy (first `callTool()` against the cached `tools/list` entry); `listTools()` no longer throws on an uncompilable `outputSchema` — every tool stays listed and the compile failure is captured per-tool. Calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams)` with `data.reason` set to the structured kind, before the request is sent (validation is never silently skipped). Applies on every era — the legacy-era `listTools()` path is unchanged at the wire level only. No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt @@ -661,6 +661,18 @@ Validator behavior: `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. - To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. +JSON Schema 2020-12 posture (SEP-1613 / SEP-2106): the default validator supports JSON Schema 2020-12 only (the spec's only MUST) — on Node it is now `Ajv2020` instead of draft-07 `Ajv`. Schemas declaring a different `$schema` are rejected with +`SchemaCompileError{kind:'unsupported-dialect'}`; to validate other dialects, pass a pre-configured Ajv instance: `new AjvJsonSchemaValidator(new Ajv({...}))`. `CallToolResult.structuredContent` is typed `unknown` (was `{ [k: string]: unknown }`). The presence check is +`!== undefined`, not falsy. Non-same-document `$ref`/`$dynamicRef` is rejected at compile time with `SchemaCompileError`. + +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `result.structuredContent.` / `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 | `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv); new AjvJsonSchemaValidator(ajv)` (import `Ajv`, `addFormats` from `…/validators/ajv`) | +| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` or `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | + ## 15. Migration Steps (apply in this order) 1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages diff --git a/docs/migration.md b/docs/migration.md index e61eba558a..313a5eb684 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -594,9 +594,10 @@ New `ClientOptions`: The `ResponseCacheStore` interface gained `delete(key)` (the per-URI invalidation `notifications/resources/updated` drives) — custom stores written against the alpha substrate need to add it. The default `InMemoryResponseCacheStore` is now bounded (default 512 entries, oldest-first eviction; configurable via `{ maxEntries }`). -**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()` — and non-throwing: an uncompilable `outputSchema` is `console.warn`-ed -and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is -unchanged at the wire level but is observably different at the validator-lifecycle level. +**Output-schema validator lifecycle (every era):** validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now +`listTools()` succeeds (every tool stays listed) and the compile failure is captured per-tool. Calling `callTool()` on the affected tool then throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` with `data.reason` set to the structured kind, **before +the request is sent** — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at +the validator-lifecycle level. ### `InMemoryTransport` moved @@ -1442,6 +1443,87 @@ subpath in some files and rely on the default in others. To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. +### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) + +SEP-1613 (in the 2025-11-25 revision) declares JSON Schema **draft 2020-12** as the dialect for tool `inputSchema` / `outputSchema`, and SEP-2106 (2026-07-28 draft) widens both to the full 2020-12 vocabulary — `$defs`, `$ref`, `$anchor`, composition (`allOf`/`anyOf`/`oneOf`), +conditionals (`if`/`then`/`else`), `prefixItems`, `unevaluatedProperties` — and lifts the `type:"object"` root restriction on `outputSchema` and `structuredContent`. This SDK release brings the validator posture and the public types into line with both SEPs. + +#### Default validator is JSON Schema 2020-12 only + +The default validator supports **JSON Schema 2020-12 only** (the spec's only MUST). On Node it is now `Ajv2020` (`ajv/dist/2020`) instead of the draft-07 `Ajv` class; the Cloudflare Workers default was already 2020-12. Schemas declaring a different `$schema` are rejected with +`SchemaCompileError{kind:'unsupported-dialect'}`. Nothing in your code changes unless you fall into one of three populations: + +- **You declared 2020-12 keywords (`$defs`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`) in a server schema and they were silently ignored.** They are now enforced. If a previously "passing" tool input or output starts failing validation, the schema was always + wrong on the wire — fix the schema or the data. +- **You authored draft-07 idioms via `fromJsonSchema()`** (e.g. tuple `items: [...]` instead of `prefixItems`, draft-07 `definitions`). Port to 2020-12 spelling, or pass a draft-07 Ajv instance **as the second argument** — `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — built per the opt-back recipe below. The `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema()`-authored schemas (`fromJsonSchema()` compiles eagerly with the package-level default unless a validator is passed directly). +- **You imported `Ajv` from the SDK's validator subpath and relied on the re-export being the draft-07 class.** It still is — `Ajv` remains the draft-07 class (re-exported for the opt-back), but it is **no longer** what the SDK uses by default. + +To validate other dialects, pass a pre-configured Ajv instance: + +```typescript +import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + +// Opt back to the v1 (draft-07) default — accepted structurally; the $schema check is skipped. +const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); +addFormats(ajv); +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { jsonSchemaValidator: new AjvJsonSchemaValidator(ajv) }); +``` + +#### Non-same-document `$ref` is rejected — use `#/$defs/…` or `#anchor` + +Compiling an untrusted peer's schema with a network-resolving `$ref` is an SSRF and fetch-DoS primitive. The SDK never dereferences external references: any `$ref` / `$dynamicRef` whose value is not a **same-document** reference (`""`, `#/$defs/Foo`, `#anchor`) is refused at +compile time with `SchemaCompileError` (`reason.kind === 'ref-denied'`). Absolute URIs, scheme-relative `//host/…`, bare paths, and `$id`-relative references are all non-local and rejected. Inline the referenced schema under `$defs` and point at it with a fragment instead. The +same compile step bounds nesting depth (64) and total subschema count (10 000) as a defence against compositional CPU-DoS, and rejects a `$schema` dialect URI other than 2020-12 (`reason.kind === 'unsupported-dialect'`) instead of letting the engine fail opaquely. + +On the client, one tool with a refused schema **does not poison `tools/list`**: every tool stays listed, and the compile failure surfaces lazily when `callTool` is invoked on that tool, as `ProtocolError(InvalidParams)` with `data.reason` set to the structured kind. + +The two built-in providers (`AjvJsonSchemaValidator`, `CfWorkerJsonSchemaValidator`) apply these checks for you. A **custom `jsonSchemaValidator`** is not auto-guarded — bring-your-own-validator means bring-your-own-conformance — so call the exported guard yourself at the top of +your `getValidator`: + +```typescript +import { assertSchemaSafeToCompile, SchemaCompileError } from '@modelcontextprotocol/server'; + +const myValidator = { + getValidator(schema) { + assertSchemaSafeToCompile(schema); // throws SchemaCompileError on ref/bounds; you own the dialect check + return myEngine.compile(schema); + } +}; +``` + +#### `CallToolResult.structuredContent` is now typed `unknown` + +SEP-2106 lifts the `type:"object"` root restriction on `outputSchema`, so `structuredContent` may legally be an array, a string, a number, a boolean, or `null`. The public TypeScript type is widened from `{ [key: string]: unknown }` to `unknown` to match. **This is a deliberate +source-level break** for typed consumers that previously indexed into `structuredContent` directly: that worked because the v1 type let you read any key as `unknown`, which was already a lie about what the value at that key was. `unknown` is the honest type for a generic host +that does not know the server's output schema at compile time — narrow at the call site: + +```typescript +const r = await client.callTool({ name: 'compute', arguments: { n: 7 } }); + +// SEP-2106 narrowing pattern: prove the shape, then read. +const sc = r.structuredContent; +if (typeof sc === 'object' && sc !== null && 'value' in sc && typeof sc.value === 'number') { + use(sc.value); +} +``` + +The presence check is `!== undefined`, not falsy: `null`, `0`, `false`, `""` are legal `structuredContent` values now and are validated against the tool's `outputSchema` like any other value (so a falsy value against an object-typed schema still fails — this is **not** a guard +weakening). Runtime validation against the cached `outputSchema` remains the safety net regardless of how you narrow on the TypeScript side. + +#### Non-object `outputSchema` and the legacy projection + +A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc. Because the 2025-11-25 wire keeps `outputSchema`/`structuredContent` at their object/Record shapes for byte-identity, a non-object root is **2026-only vocabulary**. When such a tool is +listed toward a 2025-era client, McpServer drops the `outputSchema` from that tool's `tools/list` entry (with a warn-once `console.warn` naming the tool, fired once at registration time) so legacy clients keep seeing and calling the tool without +being handed a schema their codec cannot parse. When such a tool's handler returns non-object `structuredContent` and no `type:"text"` content of its own, McpServer auto-appends `{ type: "text", text: JSON.stringify(structuredContent) }` so legacy-style consumers that read only +`content` still receive a rendering — author any `text` block yourself to opt out. On the 2025 era the non-object `structuredContent` value itself is then **omitted** from the result: the 2025 wire shape types `structuredContent` as a string-keyed record, so emitting an array +or primitive there is invalid 2025 wire data and a strictly-conformant 2025 client would reject the entire response (the fallback included). The text block is what reaches a legacy client; the structured value reaches a 2026 client. While you serve 2025-era clients, keep schemas +in the 2025 subset (object roots) where you can. + +**Typeless-root output schemas are only stamped `type:"object"` when provably safe.** A Standard-Schema value whose JSON Schema root has no `type` — for example `z.union([z.string(), z.number()])` (`{anyOf:[…]}`), `z.any()` (`{}`), or `z.object({…}).nullable()` — is advertised +as-is on the 2026 era and dropped from the 2025 projection (with the same warn-once), because stamping `type:"object"` there would produce a self-contradictory schema that rejects every value. The SDK still defaults `type:"object"` when the root carries object keywords +(`properties`/`patternProperties`/`additionalProperties`/`required`) **or** is a `oneOf`/`anyOf`/`allOf` whose every member is `type:"object"` — so `z.discriminatedUnion(...)`, `z.union([z.object(...), …])`, and `z.intersection(...)` of objects keep their 2025-era advertisement +unchanged. + ## Specification clarifications adopted (no SDK behavior change) The 2026-07-28 specification revision includes a number of documentation-only clarifications that do not change SDK wire behavior or public surface. They are recorded here so an audit of the revision's changelog against this guide is complete; nothing in this section requires diff --git a/docs/server.md b/docs/server.md index 47d866aa6e..58da68597f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -128,18 +128,6 @@ server.registerTool( ); ``` -> [!NOTE] -> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: -> -> ```ts -> type BmiResult = { bmi: number }; // assignable -> interface BmiResult { -> bmi: number; -> } // type error -> ``` -> -> Alternatively, spread the value: `structuredContent: { ...result }`. - ### `ResourceLink` outputs Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index 68ef6015a3..e16bdf7a85 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -218,9 +218,13 @@ async function callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion callTool_structuredOutput } diff --git a/examples/schema-validators/README.md b/examples/schema-validators/README.md index ea83e07a61..4ee9673b53 100644 --- a/examples/schema-validators/README.md +++ b/examples/schema-validators/README.md @@ -1,6 +1,6 @@ # schema-validators -Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`. +Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`, including an array-root `outputSchema` (SEP-2106) with the auto-injected `TextContent` fallback and the client-side `unknown` runtime-narrowing pattern. ```bash pnpm tsx examples/schema-validators/client.ts diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts index 60bf559258..a7034f0c58 100644 --- a/examples/schema-validators/client.ts +++ b/examples/schema-validators/client.ts @@ -19,11 +19,33 @@ runClient('schema-validators', async () => { check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, world!/); } + // structuredContent is typed `unknown` (SEP-2106). The SDK has already + // runtime-validated it against the server's outputSchema. This client is + // written FOR the paired server above, so the shape is known and a cast is + // the honest known-server idiom (same as C# `.Deserialize()` or Go + // `json.Unmarshal`). A generic host that connects to arbitrary servers + // would not cast; it would render the JSON or narrow at runtime. const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); - const sc = weather.structuredContent as { city?: string; conditions?: string; celsius?: number } | undefined; - check.equal(sc?.city, 'Tokyo'); - check.equal(sc?.conditions, 'sunny'); - check.equal(sc?.celsius, 21); + const w = weather.structuredContent as { city: string; conditions: string; celsius: number }; + check.equal(w.city, 'Tokyo'); + check.equal(w.conditions, 'sunny'); + check.equal(w.celsius, 21); + + // SEP-2106: array structuredContent. The SDK auto-injects a serialized + // JSON text block alongside it. On the legacy era the non-object + // structuredContent is dropped (the 2025 wire shape only carries objects), + // so the text block is the result on that path. + const forecasts = await client.callTool({ name: 'list-forecasts', arguments: { city: 'Tokyo' } }); + const text = forecasts.content?.find(c => c.type === 'text'); + check.ok(text, 'auto-injected TextContent fallback present'); + check.match(text.text, /"hour":"09:00"/); + if (process.argv.includes('--legacy')) { + check.equal(forecasts.structuredContent, undefined, 'stripped on legacy era'); + } else { + const sc = forecasts.structuredContent as Array<{ hour: string; celsius: number }>; + check.equal(sc.length, 2); + check.equal(sc[0]?.hour, '09:00'); + } await client.close(); }); diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts index bdca9c8c17..1a04f40f78 100644 --- a/examples/schema-validators/server.ts +++ b/examples/schema-validators/server.ts @@ -48,6 +48,26 @@ function buildServer(): McpServer { } ); + // SEP-2106: outputSchema may have any JSON Schema root (here an array), and + // structuredContent may be any JSON value. When structuredContent is not an + // object and the handler returns no text block, the SDK injects a serialized + // JSON text block so legacy clients have something to read. + server.registerTool( + 'list-forecasts', + { + description: 'Hourly forecast (array structuredContent)', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.array(z.object({ hour: z.string(), celsius: z.number() })) + }, + async () => ({ + content: [], + structuredContent: [ + { hour: '09:00', celsius: 18 }, + { hour: '10:00', celsius: 21 } + ] + }) + ); + return server; } diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 85f815c189..dbc4ce00cf 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -105,9 +105,13 @@ async function Client_callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion Client_callTool_structuredOutput } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 53a8b0c2ae..222c426440 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -76,6 +76,7 @@ import { resolveInputRequiredDriverConfig, runInputRequiredFlow, scanXMcpHeaderDeclarations, + SchemaCompileError, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY, @@ -412,6 +413,19 @@ interface ListenStateEntry { settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void; } +/** + * Per-tool result of compiling an `outputSchema` (SEP-2106). Stored on the + * response-cache substrate's stamp-keyed `name → validator` index so it + * inherits that substrate's invalidation lifecycle (`list_changed` evicts, + * a refetched `tools/list` re-derives, `resetForReconnect` clears) — no + * parallel map to keep in sync. + * + * @internal + */ +type OutputSchemaCompileResult = + | { ok: true; validator: JsonSchemaValidator } + | { ok: false; validator?: undefined; compileError: unknown }; + /** * An MCP client on top of a pluggable transport. * @@ -1640,22 +1654,28 @@ export class Client extends Protocol { } /** - * Compile a single tool's `outputSchema` (or `undefined` when absent / - * uncompilable) — the caller-supplied-definition path of - * {@linkcode callTool} so an explicit `options.toolDefinition` is the - * source for BOTH mirroring AND output validation. Also passed as the - * compile callback to {@linkcode ClientResponseCache.outputValidator} so - * the cache class stays free of any validator-provider dependency. + * Compile a single tool's `outputSchema`. Passed as the compile callback to + * {@linkcode ClientResponseCache.outputValidator} so the cache class stays + * free of any validator-provider dependency, and called directly for the + * `options.toolDefinition` path of {@linkcode callTool} (a one-off + * caller-supplied definition is compiled in isolation and never enters the + * cache, so it cannot poison the listed tool of the same name). + * + * Returns `undefined` when the tool has no `outputSchema`, or a + * discriminated `{ok}` result otherwise. SEP-2106: ANY throw — from the + * ref/bounds/dialect guard or from the underlying engine — is captured as + * `{ok: false, compileError}` so one bad schema does not poison the rest + * of the listing; `callTool()` surfaces it as a typed `InvalidParams` + * error before the request. The `{ok}` discriminator (not + * `compileError !== undefined`) means a custom provider that does + * `throw undefined` is still treated as a captured failure. */ - private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { + private _compileOutputValidator(tool: Tool): OutputSchemaCompileResult | undefined { if (!tool.outputSchema) return undefined; try { - return this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + return { ok: true, validator: this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) }; } catch (error) { - console.warn( - `[mcp-sdk] tool '${tool.name}': outputSchema failed to compile and will not be validated — ${error instanceof Error ? error.message : String(error)}` - ); - return undefined; + return { ok: false, compileError: error }; } } @@ -2098,9 +2118,13 @@ export class Client extends Protocol { * arguments: { weightKg: 70, heightM: 1.75 } * }); * - * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * // Machine-readable output for the client application. SEP-2106: structuredContent is + * // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + * if (result.structuredContent !== undefined) { + * const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + * if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + * console.log(sc.bmi); + * } * } * ``` */ @@ -2150,6 +2174,30 @@ export class Client extends Protocol { return Object.keys(paramHeaders).length === 0 ? options : { ...options, headers: { ...options?.headers, ...paramHeaders } }; }; + // SEP-2106: resolve the output validator BEFORE the request so a tool whose outputSchema + // fails to compile (ref-denied / bounds-exceeded / unsupported-dialect / engine error) is + // surfaced here, per-tool, without a wasted network round-trip and server-side handler + // execution. When the caller supplied `toolDefinition`, that definition is the source for + // BOTH the `Mcp-Param-*` mirroring above AND output validation — the two derived views + // must agree — and is compiled in isolation (never written to the cache). The cache read + // is guarded: a custom store whose `get()` rejects routes to `onerror` and degrades to + // skipping validation (same outcome as a cold cache). + let compiled = + options?.toolDefinition === undefined + ? await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)) + : this._compileOutputValidator(options.toolDefinition); + const assertCompiled = (): void => { + if (compiled === undefined || compiled.ok) return; + const compileError = compiled.compileError; + const message = (compileError instanceof Error ? compileError.message : String(compileError)).slice(0, 200); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool '${params.name}' has an invalid outputSchema: ${message}`, { + reason: compileError instanceof SchemaCompileError ? compileError.reason.kind : 'invalid-schema' + }); + }; + assertCompiled(); + // The method-keyed request() path validates the era registry's plain // CallToolResult schema — with the result map aligned to the typed // map there is no wider union to narrow away (Q1-SD2 holds by @@ -2188,28 +2236,25 @@ export class Client extends Protocol { // `HEADER_MISMATCH` — the refetch failure is observable only // through `onerror`. await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_)); + // Re-resolve the output validator against the freshly-fetched entry — the pre-flight + // `compiled` was resolved from the now-evicted cache and may be stale (different + // outputSchema) or absent (cold cache on the first attempt). The recovery path is only + // entered when `options.toolDefinition` is undefined, so the cache is the sole source. + // Re-run the same fail-fast compile-error check before issuing the retry. + compiled = await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error_ => void this._reportStoreError(error_)); + assertCompiled(); result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); } - // Check if the tool has an outputSchema. When the caller supplied - // `toolDefinition`, that definition is the source for BOTH the - // `Mcp-Param-*` mirroring above AND the output validation here — the - // two derived views must agree. The cache read is guarded the same - // way as `evict()`/`set()` above: a custom store whose `get()` - // rejects AFTER the server has already executed the call must not - // surface as a `callTool()` rejection (a caller that retries on - // failure would re-execute a possibly side-effecting tool). Route to - // `onerror` and degrade to skipping validation — the same outcome as - // a cold cache. - const validator = - options?.toolDefinition === undefined - ? await this._cache - .outputValidator(params.name, tool => this._compileOutputValidator(tool)) - .catch(error => void this._reportStoreError(error)) - : this._compileOutputValidator(options.toolDefinition); + const validator = compiled !== undefined && compiled.ok ? compiled.validator : undefined; if (validator) { - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error). + // SEP-2106: presence is `=== undefined`, not falsy — `null`/`0`/`false`/`""` are legal + // values and are validated below (so a falsy value against an object-typed schema still + // fails; this is not a guard weakening). + if (result.structuredContent === undefined && !result.isError) { throw new ProtocolError( ProtocolErrorCode.InvalidRequest, `Tool ${params.name} has an output schema but did not return structured content` @@ -2217,7 +2262,7 @@ export class Client extends Protocol { } // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { + if (result.structuredContent !== undefined && !result.isError) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index cc99221e7e..0fbf451cc2 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -612,9 +612,14 @@ export class ClientResponseCache { * `Client` passes its `_jsonSchemaValidator` wrapper) so this * class carries no validator-provider dependency. One tool's uncompilable * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) - * must not poison every other tool's `callTool` — the callback returns - * `undefined` (and warns naming the offender) for the bad one and the - * index simply omits it. + * must not poison every other tool's `callTool` — the callback isolates + * that compile error per tool by returning a per-tool error variant which + * the index stores alongside the good ones, and `callTool` surfaces it as + * a typed `InvalidParams` only for that name. Because the error is held on + * this stamp-keyed substrate (not a parallel map), it inherits the + * substrate's invalidation lifecycle: a `list_changed` eviction drops it, + * a refetched `tools/list` re-derives it, and `resetForReconnect` clears + * the lot. */ async outputValidator(name: string, compile: (tool: Tool) => V | undefined): Promise { const entry = await this._probe('tools/list'); @@ -625,10 +630,8 @@ export class ClientResponseCache { if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) { const byName = new Map(); for (const tool of (entry.value as ListToolsResult).tools) { - if (tool.outputSchema) { - const validator = compile(tool); - if (validator !== undefined) byName.set(tool.name, validator); - } + const compiled = compile(tool); + if (compiled !== undefined) byName.set(tool.name, compiled); } this._toolOutputValidatorIndex = { stamp: entry.stamp, byName }; } diff --git a/packages/client/src/validators/ajv.ts b/packages/client/src/validators/ajv.ts index 770df3f57a..b00f366796 100644 --- a/packages/client/src/validators/ajv.ts +++ b/packages/client/src/validators/ajv.ts @@ -1,12 +1,18 @@ /** - * Customisation entry point for the AJV validator. Re-exports `Ajv` + `addFormats` from the - * SDK's bundled copy, so customising the validator needs no extra installs. + * Customisation entry point for the AJV validator. Re-exports `Ajv` and `addFormats` from the + * SDK's bundled copy. 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 + * `import { Ajv2020 } from 'ajv/dist/2020.js'`. The re-exported `Ajv` is the **draft-07** class, + * kept for opting back to the pre-SEP-1613 default, and would silently downgrade dialect if used + * for routine customisation. * * @example * ```ts - * import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; + * import { Ajv2020 } from 'ajv/dist/2020.js'; + * import { addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; * - * const ajv = new Ajv({ strict: true, allErrors: true }); + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 87ff1e9055..ce7d1cfd21 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -114,6 +114,88 @@ describe('client JSON Schema validator overrides', () => { await serverTransport.close(); }); + describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => { + // SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is + // surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is + // held on the response-cache substrate's stamp-keyed `name → validator` index, so it + // inherits that substrate's invalidation lifecycle — a refetched `tools/list` re-derives + // it from scratch (no stale-entry bug when the server fixes the tool by removing the + // schema). The caller-supplied `toolDefinition` path is compiled in isolation and never + // touches the cache, so a one-off bad definition cannot poison the listed tool. + async function connectMutableToolsClient(getTools: () => unknown[]) { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.onmessage = async message => { + if (!('method' in message) || !('id' in message)) return; + if (message.method === 'initialize') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } else if (message.method === 'tools/list') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { tools: getTools() } + } satisfies JSONRPCMessage); + } else if (message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { count: 1 } } + } satisfies JSONRPCMessage); + } + }; + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) }; + } + + // A network `$ref` is rejected at compile time by the SDK's built-in ref guard. + const BAD_SCHEMA = { type: 'object', $ref: 'https://example.invalid/schema.json' } as const; + const GOOD_SCHEMA = { type: 'object', properties: { count: { type: 'number' } } } as const; + + test('re-advertising a tool WITHOUT the bad outputSchema clears the captured failure', async () => { + let tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect(client.callTool({ name: 't' })).rejects.toThrow(/invalid outputSchema/); + + // Server fixes the tool by removing outputSchema entirely; refetched `tools/list` + // re-derives the index from scratch — no stale compile-error entry survives. + tools = [{ name: 't', inputSchema: { type: 'object' } }]; + await client.listTools(); + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + content: [{ type: 'text', text: 'ok' }] + }); + + await close(); + }); + + test('a one-off `toolDefinition` with a bad outputSchema does not poison the listed tool', async () => { + const tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: GOOD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect( + client.callTool({ name: 't' }, { toolDefinition: { name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA } }) + ).rejects.toThrow(/invalid outputSchema/); + + // Subsequent plain callTool of the same name (against the cached, valid listed + // schema) succeeds — the one-off definition never entered the cache. + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + structuredContent: { count: 1 } + }); + + await close(); + }); + }); + test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index ff20ee1ac5..9487d214e5 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -133,4 +133,6 @@ export type { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; // fromJsonSchema is intentionally NOT exported here — the server and client packages // provide runtime-aware wrappers that default to the appropriate validator via _shims. +export type { SchemaCompileErrorReason, SchemaSafetyLimits } from '../../validators/schemaBounds.js'; +export { assertSchemaSafeToCompile, isSameDocumentRef, SchemaCompileError } from '../../validators/schemaBounds.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54c133195e..61f30d13f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,4 +35,6 @@ export { codecForVersion } from './wire/codec.js'; export type { AjvJsonSchemaValidator } from './validators/ajvProvider.js'; export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from './validators/cfWorkerProvider.js'; export * from './validators/fromJsonSchema.js'; +export type { SchemaCompileErrorReason, SchemaSafetyLimits } from './validators/schemaBounds.js'; +export { assertSchemaSafeToCompile, isSameDocumentRef, SchemaCompileError } from './validators/schemaBounds.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index 0a5f4b7cd4..5044495473 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -1,3 +1,5 @@ +import * as z from 'zod/v4'; + import { CallToolResultSchema, InitializedNotificationSchema, @@ -71,6 +73,13 @@ export const isJSONRPCErrorResponse = (value: unknown): value is JSONRPCErrorRes */ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => JSONRPCResponseSchema.safeParse(value).success; +/** + * Narrows to the public {@linkcode CallToolResult} type (`structuredContent: unknown` per + * SEP-2106); the underlying era-neutral runtime schema keeps the 2025 record shape for + * wire-parse byte-identity (Q10-L2), so we widen locally rather than touch `schemas.ts`. + */ +const PublicCallToolResultSchema = CallToolResultSchema.extend({ structuredContent: z.unknown().optional() }); + /** * Checks if a value is a valid {@linkcode CallToolResult}. * @@ -79,13 +88,17 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J * (e.g. `resultType`) still passes through the loose index signature. Use a * transport-level parse to validate raw wire traffic. * + * Narrows to the public {@linkcode CallToolResult} type (`structuredContent: unknown` per + * SEP-2106); the underlying era-neutral runtime schema keeps the 2025 record shape for + * wire-parse byte-identity. + * * @param value - The value to check. * * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. */ export const isCallToolResult = (value: unknown): value is CallToolResult => { if (typeof value !== 'object' || value === null || !('content' in value)) return false; - return CallToolResultSchema.safeParse(value).success; + return PublicCallToolResultSchema.safeParse(value).success; }; /** diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index cb246c9e72..47d6543955 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -1,4 +1,4 @@ -import type * as z from 'zod/v4'; +import * as z from 'zod/v4'; import { OAuthClientInformationFullSchema, @@ -236,6 +236,42 @@ type SpecTypeInputs = { type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; +/** + * SEP-2106: the public `CallToolResult` / `CompatibilityCallToolResult` / `ToolResultContent` types + * widen `structuredContent` to `unknown`, and the public `Tool` / `ListToolsResult` types widen + * `outputSchema` to a loose JSON Schema document; the era-neutral runtime schema in `schemas.ts` + * keeps the 2025 record / `type:'object'` shapes for wire-parse byte-identity (Q10-L2). Override the + * registered runtime validators here so `specTypeSchemas` / `isSpecType` accept the public shape + * without touching `schemas.ts`. + * + * The sampling surfaces (`SamplingMessageContentBlock` / `SamplingMessage` / + * `CreateMessageResultWithTools`) embed `ToolResultContent` as a discriminated-union arm, so they are + * rebuilt here from the widened arm to keep `isSpecType` consistent with `isSpecType.ToolResultContent`. + */ +const PublicCallToolResultSchema = schemas.CallToolResultSchema.extend({ structuredContent: z.unknown().optional() }); +const PublicToolResultContentSchema = schemas.ToolResultContentSchema.extend({ structuredContent: z.unknown().optional() }); +const PublicToolSchema = schemas.ToolSchema.extend({ + outputSchema: z.object({ $schema: z.string().optional() }).catchall(z.unknown()).optional() +}); +const PublicSamplingMessageContentBlockSchema = z.discriminatedUnion('type', [ + schemas.TextContentSchema, + schemas.ImageContentSchema, + schemas.AudioContentSchema, + schemas.ToolUseContentSchema, + PublicToolResultContentSchema +]); +const publicSamplingContent = z.union([PublicSamplingMessageContentBlockSchema, z.array(PublicSamplingMessageContentBlockSchema)]); +const SCHEMA_OVERRIDES: Partial> = { + CallToolResultSchema: PublicCallToolResultSchema, + CompatibilityCallToolResultSchema: PublicCallToolResultSchema.or(schemas.ResultSchema.extend({ toolResult: z.unknown() })), + ToolResultContentSchema: PublicToolResultContentSchema, + ToolSchema: PublicToolSchema, + ListToolsResultSchema: schemas.ListToolsResultSchema.extend({ tools: z.array(PublicToolSchema) }), + SamplingMessageContentBlockSchema: PublicSamplingMessageContentBlockSchema, + SamplingMessageSchema: schemas.SamplingMessageSchema.extend({ content: publicSamplingContent }), + CreateMessageResultWithToolsSchema: schemas.CreateMessageResultWithToolsSchema.extend({ content: publicSamplingContent }) +}; + const _specTypeSchemas: Record = {}; const _isSpecType: Record boolean> = {}; function register(key: string, schema: z.ZodType): void { @@ -245,7 +281,7 @@ function register(key: string, schema: z.ZodType): void { } for (const key of SPEC_SCHEMA_KEYS) { // eslint-disable-next-line import/namespace -- key is constrained to keyof typeof schemas via the satisfies clause above - register(key, schemas[key]); + register(key, SCHEMA_OVERRIDES[key] ?? schemas[key]); } for (const [key, schema] of Object.entries(authSchemas)) { register(key, schema); diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index f4a0451871..101ce8b78b 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -212,6 +212,36 @@ type WireOnlyResultKey = 'resultType'; */ type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; +/** + * SEP-2106: re-types `structuredContent` as `unknown` on a (possibly union) schema-inferred result + * type while preserving every other declared member, optionality, and the loose index signature. + * `Omit & {…}` cannot be used here because `Omit` over an index-signatured + * type erases the known keys. + */ +type WidenStructuredContent = T extends unknown ? { [K in keyof T]: K extends 'structuredContent' ? unknown : T[K] } : never; + +/** + * SEP-2106: re-types `outputSchema` on the schema-inferred `Tool` to the loose JSON Schema 2020-12 + * shape (any JSON Schema document, not just `type:'object'`). The era-neutral runtime schema in + * `schemas.ts` keeps the 2025 `type:'object'` constraint for wire-parse byte-identity (Q10-L2); only + * the public TypeScript type widens. `Omit & {…}` cannot be used here because + * `Omit` over an index-signatured type erases the known keys. + */ +type Sep2106OutputSchema = { $schema?: string; [k: string]: unknown }; +type WidenToolOutputSchema = T extends unknown + ? { [K in keyof T]: K extends 'outputSchema' ? Sep2106OutputSchema | undefined : T[K] } + : never; +/** Re-maps a schema-inferred type's `tools` member to the public (widened) {@linkcode Tool}`[]`. */ +type WithPublicTools = T extends unknown ? { [K in keyof T]: K extends 'tools' ? Tool[] : T[K] } : never; +/** + * Re-maps a sampling schema's `content` member to the public (widened) + * {@linkcode SamplingMessageContentBlock} so the embedded `tool_result` arm carries the SEP-2106 + * `unknown` `structuredContent`. + */ +type WithPublicSamplingContent = T extends unknown + ? { [K in keyof T]: K extends 'content' ? SamplingMessageContentBlock | SamplingMessageContentBlock[] : T[K] } + : never; + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; @@ -385,7 +415,7 @@ export type ToolUseContent = Infer; * in the specification for at least twelve months. Migrate to calling LLM * provider APIs directly. */ -export type ToolResultContent = Infer; +export type ToolResultContent = WidenStructuredContent>; export type EmbeddedResource = Infer; export type ResourceLink = Infer; export type ContentBlock = Infer; @@ -396,12 +426,24 @@ export type PromptListChangedNotification = Infer; export type ToolExecution = Infer; -export type Tool = Infer; +/** + * `outputSchema` is widened to the loose SEP-2106 shape (any JSON Schema 2020-12 document, not just + * `type:'object'`). The 2025-era wire parse retains the `type:'object'` runtime constraint; only the + * public TypeScript type widens. {@linkcode ListToolsResult}`.tools` carries the same widened shape. + */ +export type Tool = WidenToolOutputSchema>; export type ListToolsRequest = Infer; -export type ListToolsResult = StripWireOnly>; +export type ListToolsResult = WithPublicTools>>; export type CallToolRequestParams = Infer; -export type CallToolResult = StripWireOnly>; -export type CompatibilityCallToolResult = StripWireOnly>; +/** + * `structuredContent` is widened to `unknown` per SEP-2106 (the 2026-07-28 protocol revision allows + * any JSON value here, not just objects). Narrow before property access — e.g. + * `if (result.structuredContent !== undefined && typeof result.structuredContent === 'object') …`. + * The 2025-era wire parse retains the `Record` runtime shape; only the public + * TypeScript type widens. + */ +export type CallToolResult = WidenStructuredContent>>; +export type CompatibilityCallToolResult = WidenStructuredContent>>; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; @@ -463,17 +505,21 @@ export type ModelPreferences = Infer; */ export type SamplingContent = Infer; /** + * The embedded `tool_result` arm carries the SEP-2106 widened (`unknown`) `structuredContent`; see + * {@linkcode ToolResultContent}. {@linkcode SamplingMessage}`.content` and + * {@linkcode CreateMessageResultWithTools}`.content` carry the same widened arm. + * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains * in the specification for at least twelve months. Migrate to calling LLM * provider APIs directly. */ -export type SamplingMessageContentBlock = Infer; +export type SamplingMessageContentBlock = WidenStructuredContent>; /** * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains * in the specification for at least twelve months. Migrate to calling LLM * provider APIs directly. */ -export type SamplingMessage = Infer; +export type SamplingMessage = WithPublicSamplingContent>; /** * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains * in the specification for at least twelve months. Migrate to calling LLM @@ -497,7 +543,7 @@ export type CreateMessageResult = StripWireOnly>; +export type CreateMessageResultWithTools = WithPublicSamplingContent>>; /* Elicitation */ export type BooleanSchema = Infer; @@ -910,13 +956,16 @@ export type CreateMessageRequestParamsBase = Omit; } export type CompleteRequestResourceTemplate = ExpandRecursively< diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..ede414c5ec 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -204,6 +204,20 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output') { + // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or + // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is + // PROVABLY object-shaped — either it carries object keywords at the root, or every + // member of a root `oneOf`/`anyOf`/`allOf` is itself `type:'object'` (the + // `z.discriminatedUnion(...)`, `z.union([z.object(...), ...])`, `z.intersection(...)` + // cases). Those pre-SEP schemas were valid 2025 wire data via the unconditional stamp, + // so the stamp is kept where it is provably safe. A typeless root that is NOT provably + // object-shaped (e.g. `z.union([z.string(), z.number()])` → `{anyOf:[…]}`) is returned + // as-is — stamping there would be self-contradictory. The legacy projection drop gates + // anything that does not end up `type:'object'`. + if (result.type !== undefined) return result; + return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result; + } if (result.type !== undefined && result.type !== 'object') { throw new Error( `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + @@ -213,6 +227,31 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in return { type: 'object', ...result }; } +/** + * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords + * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a + * composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively + * provably object-shaped (e.g. a nested `discriminatedUnion`). `$ref` is not followed. Used to + * decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory. + */ +function isProvablyObjectShapedRoot(schema: Record): boolean { + if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { + return true; + } + for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { + const members = schema[key]; + if (Array.isArray(members) && members.length > 0) { + return members.every( + m => + m !== null && + typeof m === 'object' && + ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) + ); + } + } + return false; +} + // Validation export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; diff --git a/packages/core/src/validators/ajvProvider.examples.ts b/packages/core/src/validators/ajvProvider.examples.ts index abf8d1572a..7258c20291 100644 --- a/packages/core/src/validators/ajvProvider.examples.ts +++ b/packages/core/src/validators/ajvProvider.examples.ts @@ -7,7 +7,9 @@ * @module */ -import { addFormats, Ajv, AjvJsonSchemaValidator } from './ajvProvider.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; + +import { addFormats, AjvJsonSchemaValidator } from './ajvProvider.js'; /** * Example: Default AJV instance. @@ -21,10 +23,17 @@ function AjvJsonSchemaValidator_default() { /** * Example: Custom AJV instance. + * + * 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 `import { Ajv2020 } from 'ajv/dist/2020.js'` + * so the custom instance keeps validating JSON Schema 2020-12 (SEP-1613). Passing `new Ajv(...)` + * (the draft-07 class) would silently downgrade dialect. */ function AjvJsonSchemaValidator_customInstance() { //#region AjvJsonSchemaValidator_customInstance - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_customInstance return validator; @@ -33,12 +42,15 @@ function AjvJsonSchemaValidator_customInstance() { /** * Example: Custom AJV instance with formats registered. * - * `Ajv` and `addFormats` are re-exported from this module so customising the validator - * requires no extra `package.json` dependencies — both come from the SDK's bundled copy. + * `addFormats` is re-exported from this module. 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 `import { Ajv2020 } from 'ajv/dist/2020.js'`. */ function AjvJsonSchemaValidator_withFormats() { //#region AjvJsonSchemaValidator_withFormats - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); addFormats(ajv); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_withFormats diff --git a/packages/core/src/validators/ajvProvider.ts b/packages/core/src/validators/ajvProvider.ts index f62a8469ae..50bf3c491b 100644 --- a/packages/core/src/validators/ajvProvider.ts +++ b/packages/core/src/validators/ajvProvider.ts @@ -2,11 +2,22 @@ * AJV-based JSON Schema validator provider */ -import { Ajv } from 'ajv'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import _addFormats from 'ajv-formats'; +import { assertSchemaSafeToCompile, SchemaCompileError, truncateForReflection } from './schemaBounds.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; +/** + * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). Anything else + * declared in `$schema` is rejected with `SchemaCompileError{kind:'unsupported-dialect'}` so the + * caller gets a graceful error rather than an engine-level crash or silent mis-validation. + */ +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; @@ -21,17 +32,16 @@ interface AjvValidateFunction { errors?: any; } -function createDefaultAjvInstance(): Ajv { - const ajv = new Ajv({ +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +function createDefaultAjvInstance(): AjvLike { + const ajv = new Ajv2020({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); - - const addFormats = _addFormats as unknown as typeof _addFormats.default; addFormats(ajv); - return ajv; } @@ -39,6 +49,14 @@ function createDefaultAjvInstance(): Ajv { * 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 `SchemaCompileError{kind:'unsupported-dialect'}`; 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 `import { Ajv2020 } from 'ajv/dist/2020.js'` — `new Ajv(...)` is the + * draft-07 class and would silently downgrade dialect. + * * @example Use with default configuration * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" * const validator = new AjvJsonSchemaValidator(); @@ -46,31 +64,69 @@ function createDefaultAjvInstance(): Ajv { * * @example Use with a custom AJV instance * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * const validator = new AjvJsonSchemaValidator(ajv); * ``` * * @example Register ajv-formats * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_withFormats" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ export class AjvJsonSchemaValidator implements jsonSchemaValidator { - private _ajv: AjvLike; + private readonly _ajv: AjvLike; + /** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */ + private readonly _userAjv: boolean; /** - * @param ajv - Optional pre-configured AJV-compatible instance. If omitted, a default instance is - * created with `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, - * and `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass - * an instance need not have `ajv` installed. + * @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 + * `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and + * `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an + * instance need not have `ajv` installed. */ constructor(ajv?: AjvLike) { + this._userAjv = ajv !== undefined; this._ajv = ajv ?? createDefaultAjvInstance(); } + /** + * JSON Schema dialects this provider can validate, as short identifiers. Advisory/diagnostic + * only — the SDK does not branch on it. + */ + readonly supportedDialects = ['2020-12'] as const; + + /** + * The dialect this provider applies when a schema declares no `$schema`. + * Advisory/diagnostic only — the SDK does not branch on it. + */ + readonly defaultDialect = '2020-12'; + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + assertSchemaSafeToCompile(schema); + + // Caller supplied a specific engine — do not second-guess by `$schema` + // (bring-your-own-validator means bring-your-own-conformance). + if ( + !this._userAjv && + '$schema' in schema && + typeof schema.$schema === 'string' && + !DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, '')) + ) { + const declared = truncateForReflection(schema.$schema); + throw new SchemaCompileError( + { kind: 'unsupported-dialect', declared, supported: this.supportedDialects }, + `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.` + ); + } + const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) @@ -94,6 +150,24 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { } } +/** + * Draft-07 AJV class, re-exported for consumers who need to opt back to the pre-SEP-1613 default. + * The full v1-equivalent construction is: + * + * ```ts + * const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); + * addFormats(ajv); + * new AjvJsonSchemaValidator(ajv); + * ``` + * + * (omitting `validateSchema: false` makes a 2020-12-stamped `$schema` fail with an opaque + * "no schema with key or ref …" engine error; omitting `addFormats` silently drops `format` + * validation that the v1 default had). + * + * 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 `import { Ajv2020 } from 'ajv/dist/2020.js'`. + */ export { Ajv } from 'ajv'; /** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */ -export const addFormats = _addFormats as unknown as typeof _addFormats.default; +export { addFormats }; diff --git a/packages/core/src/validators/cfWorkerProvider.ts b/packages/core/src/validators/cfWorkerProvider.ts index 6fcc3d507e..54f7e037b6 100644 --- a/packages/core/src/validators/cfWorkerProvider.ts +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -10,6 +10,7 @@ import { Validator } from '@cfworker/json-schema'; +import { assertSchemaSafeToCompile, SchemaCompileError, truncateForReflection } from './schemaBounds.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; /** @@ -17,11 +18,25 @@ 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). Anything else + * declared in `$schema` is rejected with `SchemaCompileError{kind:'unsupported-dialect'}`. + */ +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. * - * @example Use with default configuration (draft 2020-12, shortcircuit on) + * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different + * `$schema` are rejected with `SchemaCompileError{kind:'unsupported-dialect'}`. Passing an + * explicit `draft` to the constructor overrides this — that draft is used for every schema + * regardless of `$schema`. + * + * @example Use with default configuration (2020-12, shortcircuit on) * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" * const validator = new CfWorkerJsonSchemaValidator(); * ``` @@ -35,21 +50,36 @@ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; * ``` */ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - private shortcircuit: boolean; - private draft: CfWorkerSchemaDraft; + private readonly shortcircuit: boolean; + /** Caller-supplied draft; when set, the `$schema` check is skipped (caller owns dialect). */ + private readonly draft?: CfWorkerSchemaDraft; /** * Create a validator * * @param options - Configuration options * @param options.shortcircuit - If `true`, stop validation after first error (default: `true`) - * @param options.draft - JSON Schema draft version to use (default: `'2020-12'`) + * @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`. */ constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { this.shortcircuit = options?.shortcircuit ?? true; - this.draft = options?.draft ?? '2020-12'; + this.draft = options?.draft; } + /** + * JSON Schema dialects this provider can validate, as short identifiers. Advisory/diagnostic + * only — the SDK does not branch on it. + */ + readonly supportedDialects = ['2020-12'] as const; + + /** + * The dialect this provider applies when a schema declares no `$schema` and no `{draft}` is + * forced. Advisory/diagnostic only — the SDK does not branch on it. + */ + readonly defaultDialect = '2020-12'; + /** * Create a validator for the given JSON Schema * @@ -59,8 +89,27 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { + assertSchemaSafeToCompile(schema); + + // 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 = truncateForReflection(schema.$schema); + throw new SchemaCompileError( + { kind: 'unsupported-dialect', declared, supported: this.supportedDialects }, + `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'; // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible - const validator = new Validator(schema as ConstructorParameters[0], this.draft, this.shortcircuit); + const validator = new Validator(schema as ConstructorParameters[0], draft, this.shortcircuit); return (input: unknown): JsonSchemaValidatorResult => { const result = validator.validate(input); diff --git a/packages/core/src/validators/schemaBounds.ts b/packages/core/src/validators/schemaBounds.ts new file mode 100644 index 0000000000..feed50fafb --- /dev/null +++ b/packages/core/src/validators/schemaBounds.ts @@ -0,0 +1,206 @@ +/** + * Safety guards applied before a JSON Schema is handed to a validator engine. + * + * SEP-2106 (§Security Implications) widens tool `inputSchema` / `outputSchema` to the full JSON + * Schema 2020-12 vocabulary. Two abuse vectors come with that flexibility, and this module + * addresses both before a schema — which may originate from an untrusted peer (a server's + * advertised tool definitions, or a client's elicitation response schema) — is compiled: + * + * 1. **`$ref` SSRF / fetch-DoS.** JSON Schema 2020-12 allows `$ref` to point at an absolute URI. + * A validator that dereferences such a reference over the network gives an attacker a + * server-side request-forgery primitive. The SDK never dereferences non-local references; any + * `$ref` / `$dynamicRef` whose value is not a same-document reference (the empty string, or a + * `#…` fragment such as `#/$defs/Foo` or `#anchor`) is rejected outright. + * 2. **Composition resource use.** Composition keywords (`anyOf` / `oneOf` / `allOf` / `if` / + * `then` / `else`) and `$defs` enable pathologically expensive schemas. The walk bounds the + * maximum nesting depth and the total number of (sub)schema objects so a malicious tool + * definition cannot act as a CPU-DoS vector against the validator. + * + * Both built-in providers ({@link AjvJsonSchemaValidator}, {@link CfWorkerJsonSchemaValidator}) + * call {@link assertSchemaSafeToCompile} at the top of `getValidator`. Custom + * {@link jsonSchemaValidator} implementations are responsible for their own conformance and SHOULD + * call this function themselves to inherit the SDK's defaults. + */ + +/** Maximum allowed nesting depth of a JSON Schema before it is rejected. */ +export const DEFAULT_MAX_SCHEMA_DEPTH = 64; + +/** Maximum allowed total number of (sub)schema objects before a JSON Schema is rejected. */ +export const DEFAULT_MAX_SUBSCHEMA_COUNT = 10_000; + +/** + * Vocabulary list of JSON Schema keywords whose values are themselves schemas. Currently has no + * consumers; intended as the single source for the SEP-2243 `x-mcp-header` reachability scan in + * `mcpParamHeaders.ts` (which today carries its own private list — consolidation is a follow-up). + * + * NOTE: {@link assertSchemaSafeToCompile} does **not** consult this list; its walk is intentionally + * exhaustive (every key) so a future or vendor keyword cannot smuggle a `$ref` past the guard. + */ +export const SUBSCHEMA_KEYWORDS: readonly string[] = [ + 'properties', + 'items', + 'prefixItems', + 'contains', + 'additionalProperties', + 'patternProperties', + 'propertyNames', + 'unevaluatedProperties', + 'unevaluatedItems', + 'dependentSchemas', + 'contentSchema', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$defs', + 'definitions', + 'additionalItems' +]; + +/** Hard cap on attacker-controlled strings reflected into {@link SchemaCompileError}. */ +const REFLECTED_STRING_CAP = 200; + +/** + * Bound an attacker-controlled string to {@link REFLECTED_STRING_CAP} characters before it reaches + * a logger sink. Exported for the built-in providers' error-message templates. + * + * @internal + */ +export function truncateForReflection(value: string): string { + return value.length > REFLECTED_STRING_CAP ? `${value.slice(0, REFLECTED_STRING_CAP)}…` : value; +} + +/** Structured reason a JSON Schema was refused at compile time. */ +export type SchemaCompileErrorReason = + | { kind: 'ref-denied'; ref: string } + | { kind: 'unsupported-dialect'; declared: string; supported: readonly string[] } + | { kind: 'bounds-exceeded'; bound: 'maxDepth' | 'maxSubschemas' } + | { kind: 'invalid-schema'; detail: string }; + +/** + * Thrown by the built-in validator providers when a schema is refused before being handed to the + * underlying engine. Carries a structured {@link reason} suitable for surfacing as `error.data`. + * + * The `ref` / `declared` fields are attacker-controlled and are truncated to 200 characters before + * being stored (per SEP-2106 §Security Implications); callers MUST NOT re-read the raw schema value + * into a logger sink without applying the same bound. + */ +export class SchemaCompileError extends Error { + readonly reason: SchemaCompileErrorReason; + + constructor(reason: SchemaCompileErrorReason, message: string) { + super(message); + this.name = 'SchemaCompileError'; + switch (reason.kind) { + case 'ref-denied': { + this.reason = { kind: 'ref-denied', ref: truncateForReflection(reason.ref) }; + break; + } + case 'unsupported-dialect': { + this.reason = { + kind: 'unsupported-dialect', + declared: truncateForReflection(reason.declared), + supported: reason.supported + }; + break; + } + case 'invalid-schema': { + this.reason = { kind: 'invalid-schema', detail: truncateForReflection(reason.detail) }; + break; + } + default: { + this.reason = reason; + } + } + } +} + +/** + * A `$ref` / `$dynamicRef` is "same-document" iff it is the empty string (a self-reference) or + * begins with `#` (a JSON Pointer into `$defs`, or an `#anchor` fragment). Everything else — + * absolute URI, scheme-relative `//host/…`, bare path, `$id`-relative — is non-local and rejected. + */ +export function isSameDocumentRef(ref: string): boolean { + return ref === '' || ref.startsWith('#'); +} + +/** Tunable limits for {@link assertSchemaSafeToCompile}. */ +export interface SchemaSafetyLimits { + /** Maximum nesting depth (default `64`). */ + maxDepth?: number; + /** Maximum total number of (sub)schema objects (default `10_000`). */ + maxSubschemas?: number; +} + +/** + * Throws {@link SchemaCompileError} if a JSON Schema is unsafe to compile — either because it + * carries a non-local `$ref` / `$dynamicRef` (which the SDK refuses to dereference) or because it + * exceeds the configured composition bounds. Safe schemas return normally. + * + * The walk is cycle-guarded on object identity, so a `$defs` entry that references its own ancestor + * object terminates without throwing (the depth bound also caps this, but the explicit guard avoids + * depending on it). + * + * The walk is intentionally **positional-agnostic**: it descends through every key, so a `$ref` + * appearing as data under a value-carrying keyword (`const`, `enum`, `default`, `examples`) is + * over-rejected. This is a deliberate fail-safe trade-off — relaxing it would require the guard to + * track schema-vs-data positions, which is exactly the surface a bypass would target. + * + * @param schema - the JSON Schema (or subschema) to inspect. + * @param limits - optional overrides for the depth / subschema-count caps. + */ +export function assertSchemaSafeToCompile(schema: unknown, limits: SchemaSafetyLimits = {}): void { + const maxDepth = limits.maxDepth ?? DEFAULT_MAX_SCHEMA_DEPTH; + const maxSubschemas = limits.maxSubschemas ?? DEFAULT_MAX_SUBSCHEMA_COUNT; + const seen = new WeakSet(); + let subschemaCount = 0; + + const walk = (node: unknown, depth: number): void => { + if (depth > maxDepth) { + throw new SchemaCompileError( + { kind: 'bounds-exceeded', bound: 'maxDepth' }, + `JSON Schema is too deeply nested (exceeds max depth ${maxDepth}); refusing to compile.` + ); + } + + if (Array.isArray(node)) { + for (const item of node) { + walk(item, depth + 1); + } + return; + } + + if (node === null || typeof node !== 'object') { + return; + } + + if (seen.has(node)) { + return; + } + seen.add(node); + + subschemaCount += 1; + if (subschemaCount > maxSubschemas) { + throw new SchemaCompileError( + { kind: 'bounds-exceeded', bound: 'maxSubschemas' }, + `JSON Schema has too many subschemas (exceeds max ${maxSubschemas}); refusing to compile.` + ); + } + + for (const [key, value] of Object.entries(node)) { + if ((key === '$ref' || key === '$dynamicRef') && typeof value === 'string' && !isSameDocumentRef(value)) { + throw new SchemaCompileError( + { kind: 'ref-denied', ref: value }, + `JSON Schema contains a non-local "${key}" ("${truncateForReflection(value)}"). External reference dereferencing is disabled; ` + + `only same-document references (e.g. "#/$defs/Foo" or "#anchor") are supported.` + ); + } + walk(value, depth + 1); + } + }; + + walk(schema, 0); +} diff --git a/packages/core/src/validators/types.ts b/packages/core/src/validators/types.ts index e2202b4a69..e9cc36ea45 100644 --- a/packages/core/src/validators/types.ts +++ b/packages/core/src/validators/types.ts @@ -52,6 +52,10 @@ export interface jsonSchemaValidator { /** * Create a validator for the given JSON Schema * + * Custom implementations are responsible for the spec's `$ref` MUST-NOT and composition-bounds + * SHOULD (SEP-2106 §Security Implications); call {@link assertSchemaSafeToCompile} on `schema` + * at the top of this method to inherit the SDK's defaults. + * * @param schema - Standard JSON Schema object * @returns A validator function that can be called multiple times */ diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index 76d6d7bfab..5a7d464968 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -286,7 +286,10 @@ const sdkTypeChecks = { }, CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { sdk = spec; - spec = sdk; + // SEP-2106: the public SDK type widens `structuredContent` to `unknown`; the 2025 spec + // type pins it at `{ [key: string]: unknown }`. spec→sdk holds (record ⊂ unknown); the + // reverse intentionally does not. + spec = sdk as typeof sdk & { structuredContent?: { [key: string]: unknown } }; }, CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { sdk = spec; @@ -323,11 +326,14 @@ const sdkTypeChecks = { }, SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { sdk = spec; - spec = sdk; + // SEP-2106: the embedded tool_result arm's `structuredContent` widens to `unknown` on the + // public SDK type. spec→sdk holds; the reverse intentionally does not (see ToolResultContent). + spec = sdk as typeof sdk & { content: SpecTypes.SamplingMessage['content'] }; }, CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { sdk = spec; - spec = sdk; + // SEP-2106: see SamplingMessage above. + spec = sdk as typeof sdk & { content: SpecTypes.CreateMessageResult['content'] }; }, SetLevelRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SetLevelRequest) => { sdk = spec; @@ -566,11 +572,17 @@ const sdkTypeChecks = { }, ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { sdk = spec; - spec = sdk; + // SEP-2106: the public SDK type widens `structuredContent` to `unknown`; the 2025 spec + // type pins it at `{ [key: string]: unknown }`. spec→sdk holds (record ⊂ unknown); the + // reverse intentionally does not. + spec = sdk as typeof sdk & { structuredContent?: { [key: string]: unknown } }; }, SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { sdk = spec; - spec = sdk; + // SEP-2106: the public SDK type widens the tool_result arm's `structuredContent` to + // `unknown`; the 2025 spec type pins it at `{ [key: string]: unknown }`. spec→sdk holds + // (record ⊂ unknown); the reverse intentionally does not. + spec = sdk as typeof sdk & { structuredContent?: { [key: string]: unknown } }; }, Annotations: (sdk: SDKTypes.Annotations, spec: SpecTypes.Annotations) => { sdk = spec; diff --git a/packages/core/test/types/guards.test.ts b/packages/core/test/types/guards.test.ts index 117e9ecda7..370181d55a 100644 --- a/packages/core/test/types/guards.test.ts +++ b/packages/core/test/types/guards.test.ts @@ -107,6 +107,13 @@ describe('isCallToolResult', () => { ).toBe(true); }); + it('SEP-2106: accepts widened structuredContent (arrays / primitives)', () => { + expect(isCallToolResult({ content: [], structuredContent: [1, 2, 3] })).toBe(true); + expect(isCallToolResult({ content: [], structuredContent: 0 })).toBe(true); + expect(isCallToolResult({ content: [], structuredContent: 'x' })).toBe(true); + expect(isCallToolResult({ content: [], structuredContent: null })).toBe(true); + }); + it('returns false for non-objects', () => { expect(isCallToolResult(null)).toBe(false); expect(isCallToolResult(42)).toBe(false); diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 7a077717cf..9376dd4fd9 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -12,6 +12,7 @@ import type { JSONRPCRequest, JSONValue, ResourceTemplateType, + SamplingMessageContentBlock, Tool } from '../../src/types/types.js'; @@ -52,6 +53,34 @@ describe('isSpecType', () => { expect(isSpecType.CallToolResult('string')).toBe(false); }); + it('CallToolResult — SEP-2106 widened structuredContent (override applied)', () => { + expect(isSpecType.CallToolResult({ content: [], structuredContent: [1, 2, 3] })).toBe(true); + expect(isSpecType.CallToolResult({ content: [], structuredContent: 0 })).toBe(true); + expect(isSpecType.CompatibilityCallToolResult({ content: [], structuredContent: 'x' })).toBe(true); + }); + + it('ToolResultContent — SEP-2106 widened structuredContent (override applied)', () => { + const base = { type: 'tool_result', toolUseId: 'tu_1', content: [] }; + expect(isSpecType.ToolResultContent({ ...base, structuredContent: [1, 2, 3] })).toBe(true); + expect(isSpecType.ToolResultContent({ ...base, structuredContent: 0 })).toBe(true); + expect(isSpecType.ToolResultContent({ ...base, structuredContent: null })).toBe(true); + expect(isSpecType.ToolResultContent({ ...base, structuredContent: { ok: true } })).toBe(true); + }); + + it('Sampling surfaces — SEP-2106 widened structuredContent on the embedded tool_result arm', () => { + const tr = { type: 'tool_result', toolUseId: 'x', content: [], structuredContent: [1, 2, 3] }; + expect(isSpecType.SamplingMessageContentBlock(tr)).toBe(true); + expect(isSpecType.SamplingMessage({ role: 'user', content: tr })).toBe(true); + expect(isSpecType.SamplingMessage({ role: 'user', content: [tr] })).toBe(true); + expect(isSpecType.CreateMessageResultWithTools({ model: 'm', role: 'assistant', content: [tr] })).toBe(true); + // non-tool_result arms still validate normally + expect(isSpecType.SamplingMessageContentBlock({ type: 'text', text: 'hi' })).toBe(true); + expect(isSpecType.SamplingMessageContentBlock({ type: 'text' })).toBe(false); + // type-level: the public union's tool_result arm carries `unknown` structuredContent + const block: SamplingMessageContentBlock = { type: 'tool_result', toolUseId: 'x', content: [], structuredContent: [1, 2, 3] }; + void block; + }); + it('ContentBlock — accepts text block, rejects wrong shape', () => { expect(isSpecType.ContentBlock({ type: 'text', text: 'hi' })).toBe(true); expect(isSpecType.ContentBlock({ type: 'text' })).toBe(false); @@ -63,6 +92,19 @@ describe('isSpecType', () => { expect(isSpecType.Tool({ name: 'echo' })).toBe(false); }); + it('Tool — SEP-2106 widened outputSchema (override applied)', () => { + const arrayOut = { name: 'nums', inputSchema: { type: 'object' }, outputSchema: { type: 'array', items: { type: 'number' } } }; + expect(isSpecType.Tool(arrayOut)).toBe(true); + expect(isSpecType.ListToolsResult({ tools: [arrayOut] })).toBe(true); + // type-level: the public Tool['outputSchema'] is the loose SEP-2106 shape, not pinned to type:'object'. + const t: Tool = { + name: 'nums', + inputSchema: { type: 'object' }, + outputSchema: { type: 'array', items: { type: 'number' } } + }; + void t; + }); + it('ResourceTemplate — accepts valid, rejects missing uriTemplate', () => { expect(isSpecType.ResourceTemplate({ name: 'r', uriTemplate: 'file:///{path}' })).toBe(true); expect(isSpecType.ResourceTemplate({ name: 'r' })).toBe(false); @@ -145,12 +187,22 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { // cut reverts to plain equality, and per-revision wire validators are // deliberately NOT public surface (addable later via the versioned // zod-schemas exports). Changeset: codec-split-wire-break. - expectTypeOf().toEqualTypeOf(); + // SEP-2106: the public `CallToolResult` widens `structuredContent` to `unknown` (the 2025 + // runtime schema retains `Record`). Pin every other field exactly via the + // bidirectional-assignability-with-one-field-cast pattern from spec.types.2025-11-25.test.ts + // — spec→public holds directly (Record ⊂ unknown); public→spec holds once structuredContent + // is re-narrowed. + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + // SEP-2106: the public `Tool` widens `outputSchema` to a loose JSON Schema document (the + // 2025 runtime schema retains `type:'object'`). Same bidirectional pin as CallToolResult — + // spec→public holds directly; public→spec holds once outputSchema is re-narrowed. + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 6c3de99d77..505fbf6438 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -39,4 +39,57 @@ describe('standardSchemaToJsonSchema', () => { expect(keys.filter(k => k === 'type')).toHaveLength(1); expect(result.type).toBe('object'); }); + + describe("io='output' (SEP-2106)", () => { + test('union of primitives is returned as-is without type:object stamping', () => { + const result = standardSchemaToJsonSchema(z.union([z.string(), z.number()]), 'output'); + // zod emits a typeless `{anyOf:[…]}` — stamping `type:'object'` here would be + // self-contradictory; the legacy projection drop gates it instead. + expect(result.type).toBeUndefined(); + expect(result.anyOf).toBeDefined(); + }); + + test('nullable object is not force-stamped type:object', () => { + const result = standardSchemaToJsonSchema(z.object({}).nullable(), 'output'); + expect(result.type).not.toBe('object'); + }); + + test('object schemas still get type:object', () => { + const result = standardSchemaToJsonSchema(z.object({ a: z.string() }), 'output'); + expect(result.type).toBe('object'); + }); + + test('discriminatedUnion (typeless oneOf of objects) is stamped type:object', () => { + // every branch is `type:'object'` so stamping is redundant-but-valid; preserves + // 2025-era wire bytes for a pre-SEP object schema. + const schema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('a'), x: z.number() }), + z.object({ kind: z.literal('b'), y: z.string() }) + ]); + const result = standardSchemaToJsonSchema(schema, 'output'); + expect(result.type).toBe('object'); + expect(result.oneOf).toBeDefined(); + }); + + test('union of objects (typeless anyOf of objects) is stamped type:object', () => { + const schema = z.union([z.object({ a: z.number() }), z.object({ b: z.string() })]); + const result = standardSchemaToJsonSchema(schema, 'output'); + expect(result.type).toBe('object'); + }); + + test('union of discriminatedUnions (typeless anyOf of typeless oneOf-of-objects) is stamped type:object', () => { + // Members are themselves typeless object-shaped compositions; the check recurses. + const a = z.discriminatedUnion('kind', [z.object({ kind: z.literal('a'), x: z.number() }), z.object({ kind: z.literal('b') })]); + const b = z.discriminatedUnion('kind', [z.object({ kind: z.literal('c'), y: z.string() }), z.object({ kind: z.literal('d') })]); + const result = standardSchemaToJsonSchema(z.union([a, b]), 'output'); + expect(result.type).toBe('object'); + }); + + test('mixed union (object + primitive) is NOT stamped', () => { + const schema = z.union([z.object({ a: z.number() }), z.string()]); + const result = standardSchemaToJsonSchema(schema, 'output'); + // not every branch is type:'object' — stamping would be self-contradictory. + expect(result.type).toBeUndefined(); + }); + }); }); diff --git a/packages/core/test/validators/dialect.test.ts b/packages/core/test/validators/dialect.test.ts new file mode 100644 index 0000000000..12c3dc3940 --- /dev/null +++ b/packages/core/test/validators/dialect.test.ts @@ -0,0 +1,103 @@ +/** + * `$schema` dialect handling for the built-in JSON Schema validator providers. + * + * SEP-1613 declares JSON Schema **2020-12** the dialect for tool schemas — the spec's only + * MUST on the matter. The built-in providers therefore validate as 2020-12 only: a schema + * declaring `$schema: …2020-12…` (or no `$schema`) compiles; a schema declaring any other + * `$schema` is rejected with `SchemaCompileError{kind:'unsupported-dialect'}`. The escape hatch + * is the existing custom-engine constructor: a caller-supplied Ajv instance / explicit + * `{draft}` skips the `$schema` check (bring-your-own-validator means bring-your-own-dialect). + * + * Discriminator: `prefixItems` is a 2020-12 keyword. `Ajv2020` enforces it; the draft-07 Ajv + * class (with `strict:false`) silently ignores it. + */ + +import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider.js'; +import { SchemaCompileError } from '../../src/validators/schemaBounds.js'; +import type { JsonSchemaType } from '../../src/validators/types.js'; + +const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#'; +const DRAFT_2019_URI = 'https://json-schema.org/draft/2019-09/schema'; +const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema'; + +/** `prefixItems` discriminator: 2020-12 enforces it. */ +const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({ + ...($schema ? { $schema } : {}), + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] +}); +/** Violates `prefixItems` (positions swapped). */ +const PREFIX_ITEMS_BAD: unknown = ['x', 1]; + +describe('JSON Schema $schema dialect handling (2020-12 only)', () => { + describe('AjvJsonSchemaValidator', () => { + const provider = new AjvJsonSchemaValidator(); + + it('no $schema → 2020-12 (prefixItems enforced)', () => { + const v = provider.getValidator(prefixItemsSchema()); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + }); + + it('$schema: 2020-12 → compiles, prefixItems enforced', () => { + const v = provider.getValidator(prefixItemsSchema(DRAFT_2020_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + expect(v([1, 'x']).valid).toBe(true); + }); + + it.each([ + { label: 'draft-07', uri: DRAFT_07_URI }, + { label: '2019-09', uri: DRAFT_2019_URI } + ])('$schema: $label → SchemaCompileError{unsupported-dialect}', ({ uri }) => { + expect.assertions(2); + try { + provider.getValidator(prefixItemsSchema(uri)); + } catch (e) { + expect(e).toBeInstanceOf(SchemaCompileError); + expect((e as SchemaCompileError).reason).toMatchObject({ kind: 'unsupported-dialect', supported: ['2020-12'] }); + } + }); + + it('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. + const draft07 = new Ajv({ strict: false, validateSchema: false, allErrors: true }); + const custom = new AjvJsonSchemaValidator(draft07); + const v = custom.getValidator(prefixItemsSchema(DRAFT_07_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(true); + }); + }); + + describe('CfWorkerJsonSchemaValidator', () => { + const provider = new CfWorkerJsonSchemaValidator(); + + it('no $schema → 2020-12 (prefixItems enforced)', () => { + const v = provider.getValidator(prefixItemsSchema()); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + }); + + it('$schema: 2020-12 → compiles, prefixItems enforced', () => { + const v = provider.getValidator(prefixItemsSchema(DRAFT_2020_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + expect(v([1, 'x']).valid).toBe(true); + }); + + it.each([ + { label: 'draft-07', uri: DRAFT_07_URI }, + { label: '2019-09', uri: DRAFT_2019_URI } + ])('$schema: $label → SchemaCompileError{unsupported-dialect}', ({ uri }) => { + expect.assertions(2); + try { + provider.getValidator(prefixItemsSchema(uri)); + } catch (e) { + expect(e).toBeInstanceOf(SchemaCompileError); + expect((e as SchemaCompileError).reason).toMatchObject({ kind: 'unsupported-dialect', supported: ['2020-12'] }); + } + }); + + it('explicit {draft} ctor option bypasses the $schema check (caller owns dialect)', () => { + const forced = new CfWorkerJsonSchemaValidator({ draft: '7' }); + expect(() => forced.getValidator(prefixItemsSchema(DRAFT_07_URI))).not.toThrow(); + }); + }); +}); diff --git a/packages/core/test/validators/importGuard.test.ts b/packages/core/test/validators/importGuard.test.ts new file mode 100644 index 0000000000..5d78fd4a01 --- /dev/null +++ b/packages/core/test/validators/importGuard.test.ts @@ -0,0 +1,34 @@ +/** + * SEP-2106 §Security Implications: prove statically that no network path is reachable from schema + * compilation under default config — `validators/**` must not import an HTTP client. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const validatorsDir = path.resolve(here, '../../src/validators'); + +// Scan the WHOLE source body, not just import lines — bare global `fetch(...)` and dynamic +// `import('undici')` need no static-import line on Node ≥18 / workerd. +const FORBIDDEN = /node:https?|['"]https?['"]|undici|node-fetch|cross-fetch|axios|\bfetch\s*\(|\bimport\s*\(/; + +describe('validators/** import guard', () => { + const files = readdirSync(validatorsDir).filter(f => f.endsWith('.ts') && !f.endsWith('.examples.ts')); + + it('finds at least one source file', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files)('%s does not import or call an HTTP client', file => { + const src = readFileSync(path.join(validatorsDir, file), 'utf8'); + // Strip line comments so JSDoc prose mentioning fetch/$ref dereferencing does not trip the + // pattern; block comments are matched line-by-line via the leading `*`. + const code = src + .split('\n') + .filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + expect(code).not.toMatch(FORBIDDEN); + }); +}); diff --git a/packages/core/test/validators/schemaBounds.test.ts b/packages/core/test/validators/schemaBounds.test.ts new file mode 100644 index 0000000000..603b9fe7a0 --- /dev/null +++ b/packages/core/test/validators/schemaBounds.test.ts @@ -0,0 +1,130 @@ +import { + assertSchemaSafeToCompile, + DEFAULT_MAX_SCHEMA_DEPTH, + DEFAULT_MAX_SUBSCHEMA_COUNT, + isSameDocumentRef, + SchemaCompileError +} from '../../src/validators/schemaBounds.js'; + +describe('isSameDocumentRef', () => { + it.each([ + ['#/$defs/X', true], + ['#anchor', true], + ['#', true], + ['', true], + ['https://example.com/schema.json', false], + ['//host/path', false], + ['./relative.json', false], + ['urn:example:schema', false], + ['other.json#/$defs/X', false] + ])('%j → %s', (ref, expected) => { + expect(isSameDocumentRef(ref)).toBe(expected); + }); +}); + +describe('assertSchemaSafeToCompile', () => { + it('passes a vanilla object schema', () => { + expect(() => assertSchemaSafeToCompile({ type: 'object', properties: {} })).not.toThrow(); + }); + + it('passes a same-document $ref into $defs', () => { + expect(() => + assertSchemaSafeToCompile({ + $ref: '#/$defs/a', + $defs: { a: { type: 'string' } } + }) + ).not.toThrow(); + }); + + it('throws ref-denied on a non-local $ref', () => { + let caught: unknown; + try { + assertSchemaSafeToCompile({ $ref: 'https://example.com/x' }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(SchemaCompileError); + expect((caught as SchemaCompileError).reason).toEqual({ kind: 'ref-denied', ref: 'https://example.com/x' }); + }); + + it('throws ref-denied on a non-local $dynamicRef', () => { + let caught: unknown; + try { + assertSchemaSafeToCompile({ $dynamicRef: 'https://example.com/x' }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(SchemaCompileError); + expect((caught as SchemaCompileError).reason.kind).toBe('ref-denied'); + }); + + it('throws ref-denied on a non-local $ref nested under properties', () => { + expect(() => + assertSchemaSafeToCompile({ + type: 'object', + properties: { nested: { $ref: './sibling.json' } } + }) + ).toThrow(SchemaCompileError); + }); + + it('throws bounds-exceeded:maxDepth at depth 65 (default cap 64)', () => { + let node: Record = { type: 'string' }; + for (let i = 0; i < DEFAULT_MAX_SCHEMA_DEPTH + 1; i++) { + node = { not: node }; + } + let caught: unknown; + try { + assertSchemaSafeToCompile(node); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(SchemaCompileError); + expect((caught as SchemaCompileError).reason).toEqual({ kind: 'bounds-exceeded', bound: 'maxDepth' }); + }); + + it('throws bounds-exceeded:maxSubschemas past the default cap', () => { + const anyOf: unknown[] = []; + for (let i = 0; i < DEFAULT_MAX_SUBSCHEMA_COUNT + 1; i++) { + anyOf.push({ const: i }); + } + let caught: unknown; + try { + // maxDepth lifted so the count bound is the one that trips. + assertSchemaSafeToCompile({ anyOf }, { maxDepth: DEFAULT_MAX_SUBSCHEMA_COUNT + 10 }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(SchemaCompileError); + expect((caught as SchemaCompileError).reason).toEqual({ kind: 'bounds-exceeded', bound: 'maxSubschemas' }); + }); + + it('terminates without throwing on a cyclic object graph', () => { + const a: Record = { type: 'object', $defs: {} }; + (a.$defs as Record).x = a; + expect(() => assertSchemaSafeToCompile(a)).not.toThrow(); + }); + + it('returns normally for non-object/null inputs', () => { + expect(() => assertSchemaSafeToCompile(null)).not.toThrow(); + expect(() => assertSchemaSafeToCompile(true)).not.toThrow(); + expect(() => assertSchemaSafeToCompile('not a schema')).not.toThrow(); + }); +}); + +describe('SchemaCompileError', () => { + it('truncates the reflected ref to 200 characters', () => { + const longRef = 'https://attacker.example/'.padEnd(500, 'A'); + const err = new SchemaCompileError({ kind: 'ref-denied', ref: longRef }, 'x'); + expect(err.reason.kind).toBe('ref-denied'); + const ref = (err.reason as { kind: 'ref-denied'; ref: string }).ref; + expect(ref.length).toBeLessThanOrEqual(201); // 200 chars + ellipsis + expect(ref.startsWith('https://attacker.example/')).toBe(true); + }); + + it('truncates the reflected $schema declaration to 200 characters', () => { + const longDecl = 'https://attacker.example/'.padEnd(500, 'A'); + const err = new SchemaCompileError({ kind: 'unsupported-dialect', declared: longDecl, supported: ['2020-12'] }, 'x'); + const declared = (err.reason as { kind: 'unsupported-dialect'; declared: string }).declared; + expect(declared.length).toBeLessThanOrEqual(201); + }); +}); diff --git a/packages/core/test/validators/validators.test.ts b/packages/core/test/validators/validators.test.ts index 6c543cb058..0773b46c7b 100644 --- a/packages/core/test/validators/validators.test.ts +++ b/packages/core/test/validators/validators.test.ts @@ -21,6 +21,11 @@ const validators = [ ]; describe('JSON Schema Validators', () => { + it('AjvJsonSchemaValidator default instance is 2020-12', () => { + // SEP-1613 pin: the SDK-constructed default Ajv is Ajv2020, not draft-07. + expect(new AjvJsonSchemaValidator().defaultDialect).toBe('2020-12'); + }); + describe.each(validators)('$name Validator', ({ provider }) => { describe('String schemas', () => { it('validates basic string', () => { @@ -312,6 +317,10 @@ describe('JSON Schema Validators', () => { }); describe('JSON Schema 2020-12 features', () => { + it('defaults to the 2020-12 dialect (SEP-1613)', () => { + expect(provider.defaultDialect).toBe('2020-12'); + }); + it('validates schema with $schema field', () => { const schema: JsonSchemaType = { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -322,6 +331,16 @@ describe('JSON Schema Validators', () => { expect(validator('test').valid).toBe(true); }); + it('accepts a $schema URI with a trailing fragment hash', () => { + const schema: JsonSchemaType = { + $schema: 'https://json-schema.org/draft/2020-12/schema#', + type: 'string' + }; + const validator = provider.getValidator(schema); + + expect(validator('test').valid).toBe(true); + }); + it('validates schema with $id field', () => { const schema: JsonSchemaType = { $id: 'https://example.com/schemas/test', diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 5eab993c30..e64b05f9af 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -32,6 +32,7 @@ import { assertValidCacheHint, attachCacheHintFallback, isInputRequiredResult, + isModernProtocolVersion, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -49,6 +50,13 @@ import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; +/** + * Tool names whose non-object `outputSchema` has already been warned about. Module-level so the + * warn-once dedup survives across the per-request `createMcpHandler` factory model (which builds a + * fresh `McpServer` per request). + */ +const warnedNonObjectOutput: Set = new Set(); + /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. * For advanced usage (like sending notifications or setting custom request handlers), use the underlying @@ -82,6 +90,26 @@ export class McpServer { */ private _toolInputSchemaJson: { [name: string]: Record } = {}; + /** Whether the negotiated protocol revision is the modern (≥ 2026-07-28) era. */ + private _isModernEra(): boolean { + const v = this.server.getNegotiatedProtocolVersion(); + return v !== undefined && isModernProtocolVersion(v); + } + + private _warnNonObjectOutputSchemaOnce(name: string): void { + // Module-level dedup: under the per-request `createMcpHandler` factory model a fresh + // McpServer is constructed per request, so an instance-level Set would re-warn on every + // request. The dedup key is the tool name — process-global, mirrors `warnedZodFallback`. + if (warnedNonObjectOutput.has(name)) return; + warnedNonObjectOutput.add(name); + console.warn( + `[mcp-sdk] tool '${name}' has a non-object outputSchema (SEP-2106). This is dropped from the ` + + `legacy (≤ 2025-11-25) tools/list projection so 2025 clients can still call the tool; ` + + `the serialized result is carried in a TextContent fallback. Serve a 2026-07-28 client ` + + `to advertise the schema.` + ); + } + /** * The JSON-serialized `inputSchema` of a registered tool, or `undefined` * when no such tool is registered. Used by the HTTP entry's pre-dispatch @@ -193,7 +221,16 @@ export class McpServer { }; if (tool.outputSchema) { - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + const json = standardSchemaToJsonSchema(tool.outputSchema, 'output'); + // SEP-2106 / OQ-3: a non-object outputSchema root is 2026-only + // vocabulary. On the legacy era it is dropped from the projection + // so 2025 clients keep seeing/calling the tool (the TextContent + // fallback carries the serialized result). + if (isNonObjectJsonSchemaRoot(json) && !this._isModernEra()) { + this._warnNonObjectOutputSchemaOnce(name); + } else { + toolDefinition.outputSchema = json; + } } return toolDefinition; @@ -214,7 +251,7 @@ export class McpServer { const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); const result = await this.executeToolHandler(tool, args, ctx); await this.validateToolOutput(tool, result, request.params.name); - return result; + return ensureTextContentForNonObjectStructuredContent(result, this._isModernEra()); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -288,7 +325,11 @@ export class McpServer { return; } - if (!result.structuredContent) { + // SEP-2106: `structuredContent` may legally be any JSON value including `null`, `0`, + // `false`, `""`. The presence check is therefore `=== undefined` (not falsy); when present, + // the value is ALWAYS validated against the output schema — a falsy value against an + // object-typed schema fails validation, so this is not a guard weakening. + if (result.structuredContent === undefined) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` @@ -815,6 +856,21 @@ export class McpServer { } } + // SEP-2106 / OQ-3: warn once at registration time when the outputSchema + // root is non-object, so the developer sees the legacy-projection drop + // during local development (the tools/list path also warns once on the + // first legacy listing). + if (outputSchema !== undefined) { + try { + const json = standardSchemaToJsonSchema(outputSchema, 'output'); + if (isNonObjectJsonSchemaRoot(json)) { + this._warnNonObjectOutputSchemaOnce(name); + } + } catch { + // Conversion failure surfaces at tools/list as it always has. + } + } + // Track current handler for executor regeneration let currentHandler = handler; @@ -1272,6 +1328,44 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; +/** + * True when a converted output JSON Schema's root is not `type: 'object'` (SEP-2106 vocabulary) — + * either an explicit non-object `type` or a typeless root such as `{anyOf:[…]}`. The 2025 wire + * shape requires `type:'object'` at the root of `outputSchema`, so anything else is dropped from + * the legacy projection (and the TextContent fallback carries the serialised result). + * `standardSchemaToJsonSchema(…, 'output')` already stamps `type:'object'` onto provably + * object-shaped typeless roots, so those still pass. + */ +function isNonObjectJsonSchemaRoot(json: Record): boolean { + return json.type !== 'object'; +} + +/** + * SEP-2106 backward-compatibility fallback: when `structuredContent` is a non-object JSON value + * (array, string, number, boolean, `null`) and the handler did not author any `TextContent`, append + * a `{type:'text', text: JSON.stringify(structuredContent)}` block so legacy clients that ignore + * `structuredContent` still receive a rendering. Object-shaped structured content is left to the + * pre-SEP-2106 behavior (no auto-fallback). The author opts out by returning any `text` block. + * + * On the legacy (≤ 2025-11-25) era the non-object `structuredContent` is then dropped from the + * result: the 2025 wire shape types `structuredContent` as a string-keyed record, so an array or + * primitive there is invalid 2025 wire data and a strictly-conformant 2025 client rejects the + * entire response (the `TextContent` fallback included). Stripping it makes the fallback the + * only payload on the legacy path, which is what the SEP's compatibility note intends. + */ +function ensureTextContentForNonObjectStructuredContent( + result: CallToolResult | InputRequiredResult, + isModernEra: boolean +): CallToolResult | InputRequiredResult { + if (isInputRequiredResult(result)) return result; + const sc = result.structuredContent; + if (sc === undefined) return result; + if (typeof sc === 'object' && sc !== null && !Array.isArray(sc)) return result; + const hasTextContent = result.content?.some(c => c.type === 'text') ?? false; + const content = hasTextContent ? result.content : [...(result.content ?? []), { type: 'text' as const, text: JSON.stringify(sc) }]; + return { ...result, content, structuredContent: isModernEra ? result.structuredContent : undefined }; +} + /** * Additional, optional information for annotating a resource. */ diff --git a/packages/server/src/validators/ajv.ts b/packages/server/src/validators/ajv.ts index 31f0fed3b3..b4943d6b76 100644 --- a/packages/server/src/validators/ajv.ts +++ b/packages/server/src/validators/ajv.ts @@ -1,12 +1,18 @@ /** - * Customisation entry point for the AJV validator. Re-exports `Ajv` + `addFormats` from the - * SDK's bundled copy, so customising the validator needs no extra installs. + * Customisation entry point for the AJV validator. Re-exports `Ajv` and `addFormats` from the + * SDK's bundled copy. 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 + * `import { Ajv2020 } from 'ajv/dist/2020.js'`. The re-exported `Ajv` is the **draft-07** class, + * kept for opting back to the pre-SEP-1613 default, and would silently downgrade dialect if used + * for routine customisation. * * @example * ```ts - * import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + * import { Ajv2020 } from 'ajv/dist/2020.js'; + * import { addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; * - * const ajv = new Ajv({ strict: true, allErrors: true }); + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..50069fdd55 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -127,3 +127,25 @@ describe('InferRawShape', () => { expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); }); }); + +describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () => { + it('accepts z.array(z.number()) as outputSchema and a number[] structuredContent compiles', () => { + const server = new McpServer({ name: 's', version: '1' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + server.registerTool('arr', { inputSchema: z.object({ n: z.number() }), outputSchema: z.array(z.number()) }, async ({ n }) => ({ + content: [], + structuredContent: [n, n + 1] satisfies number[] + })); + // NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is + // captured but does NOT currently flow into the callback's return type — ToolCallback's + // SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so + // a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput) + // is the guard. Tightening the generic is out of this commit's scope. + server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({ + content: [], + structuredContent: 'not-an-array' // compiles: structuredContent is `unknown` + })); + expectTypeOf().toMatchTypeOf>>>(); + warn.mockRestore(); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 20b9f30c2b..435a1d0633 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -52,8 +52,6 @@ client: - auth/scope-retry-limit # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised @@ -66,10 +64,7 @@ client: # when PRM authorization_servers changes. - auth/authorization-server-migration -server: +server: [] # --- Carried-forward scenarios (also run by the 2025 legs) --- - # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain - # Zod object with none of the JSON Schema 2020-12 keywords the scenario - # checks; it fails identically at 2025 in `--suite all` (not a 2026-path - # regression). - - json-schema-2020-12 + # (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found + # burned by the spec#2907 error-code renumber + alpha.5 referee.) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index ba9ab0d455..ae5ce4a4c8 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -19,8 +19,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. - auth/iss-supported - auth/iss-not-advertised diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 92e70f9f1a..66b0611371 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -686,6 +686,40 @@ async function runSSERetryClient(serverUrl: string): Promise { registerScenario('sse-retry', runSSERetryClient); +// ============================================================================ +// JSON Schema $ref dereference scenario (SEP-2106) +// ============================================================================ + +/** + * The scenario serves a tool whose outputSchema carries a network `$ref`; the + * conformance check passes when the client lists tools without dereferencing + * (fetching) that URL. The SDK never dereferences network refs — its built-in + * validator providers reject non-same-document `$ref` at compile time, and + * the response cache's `outputValidator()` callback isolates that compile + * error per tool — so a plain + * connect → listTools → close is sufficient: `listTools()` returns normally + * and the canary URL is never fetched. + */ +async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise { + const client = new Client({ name: 'json-schema-ref-no-deref-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 256fb43ff9..7e11a83e67 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -665,22 +665,48 @@ function createMcpServer() { } ); - // SEP-1613: JSON Schema 2020-12 conformance test tool + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. + // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) + // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — + // (SEP-2106) survive tools/list verbatim. The schema is hand-authored JSON + // (via fromJsonSchema) so the keywords are advertised exactly as written; + // a Zod object would not emit them. mcpServer.registerTool( 'json_schema_2020_12_tool', { - description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', - inputSchema: z.object({ - name: z.string().optional(), - address: z - .object({ - street: z.string().optional(), - city: z.string().optional() - }) - .optional() + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)', + inputSchema: fromJsonSchema({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + $defs: { + address: { + $anchor: 'addressDef', + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' }, + contactMethod: { type: 'string', enum: ['phone', 'email'] }, + phone: { type: 'string' }, + email: { type: 'string' } + }, + allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }], + if: { + properties: { contactMethod: { const: 'phone' } }, + required: ['contactMethod'] + }, + // eslint-disable-next-line unicorn/no-thenable -- `then` is a JSON Schema 2020-12 keyword + then: { required: ['phone'] }, + else: { required: ['email'] }, + additionalProperties: false }) }, - async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + async (args): Promise => { return { content: [ { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 02291aa6e7..1d337419b0 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -497,6 +497,94 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'Server-side output schema validation is skipped when the tool returns an isError result.' }, + + // Tools: JSON Schema 2020-12 validator posture (SEP-1613 / SEP-2106) + + 'client:jsonschema:ref-denied': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema carries a non-same-document $ref (absolute URI, scheme-relative path, …) is refused at compile time on the client: callTool throws InvalidParams with data.reason "ref-denied" and the referenced URL is never dereferenced.' + }, + 'client:jsonschema:same-document-ref-ok': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema uses same-document $ref ("#/$defs/…" or "#anchor") compiles on the client and validates structuredContent against the referenced subschema.' + }, + '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 data.reason "unsupported-dialect" instead of having the underlying engine fail opaquely.' + }, + 'client:jsonschema:bad-schema-isolates-tool': { + source: 'sdk', + behavior: + 'One bad outputSchema in a tools/list response (a schema the validator refuses to compile) does not poison the listing: tools/list resolves with every tool present, callTool on the bad tool throws InvalidParams, and callTool on the other tools succeeds.' + }, + 'client:jsonschema:non-object-output': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema has a non-object root (e.g. type:"array") is accepted by the client validator on the 2026-07-28 era: structuredContent matching that root validates and is returned typed unknown.', + note: 'Restricted to the entryModern arm because the 2025-era wire codec keeps outputSchema/structuredContent at their type:"object" / Record shapes (byte-identity), so a non-object root only round-trips on the 2026-07-28 path.' + }, + 'client:jsonschema:2020-12:prefixItems': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'The default client validator enforces JSON Schema 2020-12 vocabulary: a tool whose advertised outputSchema uses prefixItems rejects structuredContent that violates the per-index item schemas (a draft-07 engine with strict:false would silently ignore prefixItems and accept).', + note: 'Restricted to the entryModern arm because the array-typed outputSchema/structuredContent only round-trip on the 2026-07-28 wire codec.' + }, + 'client:jsonschema:dialect:default-is-2020-12': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema declares no $schema is validated by the client with the 2020-12 engine (the default): a 2020-12-only keyword (prefixItems) in the schema is enforced, so structuredContent violating it causes callTool to throw InvalidParams.', + note: 'Restricted to the entryModern arm so the schema (carrying prefixItems) round-trips through the 2026-07-28 wire codec verbatim.' + }, + 'client:jsonschema:falsy-structured-content-validated': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A falsy structuredContent value (0, false, "", null) is treated as present by the client and validated against the cached outputSchema — the presence check is `=== undefined`, not falsy, so a tool returning structuredContent: 0 against outputSchema {type:"integer"} resolves with the value rather than throwing "did not return structured content".', + note: 'Restricted to the entryModern arm because primitive structuredContent only round-trips on the 2026-07-28 wire codec.' + }, + 'server:jsonschema:array-structured-content-textfallback': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns array-typed structuredContent and no text content has a {type:"text", text: JSON.stringify(structuredContent)} block auto-appended (the SEP-2106 backward-compatibility fallback) so legacy-style consumers still receive a rendering.', + note: 'Runs on the entryModern arm so the array structuredContent round-trips through the 2026-07-28 wire codec.' + }, + 'server:jsonschema:primitive-structured-content': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns primitive (string / number / boolean / null) structuredContent round-trips on the 2026-07-28 era: the value reaches the client as typed unknown and the auto TextContent fallback carries its JSON serialisation.', + note: 'Runs on the entryModern arm so a non-object structuredContent round-trips through the 2026-07-28 wire codec.' + }, + '2025:jsonschema:object-root-still-enforced': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era listing, a McpServer tool registered with a non-object-root outputSchema has the outputSchema dropped from its tools/list entry (the SEP-2106 legacy projection): the tool stays listed, the schema is absent so 2025 clients are not handed unparseable vocabulary, and a warn-once console.warn fires naming the tool.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm: a statement about what 2025-era clients see when served by a SEP-2106-aware server.' + }, + '2025:jsonschema:non-object-structured-content-stripped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era tools/call, a McpServer tool whose handler returns non-object structuredContent (array/primitive/null) has the auto-TextContent fallback injected and the structuredContent value omitted from the result: the 2025 wire shape types structuredContent as a string-keyed record, so emitting an array/primitive there is invalid 2025 wire data and a strictly-conformant 2025 client would otherwise reject the entire response (fallback included). The fallback alone is what reaches a legacy client.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The result-side mirror of the legacy outputSchema projection.' + }, + 'mcpserver:tool:duplicate-name': { source: 'sdk', behavior: 'Registering a tool with a name already in use is rejected at registration time.' diff --git a/test/e2e/scenarios/jsonschema.test.ts b/test/e2e/scenarios/jsonschema.test.ts new file mode 100644 index 0000000000..9e98b493a2 --- /dev/null +++ b/test/e2e/scenarios/jsonschema.test.ts @@ -0,0 +1,426 @@ +/** + * Self-contained test bodies for the JSON Schema 2020-12 validator posture + * (SEP-1613 dialect, SEP-2106 `$ref`/bounds + non-object roots). + * + * Each export is a {@link verifies} body: it builds its own server (via a + * factory), builds its own client, wires them with {@link wire}, and asserts. + * There are no shared fixture imports; helpers local to multiple bodies live at + * the top of this file. + * + * The era-spanning bodies use `type:'object'`-rooted output schemas (so the + * 2025-era wire codec — which keeps `outputSchema`/`structuredContent` at their + * object/Record shapes for byte-identity — round-trips them on every arm). The + * non-object-root bodies are restricted to the createMcpHandler entry arms in + * `requirements.ts` because only the 2026-07-28 wire codec carries that + * vocabulary. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { Tool } from '@modelcontextprotocol/server'; +import { fromJsonSchema, McpServer, ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Plain client with no extra capabilities declared. */ +const newClient = () => new Client({ name: 'c', version: '0' }); +// SEP-2106: McpServer's non-object-outputSchema warn-once dedup is module-level (process-global per +// tool name). The 2025 legacy-projection scenario asserts the warn fires; tool names must be unique +// per `verifies()` cell invocation so earlier cells in the same vitest worker do not swallow it. +let nonObjectWarnSeq = 0; + +/** Object-root output schema with a same-document `$ref` into `$defs`. */ +const SAME_DOCUMENT_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: '#/$defs/Point' } }, + required: ['point'], + $defs: { + Point: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' } }, + required: ['x', 'y'] + } + } +}; + +/** Object-root output schema with a non-same-document (network) `$ref`. */ +const NETWORK_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: 'https://schemas.example.invalid/point.json' } }, + required: ['point'] +}; + +/** Object-root output schema declaring a `$schema` dialect URI no built-in provider recognises. */ +const UNKNOWN_DIALECT_OUTPUT = { + $schema: 'https://example.invalid/json-schema/v99/schema', + type: 'object' as const, + properties: { value: { type: 'number' } }, + required: ['value'] +}; + +/** + * Low-level Server factory advertising one tool per fixture output schema. + * The low-level Server applies no server-side output validation, so the + * client-side validator behavior under test is the only check in the path. + */ +function refSchemaServer(): Server { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'network-ref', + inputSchema: { type: 'object' }, + outputSchema: NETWORK_REF_OUTPUT + }, + { + name: 'local-ref', + inputSchema: { type: 'object' }, + outputSchema: SAME_DOCUMENT_REF_OUTPUT + }, + { + name: 'unknown-dialect', + inputSchema: { type: 'object' }, + outputSchema: UNKNOWN_DIALECT_OUTPUT + } + ] + })); + s.setRequestHandler('tools/call', req => { + switch (req.params.name) { + case 'network-ref': + case 'local-ref': { + return { structuredContent: { point: { x: 1, y: 2 } }, content: [] }; + } + case 'unknown-dialect': { + return { structuredContent: { value: 7 }, content: [] }; + } + default: { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `unknown tool ${req.params.name}`); + } + } + }); + return s; +} + +verifies('client:jsonschema:ref-denied', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + // listTools resolves with every advertised tool — the bad schema does not + // poison the listing (compile is per-tool and lazy). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['network-ref', 'local-ref', 'unknown-dialect'])); + + const call = client.callTool({ name: 'network-ref', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.data).toMatchObject({ reason: 'ref-denied' }); +}); + +verifies('client:jsonschema:same-document-ref-ok', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'local-ref'); + expect(tool?.outputSchema).toMatchObject({ $defs: { Point: { type: 'object' } } }); + + const r = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ point: { x: 1, y: 2 } }); +}); + +verifies('client:jsonschema:unsupported-dialect-graceful', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'unknown-dialect')?.outputSchema).toMatchObject({ + $schema: 'https://example.invalid/json-schema/v99/schema' + }); + + const call = client.callTool({ name: 'unknown-dialect', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.data).toMatchObject({ reason: 'unsupported-dialect' }); +}); + +verifies('client:jsonschema:bad-schema-isolates-tool', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + // The listing carries every tool, including the one whose schema the + // validator refuses to compile. + const { tools } = await client.listTools(); + expect(tools.map(t => t.name).toSorted()).toEqual(['local-ref', 'network-ref', 'unknown-dialect']); + + // The good tool is callable and validates. + const ok = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(ok.isError).toBeFalsy(); + expect(ok.structuredContent).toEqual({ point: { x: 1, y: 2 } }); + + // The bad tool surfaces its compile failure lazily, per-tool. + const bad = client.callTool({ name: 'network-ref', arguments: {} }); + await expect(bad).rejects.toBeInstanceOf(ProtocolError); + const err = await bad.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/invalid outputSchema/i); +}); + +verifies('client:jsonschema:non-object-output', async ({ transport }: TestArgs) => { + // Low-level server with a non-object-root output schema. Only meaningful on + // the 2026-07-28 wire codec (entryModern arm), where outputSchema is a + // loose object and structuredContent is `unknown`. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'array-out', + inputSchema: { type: 'object' }, + outputSchema: { type: 'array', items: { type: 'number' } } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: [1, 2, 3], content: [] })); + return s; + }; + const client = newClient(); + // strictValidation:false — the era-neutral wire-sniffer schema keeps + // structuredContent at its 2025 Record shape; the 2026 wire codec is the + // referee here. + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'array-out')?.outputSchema).toMatchObject({ type: 'array' }); + + const r = await client.callTool({ name: 'array-out', arguments: {} }); + expect(r.isError).toBeFalsy(); + // SEP-2106: structuredContent is typed `unknown`; narrow at the call site. + expect(r.structuredContent).toEqual([1, 2, 3]); + expect(Array.isArray(r.structuredContent)).toBe(true); +}); + +verifies('client:jsonschema:2020-12:prefixItems', async ({ transport }: TestArgs) => { + // Low-level server advertising a 2020-12-only `prefixItems` outputSchema and + // returning structuredContent in the WRONG positional order. Ajv2020 + // enforces prefixItems → validation fails; a draft-07 Ajv with strict:false + // would ignore the keyword and accept. This pins the SEP-1613 default. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'tuple-out', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] + } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: ['x', 1], content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + await client.listTools(); + const call = client.callTool({ name: 'tuple-out', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +/** + * Low-level Server advertising a `prefixItems` outputSchema with no `$schema` + * stamp. The handler returns structuredContent that violates `prefixItems` + * (positions swapped). With the 2020-12 default, `prefixItems` is enforced and + * validation fails. + */ +function dialectServer(): Server { + const out: Tool['outputSchema'] = { + type: 'object', + properties: { v: { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }] } }, + required: ['v'] + }; + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'no-stamp', inputSchema: { type: 'object' }, outputSchema: out }] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: { v: ['x', 1] }, content: [] })); + return s; +} + +verifies('client:jsonschema:dialect:default-is-2020-12', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, dialectServer, client); + + await client.listTools(); + // No `$schema` → 2020-12 default → `prefixItems` enforced → {v:['x',1]} invalid. + const call = client.callTool({ name: 'no-stamp', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +verifies('client:jsonschema:falsy-structured-content-validated', async ({ transport }: TestArgs) => { + // Low-level server with `outputSchema:{type:'integer'}` returning + // `structuredContent: 0`. Pins the SEP-2106 §4.3 `=== undefined` presence + // check on the client: a falsy value is treated as PRESENT and validated, + // not as missing. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'zero', + inputSchema: { type: 'object' }, + outputSchema: { type: 'integer' } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: 0, content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + await client.listTools(); + const r = await client.callTool({ name: 'zero', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.structuredContent === 0).toBe(true); +}); + +verifies('server:jsonschema:array-structured-content-textfallback', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'list-numbers', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + s.registerTool( + 'list-authored', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1], content: [{ type: 'text', text: 'mine' }] }) + ); + return s; + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const client = newClient(); + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + const r = await client.callTool({ name: 'list-numbers', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual([1, 2, 3]); + // The auto-TextContent fallback carries the JSON serialisation because + // the handler authored no `type:'text'` block of its own. + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + + // Author opt-out: any author-supplied `type:'text'` block suppresses the + // auto-fallback — exactly the authored block, no JSON-stringify append. + const own = await client.callTool({ name: 'list-authored', arguments: {} }); + expect(own.structuredContent).toEqual([1]); + const textBlocks = (own.content ?? []).filter(c => c.type === 'text'); + expect(textBlocks).toEqual([{ type: 'text', text: 'mine' }]); + } finally { + warn.mockRestore(); + } +}); + +verifies('server:jsonschema:primitive-structured-content', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'count', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'number' }) + }, + () => ({ structuredContent: 0, content: [] }) + ); + return s; + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const client = newClient(); + await using _ = await wire(transport, makeServer, client, { strictValidation: false }); + + const r = await client.callTool({ name: 'count', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.content).toContainEqual({ type: 'text', text: '0' }); + } finally { + warn.mockRestore(); + } +}); + +verifies( + ['2025:jsonschema:object-root-still-enforced', '2025:jsonschema:non-object-structured-content-stripped'], + async ({ transport }: TestArgs) => { + // McpServer with a non-object-root outputSchema, served on the 2025 era + // (entryStateless arm). The legacy projection drops the outputSchema from + // the listing so 2025 clients are not handed vocabulary their codec cannot + // parse; the tool stays listed and remains callable, with the non-object + // structuredContent omitted from the result and the auto-TextContent + // fallback carrying the serialized JSON instead. + // + // Tool name is unique per cell invocation so the module-level warn-once + // dedup (process-global per tool name) does not swallow the warning on + // the second+ cell of the same vitest worker. + const toolName = `list-numbers-${nonObjectWarnSeq++}`; + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + toolName, + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + return s; + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === toolName); + expect(tool).toBeDefined(); + // The non-object outputSchema is dropped from the legacy projection. + expect(tool?.outputSchema).toBeUndefined(); + // The registration-time / first-legacy-listing warn-once fired. + expect(warn.mock.calls.some(c => /non-object outputSchema/.test(String(c[0])))).toBe(true); + + // The tool stays callable on the legacy era: the non-object + // structuredContent is omitted from the result (it is invalid 2025 + // wire data and would otherwise cause a strictly-conformant 2025 + // client to reject the entire response); the auto-TextContent + // fallback carries the serialized JSON. + const r = await client.callTool({ name: toolName, arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBeUndefined(); + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + } finally { + warn.mockRestore(); + } + } +); diff --git a/test/e2e/scenarios/sampling.test.ts b/test/e2e/scenarios/sampling.test.ts index f251a9ef5f..75e4fc52cf 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -202,7 +202,7 @@ verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => { const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } }); expect(r.structuredContent).toMatchObject({ ok: false, code: -1 }); - expect(r.structuredContent?.message).toMatch(/User rejected sampling request/); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/User rejected sampling request/); }); verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => { @@ -340,7 +340,7 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs }); expect(r.structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams }); - expect(r.structuredContent?.message).toMatch(/tool.?result/i); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/tool.?result/i); }); verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => { @@ -425,7 +425,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withTools.structuredContent).toMatchObject({ ok: false }); - expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withTools.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const withChoice = await client.callTool({ @@ -434,7 +434,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withChoice.structuredContent).toMatchObject({ ok: false }); - expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withChoice.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const empty = await client.callTool({ @@ -443,7 +443,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(empty.structuredContent).toMatchObject({ ok: false }); - expect(empty.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((empty.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); });