diff --git a/.changeset/sep-2106-json-schema-2020-12.md b/.changeset/sep-2106-json-schema-2020-12.md new file mode 100644 index 0000000000..8ddd08df9c --- /dev/null +++ b/.changeset/sep-2106-json-schema-2020-12.md @@ -0,0 +1,23 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Implement SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 2020-12, and `structuredContent` may be any JSON value. + +- `inputSchema` still requires `type: "object"` at the root but now accepts any JSON Schema 2020-12 keyword (`oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`$anchor`, …). +- `outputSchema` may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions — instead of being restricted to `type: "object"`. +- `CallToolResult.structuredContent` widens from `{ [key: string]: unknown }` to `unknown`. **This is a source-breaking type change** for typed consumers: property access now requires a runtime narrowing guard before reading object properties. +- New `CallToolResultWithStructuredContent` type for APIs whose output shape is known ahead of time (for example, server-side handlers typed from `outputSchema`). +- `McpServer.registerTool` type-checks a handler's returned `structuredContent` against the tool's `outputSchema` inferred output. +- Servers returning array or primitive `structuredContent` automatically also emit a serialized `TextContent` block, so pre-SEP clients can fall back to the text content. +- Built-in validators refuse to dereference non-same-document `$ref`/`$dynamicRef` (SSRF guard) and reject schemas exceeding depth / subschema-count bounds (composition-DoS guard). +- `Client.listTools()` no longer rejects when a single advertised tool's `outputSchema` fails to compile (e.g. it trips the safety guards above): the failure is scoped to the offending tool. Every other tool stays listable and callable; calling the offending tool throws a + descriptive error instead of silently skipping output validation. +- The default Node validator now uses `Ajv2020`, so the 2020-12 dialect is honored by default (previously `new Ajv()` ran draft-07 semantics and silently ignored keywords such as `prefixItems`). Both built-in validators now default to the `2020-12` dialect + (`MCP_DEFAULT_SCHEMA_DIALECT`). +- For compatibility with existing draft-07 tuple schemas, built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should prefer + `prefixItems` directly. +- New opt-in `resolveExternalSchemaRefs(schema, options)` helper (the SEP's optional external-`$ref` mode): fetches and inlines non-local `$ref`s ahead of time into a self-contained schema. Disabled by default, enforces a host allowlist (and rejects loopback/link-local/private + targets otherwise), `https`-only by default, with fetch timeout / response-size / document-count limits, dereference logging, and fail-closed on unresolved references. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..59f913a748 100644 --- a/docs/client.md +++ b/docs/client.md @@ -239,7 +239,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 for programmatic use by the client application, complementing `content` which is for the LLM. Since it can be an object, array, primitive, or null, narrow it at runtime before reading object properties: ```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ @@ -248,8 +248,12 @@ const result = await client.callTool({ }); // Machine-readable output for the client application -if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } +const structuredContent = result.structuredContent; +if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) { + const bmi = (structuredContent as Record).bmi; + if (typeof bmi === 'number') { + console.log(bmi); // e.g. 22.86 + } } ``` diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 73a2e4a0b9..5a318c811f 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -550,11 +550,51 @@ Validator behavior: - Do not add validator imports for normal migrations. - Do not install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path; client/server bundle the runtime-selected defaults and the root entry point does not pull either dep in. -- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`, +- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import `Ajv2020`, `addFormats`, and `AjvJsonSchemaValidator` from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv`, `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. +- Use `Ajv2020` for AJV customization. A plain `Ajv` instance uses draft-07 semantics and does not match MCP's JSON Schema 2020-12 default. - To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. -## 15. Migration Steps (apply in this order) +## 15. JSON Schema 2020-12 Tool Schemas & `structuredContent` (SEP-2106) + +Tool schemas conform to full JSON Schema 2020-12, and `structuredContent` may be any JSON value. + +| Aspect | v1 / pre-SEP | v2 / SEP-2106 | +| ---------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `inputSchema` root | `type: "object"` + `properties`/`required` only | `type: "object"` required, **plus** any 2020-12 keyword (`oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`$anchor`) | +| `outputSchema` root | `type: "object"` only | **any** valid JSON Schema 2020-12 (object, array, primitive, composition) | +| `Tool.inputSchema` / `Tool.outputSchema` types | object schema with typed `properties`/`required` members | broad JSON Schema records; narrow keyword field values before using them (**source-breaking**) | +| `CallToolResult.structuredContent` type | `{ [key: string]: unknown }` | `unknown` (**source-breaking**) | +| `client.callTool(...)` | returns `structuredContent` as object | returns `structuredContent` as `unknown`; narrow it before property access | +| `registerTool` handler return | `structuredContent` untyped | type-checked against the tool's `outputSchema` inferred output | + +Source-breaking fix — property access on `structuredContent` needs a type or a guard: + +```typescript +// Before: result.structuredContent?.temperature (compiled, but unsound for non-object output) +// After: +const result = await client.callTool({ name: 'get_weather', arguments: { city: 'SF' } }); +const sc = result.structuredContent; +const temp = typeof sc === 'object' && sc !== null && !Array.isArray(sc) ? (sc as Record).temperature : undefined; +``` + +Source-breaking fix — property access on `Tool.inputSchema` / `Tool.outputSchema` keyword field values also needs narrowing: + +```typescript +const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : []; +const properties = tool.inputSchema.properties; +if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) { + const propertyNames = Object.keys(properties); +} +``` + +Behavior notes: + +- A server returning array/primitive `structuredContent` automatically also emits a serialized `TextContent` block (old-client interop). No action required. +- Built-in validators reject non-same-document `$ref`/`$dynamicRef` (SSRF) and over-budget schemas (composition DoS). Use `resolveExternalSchemaRefs(schema, { allowlist })` to fetch and inline approved external refs before validation, or use a custom `jsonSchemaValidator` to + change validator behavior. + +## 16. Migration Steps (apply in this order) 1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages 2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport` @@ -566,6 +606,7 @@ Validator behavior: 8. If using server SSE transport, migrate to Streamable HTTP 9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to external IdP/OAuth library 10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true` -11. Format the changed files with the project's formatter (`prettier --write`, `eslint --fix`, or `biome format --write`) — edits are not reformatted automatically, and the wrapped schemas (step 5) and rewritten `setRequestHandler` method strings (section 9) frequently need it to +11. If you read properties off `Tool.inputSchema`, `Tool.outputSchema`, or `result.structuredContent`, add narrowing guards (section 15) +12. Format the changed files with the project's formatter (`prettier --write`, `eslint --fix`, or `biome format --write`) — edits are not reformatted automatically, and the wrapped schemas (step 5) and rewritten `setRequestHandler` method strings (section 9) frequently need it to satisfy lint -12. Verify: build with `tsc` / run tests +13. Verify: build with `tsc` / run tests diff --git a/docs/migration.md b/docs/migration.md index 1b6062225c..062e63ce28 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -978,11 +978,9 @@ You do not need to install or import validator packages for the default behavior If you want to customize the **built-in** backend (for example, pre-register schemas by `$id`, register custom AJV formats, or change the `@cfworker/json-schema` draft), import the named class from the explicit subpath and pass an instance through `jsonSchemaValidator`: ```typescript -import { Ajv } from 'ajv'; -import addFormats from 'ajv-formats'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; +import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; -const ajv = new Ajv({ strict: true, allErrors: true }); +const ajv = new Ajv2020({ strict: true, allErrors: true }); addFormats(ajv); const server = new McpServer( @@ -1009,10 +1007,53 @@ const server = new McpServer( (both subpaths are also available on `@modelcontextprotocol/client/validators/...`) If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the -subpath in some files and rely on the default in others. +subpath in some files and rely on the default in others. For AJV customization, use the re-exported `Ajv2020` class; a plain `Ajv` instance uses draft-07 semantics and will not validate JSON Schema 2020-12 keywords such as `prefixItems` the same way as MCP's default validator. + +For compatibility with existing draft-07 tuple schemas, the built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should use +`prefixItems` directly. To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. +### Tool schemas conform to JSON Schema 2020-12; `structuredContent` may be any JSON value (SEP-2106) + +Per [SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2106-json-schema-2020-12.md), tool schemas are no longer restricted to the `type`/`properties`/`required` subset, and a tool's structured output may be any JSON value: + +- **`inputSchema`** still requires `type: "object"` at the root (tool arguments are always objects), but may now use any JSON Schema 2020-12 keyword alongside it — composition (`oneOf`/`anyOf`/`allOf`/`not`), conditional (`if`/`then`/`else`), references + (`$ref`/`$defs`/`$anchor`), etc. +- **`outputSchema`** may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions. It is no longer restricted to `type: "object"`. +- **`structuredContent`** may now be any JSON value (object, array, string, number, boolean, or null), not just an object. + +**Source-breaking type change.** `CallToolResult.structuredContent` widened from `{ [key: string]: unknown }` to `unknown`. Property access without a narrowing guard no longer type-checks (the previous type was inaccurate whenever a tool returned a non-object): + +```typescript +// Before (v1): compiled, but was a lie for non-object output +const temp = result.structuredContent?.temperature; + +// After (v2): narrow before property access +const sc = result.structuredContent; +if (typeof sc === 'object' && sc !== null && !Array.isArray(sc)) { + const temp = (sc as Record).temperature; +} +``` + +The generated `Tool.inputSchema` and `Tool.outputSchema` types also widened to reflect full JSON Schema 2020-12. Keyword fields such as `properties`, `required`, and analogous `outputSchema` fields now have broad JSON values. Narrow the keyword field value before using it: + +```typescript +const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : []; +const properties = tool.inputSchema.properties; +if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) { + const propertyNames = Object.keys(properties); +} +``` + +**Stronger server-side typing.** When a tool declares an `outputSchema`, `registerTool` now type-checks the handler's returned `structuredContent` against the schema's inferred output type at compile time — a mismatch is a type error rather than a runtime-only failure. + +**Old-client interoperability.** A server that returns array or primitive `structuredContent` will automatically also emit a `TextContent` block containing the serialized JSON, so pre-SEP clients that only understand object-typed `structuredContent` can fall back to the text +content. Object `structuredContent` (and results that already include a text block) are left unchanged. + +**Security.** The built-in validators never dereference non-same-document `$ref`/`$dynamicRef` (anything not beginning with `#`) — such schemas are rejected rather than fetched, preventing SSRF. If you intentionally need external references, resolve and inline them before +validation with `resolveExternalSchemaRefs(schema, { allowlist })`. Schemas exceeding a generous depth / subschema-count bound are also rejected to prevent composition-based validation DoS. Supply your own `jsonSchemaValidator` implementation if you need different behavior. + ## Unchanged APIs The following APIs are unchanged between v1 and v2 (only the import paths changed): diff --git a/docs/server.md b/docs/server.md index 468bf0cb2a..9e1c592931 100644 --- a/docs/server.md +++ b/docs/server.md @@ -104,16 +104,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/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 99a8383bc8..616eec8df6 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -212,8 +212,12 @@ async function callTool_structuredOutput(client: Client) { }); // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + const structuredContent = result.structuredContent; + if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) { + const bmi = (structuredContent as Record).bmi; + if (typeof bmi === 'number') { + console.log(bmi); // e.g. 22.86 + } } //#endregion callTool_structuredOutput } diff --git a/examples/server/src/mcpServerOutputSchema.ts b/examples/server/src/mcpServerOutputSchema.ts index 955855c419..27a7ad8f18 100644 --- a/examples/server/src/mcpServerOutputSchema.ts +++ b/examples/server/src/mcpServerOutputSchema.ts @@ -39,9 +39,11 @@ server.registerTool( // Parameters are available but not used in this example void city; void country; - // Simulate weather API call + // Simulate weather API call. The option arrays are typed so that the values flowing into + // `structuredContent` are checked against `outputSchema` at compile time (per SEP-2106). const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; + const conditionOptions: Array<'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'> = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']; + const conditions = conditionOptions[Math.floor(Math.random() * conditionOptions.length)] ?? 'sunny'; const structuredContent = { temperature: { @@ -52,7 +54,7 @@ server.registerTool( humidity: Math.round(Math.random() * 100), wind: { speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] + direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] ?? 'N' } }; diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index a02f7dcb3d..ec01eb5a78 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -108,8 +108,12 @@ async function Client_callTool_structuredOutput(client: Client) { }); // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + const structuredContent = result.structuredContent; + if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) { + const bmi = (structuredContent as Record).bmi; + if (typeof bmi === 'number') { + console.log(bmi); + } } //#endregion Client_callTool_structuredOutput } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8277225d79..66fdae1ef2 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,6 +2,7 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, @@ -23,9 +24,11 @@ import type { MessageExtraInfo, NotificationMethod, ProtocolOptions, + Prompt, ReadResourceRequest, RequestMethod, RequestOptions, + Resource, Result, ServerCapabilities, SubscribeRequest, @@ -221,6 +224,13 @@ export class Client extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); + /** + * Tools whose advertised `outputSchema` could not be compiled into a validator (e.g. it tripped + * the SEP-2106 safety guards — a non-local `$ref` or an over-budget schema). The error is stored + * per-tool and surfaced only when that tool is called, so one malformed tool definition does not + * break `listTools()` or the use of every other tool from the same server. + */ + private _toolOutputValidatorErrors: Map = new Map(); private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; @@ -256,26 +266,62 @@ export class Client extends Protocol { private _setupListChangedHandlers(config: ListChangedHandlers): void { if (config.tools && this._serverCapabilities?.tools?.listChanged) { this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { - const result = await this.listTools(); - return result.tools; + return this._listAllTools(); }); } if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { this._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { - const result = await this.listPrompts(); - return result.prompts; + return this._listAllPrompts(); }); } if (config.resources && this._serverCapabilities?.resources?.listChanged) { this._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { - const result = await this.listResources(); - return result.resources; + return this._listAllResources(); }); } } + private async _listAllTools(): Promise { + const tools: Tool[] = []; + let cursor: string | undefined; + + do { + const result = await this.listTools(cursor === undefined ? undefined : { cursor }); + tools.push(...result.tools); + cursor = result.nextCursor; + } while (cursor !== undefined); + + return tools; + } + + private async _listAllPrompts(): Promise { + const prompts: Prompt[] = []; + let cursor: string | undefined; + + do { + const result = await this.listPrompts(cursor === undefined ? undefined : { cursor }); + prompts.push(...result.prompts); + cursor = result.nextCursor; + } while (cursor !== undefined); + + return prompts; + } + + private async _listAllResources(): Promise { + const resources: Resource[] = []; + let cursor: string | undefined; + + do { + const result = await this.listResources(cursor === undefined ? undefined : { cursor }); + resources.push(...result.resources); + cursor = result.nextCursor; + } while (cursor !== undefined); + + return resources; + } + /** * Registers new capabilities. This can only be called before connecting to a transport. * @@ -785,6 +831,9 @@ export class Client extends Protocol { * console.log(result.content); * ``` * + * Per SEP-2106 `structuredContent` may be any JSON value (object, array, string, number, + * boolean, or null). Narrow it at runtime before reading object properties: + * * @example Structured output * ```ts source="./client.examples.ts#Client_callTool_structuredOutput" * const result = await client.callTool({ @@ -793,19 +842,35 @@ export class Client extends Protocol { * }); * * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * const structuredContent = result.structuredContent; + * if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) { + * const bmi = (structuredContent as Record).bmi; + * if (typeof bmi === 'number') { + * console.log(bmi); + * } * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { + // If the tool advertised an outputSchema that failed to compile (e.g. a SEP-2106 safety-guard + // rejection), surface that error before executing a potentially side-effecting tool. + const validatorError = this._toolOutputValidatorErrors.get(params.name); + if (validatorError) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Tool ${params.name} has an output schema that could not be compiled: ${validatorError.message}` + ); + } + const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); 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). + // Per SEP-2106 structuredContent may be a falsy JSON value (0, false, "", null), so + // check explicitly for `undefined` rather than truthiness. + if (result.structuredContent === undefined && !result.isError) { throw new ProtocolError( ProtocolErrorCode.InvalidRequest, `Tool ${params.name} has an output schema but did not return structured content` @@ -813,7 +878,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) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); @@ -843,14 +908,26 @@ export class Client extends Protocol { * Cache validators for tool output schemas. * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); + private cacheToolMetadata(tools: Tool[], reset: boolean): void { + if (reset) { + this._cachedToolOutputValidators.clear(); + this._toolOutputValidatorErrors.clear(); + } for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator + this._cachedToolOutputValidators.delete(tool.name); + this._toolOutputValidatorErrors.delete(tool.name); + + // If the tool has an outputSchema, create and cache the validator. Compilation can throw + // (invalid schema, or a SEP-2106 safety-guard rejection); scope that failure to the + // offending tool rather than letting it reject the whole listTools() call. if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); + try { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + this._cachedToolOutputValidators.set(tool.name, toolValidator); + } catch (error) { + this._toolOutputValidatorErrors.set(tool.name, error instanceof Error ? error : new Error(String(error))); + } } } } @@ -892,8 +969,9 @@ export class Client extends Protocol { } const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); + // Cache the tools and their output schemas for future validation. Preserve entries across + // pagination so validators discovered on earlier pages remain active after the final page. + this.cacheToolMetadata(result.tools, params?.cursor === undefined); return result; } diff --git a/packages/client/src/validators/ajv.ts b/packages/client/src/validators/ajv.ts index 059c6b73f2..3dedb155ce 100644 --- a/packages/client/src/validators/ajv.ts +++ b/packages/client/src/validators/ajv.ts @@ -1,14 +1,14 @@ /** - * 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 `Ajv2020` + `addFormats` from + * the SDK's bundled copy, so customising the validator needs no extra installs. * * @example * ```ts - * import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; + * import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; * - * const ajv = new Ajv({ strict: true, allErrors: true }); + * const ajv = new Ajv2020({ strict: true, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ -export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +export { addFormats, Ajv, Ajv2020, AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 82c13a7021..727f9404b7 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -1,5 +1,5 @@ import type { JSONRPCMessage, JsonSchemaType, JsonSchemaValidatorResult, jsonSchemaValidator } from '@modelcontextprotocol/core-internal'; -import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION, ProtocolErrorCode } from '@modelcontextprotocol/core-internal'; import { Client } from '../../src/client/client'; import { fromJsonSchema } from '../../src/fromJsonSchema'; @@ -16,7 +16,13 @@ class RecordingValidator implements jsonSchemaValidator { } } -async function connectInitializedClient(client: Client) { +class ThrowingValidator implements jsonSchemaValidator { + getValidator(_schema: JsonSchemaType): (value: unknown) => JsonSchemaValidatorResult { + throw new Error('schema compile blocked'); + } +} + +async function connectInitializedClient(client: Client, handlers?: { onToolsCall?: (message: JSONRPCMessage) => void }) { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); serverTransport.onmessage = async message => { if ('method' in message && 'id' in message && message.method === 'initialize') { @@ -48,6 +54,16 @@ async function connectInitializedClient(client: Client) { ] } } satisfies JSONRPCMessage); + } else if ('method' in message && 'id' in message && message.method === 'tools/call') { + handlers?.onToolsCall?.(message); + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + structuredContent: { count: 1 }, + content: [{ type: 'text', text: 'ok' }] + } + } satisfies JSONRPCMessage); } }; @@ -93,6 +109,37 @@ describe('client JSON Schema validator overrides', () => { await serverTransport.close(); }); + test('callTool rejects cached output schema compile errors before sending tools/call', async () => { + const validator = new ThrowingValidator(); + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: validator + } + ); + let sawToolCall = false; + const { clientTransport, serverTransport } = await connectInitializedClient(client, { + onToolsCall: () => { + sawToolCall = true; + } + }); + + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ name: 'structured-tool' }] + }); + + await expect(client.callTool({ name: 'structured-tool', arguments: {} })).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('output schema that could not be compiled') + }); + expect(sawToolCall).toBe(false); + + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a87..196a367508 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 88d4942a74..8d087a01db 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -121,6 +121,9 @@ export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } f // `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. export type { AjvJsonSchemaValidator } from '../../validators/ajvProvider'; export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider'; +export type { ResolveExternalRefsOptions } from '../../validators/externalRefResolver'; +export { resolveExternalSchemaRefs } from '../../validators/externalRefResolver'; // 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 { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types'; +export { MCP_DEFAULT_SCHEMA_DIALECT } from '../../validators/types'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index fb89b0383d..cefc7aa65c 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -18,5 +18,6 @@ export * from './util/zodCompat'; // `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. export type { AjvJsonSchemaValidator } from './validators/ajvProvider'; export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from './validators/cfWorkerProvider'; +export * from './validators/externalRefResolver'; export * from './validators/fromJsonSchema'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types'; diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index 3c942256fd..069a98e136 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -1372,25 +1372,27 @@ export const ToolSchema = z.object({ description: z.string().optional(), /** * A JSON Schema 2020-12 object defining the expected parameters for the tool. - * Must have `type: 'object'` at the root level per MCP spec. + * + * Tool arguments are always JSON objects, so `type: 'object'` is required at the root. + * Beyond that, any JSON Schema 2020-12 keyword may appear — composition (`oneOf`/`anyOf`/ + * `allOf`/`not`), conditional (`if`/`then`/`else`), reference (`$ref`/`$defs`/`$anchor`), etc. */ inputSchema: z .object({ - type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() + $schema: z.string().optional(), + type: z.literal('object') }) .catchall(z.unknown()), /** * An optional JSON Schema 2020-12 object defining the structure of the tool's output * returned in the `structuredContent` field of a `CallToolResult`. - * Must have `type: 'object'` at the root level per MCP spec. + * + * Per SEP-2106 this may be any valid JSON Schema 2020-12 — objects, arrays, primitives, + * or compositions. It is no longer restricted to `type: 'object'` at the root. */ outputSchema: z .object({ - type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() + $schema: z.string().optional() }) .catchall(z.unknown()) .optional(), @@ -1437,11 +1439,15 @@ export const CallToolResultSchema = ResultSchema.extend({ content: z.array(ContentBlockSchema).default([]), /** - * An object containing structured tool output. + * A JSON value containing structured tool output. + * + * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON value that matches the schema. * - * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + * Per SEP-2106 this may be any JSON value (object, array, string, number, boolean, or null), + * not just an object. Servers returning a non-object value SHOULD also emit a `TextContent` + * block with the serialized JSON so pre-SEP clients can fall back to the text content. */ - structuredContent: z.record(z.string(), z.unknown()).optional(), + structuredContent: z.unknown().optional(), /** * Whether the tool call ended in an error. @@ -1668,7 +1674,7 @@ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), content: z.array(ContentBlockSchema).default([]), - structuredContent: z.object({}).loose().optional(), + structuredContent: z.unknown().optional(), isError: z.boolean().optional(), /** diff --git a/packages/core-internal/src/types/types.ts b/packages/core-internal/src/types/types.ts index 9abc68f79e..ff42dd0c2e 100644 --- a/packages/core-internal/src/types/types.ts +++ b/packages/core-internal/src/types/types.ts @@ -331,6 +331,19 @@ export type ListToolsRequest = Infer; export type ListToolsResult = Infer; export type CallToolRequestParams = Infer; export type CallToolResult = Infer; +/** + * A {@link CallToolResult} whose `structuredContent` is narrowed to a specific type. + * + * Per SEP-2106 `structuredContent` may be any JSON value (object, array, string, number, + * boolean, or null), so the wire-level type is intentionally wide. This helper produces a + * precise view of a result for APIs whose output shape is known ahead of time, such as + * tool handlers typed from a registered `outputSchema`. + * + * @typeParam StructuredContent - the expected type of `structuredContent` (defaults to any JSON value). + */ +export type CallToolResultWithStructuredContent = CallToolResult & { + structuredContent?: StructuredContent; +}; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index b938885de0..7b0bd87570 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -167,12 +167,14 @@ let warnedZodFallback = false; /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * - * MCP requires `type: "object"` at the root of tool inputSchema/outputSchema and - * prompt argument schemas. Zod's discriminated unions emit `{oneOf: [...]}` without - * a top-level `type`, so this function defaults `type` to `"object"` when absent. + * For `io: 'input'` (tool inputSchema and prompt argument schemas), MCP requires `type: "object"` + * at the root: tool arguments are always a JSON object. Zod's discriminated unions emit + * `{oneOf: [...]}` without a top-level `type`, so this function defaults `type` to `"object"` when + * absent, and throws if the schema has an explicit non-object `type` (e.g. `z.string()`). * - * Throws if the schema has an explicit non-object `type` (e.g. `z.string()`), - * since that cannot satisfy the MCP spec. + * For `io: 'output'` (tool outputSchema), per SEP-2106 the schema may be any valid JSON Schema + * 2020-12 — objects, arrays, primitives, or compositions — so the converted schema is returned + * unchanged with no root-`type` constraint. */ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { const std = schema['~standard']; @@ -204,13 +206,21 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } - if (result.type !== undefined && result.type !== 'object') { - throw new Error( - `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + - `Wrap your schema in z.object({...}) or equivalent.` - ); + if (io === 'input') { + // MCP requires tool inputSchema (and prompt argument schemas) to describe an object: tool + // arguments are always passed as a JSON object. + if (result.type !== undefined && result.type !== 'object') { + throw new Error( + `MCP tool input and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } + return { type: 'object', ...result }; } - return { type: 'object', ...result }; + // Per SEP-2106, a tool's outputSchema may be any valid JSON Schema 2020-12 — objects, arrays, + // primitives, or compositions. Return the converted schema unchanged; do not force a root + // `type: 'object'`. + return result; } // Validation diff --git a/packages/core-internal/src/validators/ajvProvider.examples.ts b/packages/core-internal/src/validators/ajvProvider.examples.ts index 923d5a68ba..91e402a8d9 100644 --- a/packages/core-internal/src/validators/ajvProvider.examples.ts +++ b/packages/core-internal/src/validators/ajvProvider.examples.ts @@ -7,7 +7,7 @@ * @module */ -import { addFormats, Ajv, AjvJsonSchemaValidator } from './ajvProvider'; +import { addFormats, Ajv2020, AjvJsonSchemaValidator } from './ajvProvider'; /** * Example: Default AJV instance. @@ -24,7 +24,7 @@ function AjvJsonSchemaValidator_default() { */ function AjvJsonSchemaValidator_customInstance() { //#region AjvJsonSchemaValidator_customInstance - const ajv = new Ajv({ strict: true, allErrors: true }); + const ajv = new Ajv2020({ strict: true, allErrors: true }); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_customInstance return validator; @@ -33,12 +33,12 @@ function AjvJsonSchemaValidator_customInstance() { /** * Example: Custom AJV instance with formats registered. * - * `Ajv` and `addFormats` are re-exported from this module so customising the validator + * `Ajv2020` 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. */ function AjvJsonSchemaValidator_withFormats() { //#region AjvJsonSchemaValidator_withFormats - const ajv = new Ajv({ strict: true, allErrors: true }); + const ajv = new Ajv2020({ strict: true, allErrors: true }); addFormats(ajv); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_withFormats diff --git a/packages/core-internal/src/validators/ajvProvider.ts b/packages/core-internal/src/validators/ajvProvider.ts index 23d64dac7c..173aadd066 100644 --- a/packages/core-internal/src/validators/ajvProvider.ts +++ b/packages/core-internal/src/validators/ajvProvider.ts @@ -2,9 +2,12 @@ * AJV-based JSON Schema validator provider */ -import { Ajv } from 'ajv'; +import type { Ajv } from 'ajv'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import _addFormats from 'ajv-formats'; +import { assertSchemaSafeToCompile } from './schemaBounds'; +import { normalizeLegacyTupleSchema } from './schemaCompatibility'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; /** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */ @@ -22,7 +25,13 @@ interface AjvValidateFunction { } function createDefaultAjvInstance(): Ajv { - const ajv = new Ajv({ + // SEP-2106: MCP tool schemas default to the JSON Schema 2020-12 dialect when no `$schema` is + // declared. Plain `Ajv` is draft-07 and *silently ignores* 2020-12 keywords such as + // `prefixItems` (e.g. it would accept `[1, "a"]` for a `[string, number]` tuple), which would + // make validation disagree with the declared schema. `Ajv2020` runs the 2020-12 meta-schema and + // vocabulary, matching the cfworker default (`draft: '2020-12'`) used in the browser/workerd + // builds. + const ajv = new Ajv2020({ strict: false, validateFormats: true, validateSchema: false, @@ -37,7 +46,7 @@ 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). + * for the customisation entry point (re-exports `Ajv2020`, `Ajv`, and `addFormats` from the bundled copy). * * @example Use with default configuration * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" @@ -46,19 +55,20 @@ 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 }); + * const ajv = new Ajv2020({ strict: true, 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 }); + * const ajv = new Ajv2020({ strict: true, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ export class AjvJsonSchemaValidator implements jsonSchemaValidator { private _ajv: AjvLike; + private _normalizeLegacyTuples: boolean; /** * @param ajv - Optional pre-configured AJV-compatible instance. If omitted, a default instance is @@ -68,13 +78,18 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { */ constructor(ajv?: AjvLike) { this._ajv = ajv ?? createDefaultAjvInstance(); + this._normalizeLegacyTuples = ajv === undefined || ajv instanceof Ajv2020; } getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling. + assertSchemaSafeToCompile(schema); + const normalizedSchema = this._normalizeLegacyTuples ? normalizeLegacyTupleSchema(schema) : schema; + const ajvValidator = - '$id' in schema && typeof schema.$id === 'string' - ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) - : this._ajv.compile(schema); + '$id' in normalizedSchema && typeof normalizedSchema.$id === 'string' + ? (this._ajv.getSchema(normalizedSchema.$id) ?? this._ajv.compile(normalizedSchema)) + : this._ajv.compile(normalizedSchema); return (input: unknown): JsonSchemaValidatorResult => { const valid = ajvValidator(input); @@ -95,5 +110,6 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { } export { Ajv } from 'ajv'; +export { Ajv2020 } from 'ajv/dist/2020.js'; /** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */ export const addFormats = _addFormats as unknown as typeof _addFormats.default; diff --git a/packages/core-internal/src/validators/cfWorkerProvider.ts b/packages/core-internal/src/validators/cfWorkerProvider.ts index c3cfb34481..e82a416b5b 100644 --- a/packages/core-internal/src/validators/cfWorkerProvider.ts +++ b/packages/core-internal/src/validators/cfWorkerProvider.ts @@ -10,7 +10,10 @@ import { Validator } from '@cfworker/json-schema'; +import { assertSchemaSafeToCompile } from './schemaBounds'; +import { normalizeLegacyTupleSchema } from './schemaCompatibility'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; +import { MCP_DEFAULT_SCHEMA_DIALECT } from './types'; /** * JSON Schema draft version supported by `@cfworker/json-schema`. @@ -47,7 +50,8 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { */ constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { this.shortcircuit = options?.shortcircuit ?? true; - this.draft = options?.draft ?? '2020-12'; + // SEP-2106: default to the MCP-wide dialect (2020-12) when the caller does not pin one. + this.draft = options?.draft ?? MCP_DEFAULT_SCHEMA_DIALECT; } /** @@ -59,8 +63,12 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling. + assertSchemaSafeToCompile(schema); + const normalizedSchema = this.draft === MCP_DEFAULT_SCHEMA_DIALECT ? normalizeLegacyTupleSchema(schema) : schema; + // 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(normalizedSchema as ConstructorParameters[0], this.draft, this.shortcircuit); return (input: unknown): JsonSchemaValidatorResult => { const result = validator.validate(input); diff --git a/packages/core-internal/src/validators/externalRefResolver.examples.ts b/packages/core-internal/src/validators/externalRefResolver.examples.ts new file mode 100644 index 0000000000..91af6d8801 --- /dev/null +++ b/packages/core-internal/src/validators/externalRefResolver.examples.ts @@ -0,0 +1,27 @@ +/** + * Type-checked examples for `externalRefResolver.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * + * @module + */ + +import { resolveExternalSchemaRefs } from './externalRefResolver'; +import type { JsonSchemaType } from './types'; + +declare const toolOutputSchema: JsonSchemaType; + +/** + * Example: opt in to resolving external `$ref`s ahead of time. + */ +async function resolveExternalSchemaRefs_basic() { + //#region resolveExternalSchemaRefs_basic + const resolved = await resolveExternalSchemaRefs(toolOutputSchema, { + allowlist: ['schemas.example.com'] + }); + // `resolved` has no external $refs; hand it to registerTool / fromJsonSchema as usual. + //#endregion resolveExternalSchemaRefs_basic + return resolved; +} + +export { resolveExternalSchemaRefs_basic }; diff --git a/packages/core-internal/src/validators/externalRefResolver.ts b/packages/core-internal/src/validators/externalRefResolver.ts new file mode 100644 index 0000000000..0cb257a694 --- /dev/null +++ b/packages/core-internal/src/validators/externalRefResolver.ts @@ -0,0 +1,399 @@ +/** + * Opt-in resolver for external (`$ref`) JSON Schema references (SEP-2106, R-2106-10). + * + * By default the SDK **refuses** to dereference any `$ref`/`$dynamicRef` that is not a same-document + * reference (see {@link ./schemaBounds.js | assertSchemaSafeToCompile}). That safe default protects + * against the SSRF / fetch-amplification primitive a naive resolver would expose. SEP-2106 permits an + * **opt-in** mode that fetches non-local references, but requires it to be: + * + * - **disabled by default** — this is a separate function an operator must call explicitly + * ("explicit operator action", per the SEP); it is never invoked during normal validation; + * - **host-restricted** — it SHOULD enforce an allowlist of hosts, and at minimum reject loopback, + * link-local, and private network addresses; + * - **bounded** — it MUST apply timeouts and response size limits; + * - **observable** — it SHOULD log the URIs it dereferences; + * - **fail-closed** — a reference that cannot be resolved MUST cause rejection, never a silent pass. + * + * Rather than teaching the (synchronous) validators to fetch, this resolver runs **ahead of time** + * and returns a self-contained schema: each external document is fetched once and **flattened** into + * the root document's `$defs`, and every reference (external, and the internal references inside a + * fetched document) is rewritten to a root-relative same-document JSON Pointer. The result therefore + * contains **only** local pointer references — no nested `$id` scopes — so it passes the default + * safety guard and compiles with the standard validators (AJV / cfworker) without any network access + * at validation time. + * + * @example + * ```ts source="./externalRefResolver.examples.ts#resolveExternalSchemaRefs_basic" + * const resolved = await resolveExternalSchemaRefs(toolOutputSchema, { + * allowlist: ['schemas.example.com'] + * }); + * // `resolved` has no external $refs; hand it to registerTool / fromJsonSchema as usual. + * ``` + * + * @module + */ + +import type { JsonSchemaType } from './types'; + +/** Default per-request fetch timeout, in milliseconds. */ +export const DEFAULT_REF_FETCH_TIMEOUT_MS = 5000; + +/** Default maximum size of a fetched schema document, in bytes. */ +export const DEFAULT_REF_MAX_BYTES = 1_000_000; + +/** Default maximum number of distinct external documents fetched while resolving one schema. */ +export const DEFAULT_REF_MAX_DOCUMENTS = 50; + +/** Options controlling {@link resolveExternalSchemaRefs}. */ +export interface ResolveExternalRefsOptions { + /** + * Allowlist of permitted hosts (e.g. `'schemas.example.com'`). When provided, only references + * whose host exactly matches an entry are fetched; everything else is rejected. **Strongly + * recommended** — without it, the resolver still rejects loopback/link-local/private targets, + * but cannot defend against a public URL that an attacker controls. + */ + allowlist?: readonly string[]; + /** + * Permitted URL protocols. Defaults to `['https:']`. Add `'http:'` only for trusted internal + * use; plaintext fetches are easier to tamper with in transit. + */ + allowedProtocols?: readonly string[]; + /** Per-request timeout in milliseconds (default: 5000). */ + timeoutMs?: number; + /** Maximum size of a single fetched document in bytes (default: 1,000,000). */ + maxBytes?: number; + /** Maximum number of distinct documents fetched (default: 50). */ + maxDocuments?: number; + /** + * Fetch implementation. Defaults to the global `fetch`. Inject a custom one for tests or to add + * proxying/auth. Must honour the `AbortSignal` passed in `init.signal`. + */ + fetch?: typeof globalThis.fetch; + /** + * Called with each external URI **before** it is dereferenced, so operators can audit/log + * network access (the SEP asks implementations to log dereferenced URIs). Defaults to a no-op. + */ + onDereference?: (uri: string) => void; +} + +interface ResolvedOptions { + allowlist?: readonly string[]; + allowedProtocols: readonly string[]; + timeoutMs: number; + maxBytes: number; + maxDocuments: number; + fetchImpl: typeof globalThis.fetch; + onDereference: (uri: string) => void; +} + +/** Split a reference into its base (document) URI and fragment (without the leading `#`). */ +function splitRef(ref: string): { base: string; fragment: string } { + const hashIndex = ref.indexOf('#'); + if (hashIndex === -1) { + return { base: ref, fragment: '' }; + } + return { base: ref.slice(0, hashIndex), fragment: ref.slice(hashIndex + 1) }; +} + +/** A reference is "external" when it has a non-empty base (i.e. it does not start with `#`). */ +function isExternalRef(ref: string): boolean { + return ref.length > 0 && !ref.startsWith('#'); +} + +const DATA_VALUE_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']); +const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties']); + +function resolveExternalBase(base: string, containingDocumentUri: string | undefined, originalRef: string): string { + try { + return new URL(base, containingDocumentUri).href; + } catch { + throw new Error(`Refusing to dereference "${originalRef}": not an absolute URI.`); + } +} + +function ipv6LiteralFromHost(host: string): string | undefined { + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : undefined; +} + +function stripTrailingDnsRootDots(host: string): string { + let end = host.length; + while (end > 0 && host.codePointAt(end - 1) === 46) { + end--; + } + + return end === host.length ? host : host.slice(0, end); +} + +function normalizeHostForPolicy(host: string): string { + return ipv6LiteralFromHost(host) === undefined ? stripTrailingDnsRootDots(host) : host; +} + +function isBlockedIPv6Literal(host: string): boolean { + if (host === '::' || host === '::1' || host.startsWith('::ffff:')) { + return true; + } + + const firstHextet = Number.parseInt(host.split(':', 1)[0] ?? '', 16); + if (Number.isNaN(firstHextet)) { + return false; + } + + return (firstHextet >= 0xfc_00 && firstHextet <= 0xfd_ff) || (firstHextet >= 0xfe_80 && firstHextet <= 0xfe_bf); +} + +/** + * Reject hosts that are obvious SSRF targets: loopback, link-local, and private ranges. This is a + * best-effort literal-address check (it does not resolve DNS); the allowlist is the real defence. + */ +function assertHostAllowed(url: URL, options: ResolvedOptions): void { + if (!options.allowedProtocols.includes(url.protocol)) { + throw new Error( + `Refusing to dereference "${url.href}": protocol "${url.protocol}" is not allowed (allowed: ${options.allowedProtocols.join(', ')}).` + ); + } + + const host = normalizeHostForPolicy(url.hostname.toLowerCase()); + + if (options.allowlist) { + const allowlist = new Set(options.allowlist.map(allowedHost => normalizeHostForPolicy(allowedHost.toLowerCase()))); + if (!allowlist.has(host)) { + throw new Error(`Refusing to dereference "${url.href}": host "${host}" is not in the allowlist.`); + } + return; + } + + // No allowlist: reject the most dangerous literal targets so an unguarded call still cannot + // trivially hit internal services / cloud metadata endpoints. + const ipv6Literal = ipv6LiteralFromHost(host); + const blocked = + host === 'localhost' || + host === '0.0.0.0' || + host.endsWith('.localhost') || + host.endsWith('.internal') || + /^127\./.test(host) || + /^10\./.test(host) || + /^192\.168\./.test(host) || + /^169\.254\./.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host) || + (ipv6Literal !== undefined && isBlockedIPv6Literal(ipv6Literal)); + if (blocked) { + throw new Error( + `Refusing to dereference "${url.href}": host "${host}" resolves to a loopback/link-local/private address. ` + + `Provide an explicit allowlist to dereference internal hosts intentionally.` + ); + } +} + +/** Read a response body, enforcing the byte cap as it streams. */ +async function readBounded(response: Response, maxBytes: number, uri: string): Promise { + const declared = response.headers.get('content-length'); + if (declared && Number(declared) > maxBytes) { + throw new Error(`Refusing to dereference "${uri}": declared content-length ${declared} exceeds max ${maxBytes} bytes.`); + } + + const body = response.body; + if (!body) { + const text = await response.text(); + if (text.length > maxBytes) { + throw new Error(`Refusing to dereference "${uri}": response exceeds max ${maxBytes} bytes.`); + } + return text; + } + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let out = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel(); + throw new Error(`Refusing to dereference "${uri}": response exceeds max ${maxBytes} bytes.`); + } + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return out; +} + +/** Fetch and parse one external schema document, applying timeout + size bounds. */ +async function fetchDocument(uri: string, options: ResolvedOptions): Promise> { + let url: URL; + try { + url = new URL(uri); + } catch { + throw new Error(`Refusing to dereference "${uri}": not an absolute URI.`); + } + assertHostAllowed(url, options); + + options.onDereference(url.href); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs); + let text: string; + try { + const response = await options.fetchImpl(url.href, { signal: controller.signal, redirect: 'error' }); + if (!response.ok) { + throw new Error(`Refusing to use "${url.href}": fetch returned HTTP ${response.status}.`); + } + text = await readBounded(response, options.maxBytes, url.href); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Refusing to dereference "${url.href}": fetch timed out after ${options.timeoutMs}ms.`); + } + throw error instanceof Error ? error : new Error(String(error)); + } finally { + clearTimeout(timer); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Refusing to use "${url.href}": response is not valid JSON.`); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Refusing to use "${url.href}": resolved document is not a JSON Schema object.`); + } + return parsed as Record; +} + +/** + * Resolve and inline all external `$ref`/`$dynamicRef` references in a JSON Schema, returning a + * self-contained schema with only same-document references. + * + * This is the **opt-in** external-reference mode described by SEP-2106 (R-2106-10): it is never + * invoked during normal validation and must be called explicitly. Each distinct external document is + * fetched once (subject to the allowlist, protocol, timeout, size, and document-count limits) and + * bundled under a generated `$defs` slot that preserves the document's canonical `$id`; every + * external reference to that document is rewritten to a local JSON Pointer into the slot. References + * the resolver cannot satisfy cause rejection (fail-closed) rather than a silent pass. + * + * @param schema - the schema to resolve. Not mutated; a new object is returned. + * @param options - allowlist and bounds. Supplying an `allowlist` is strongly recommended. + * @returns a schema whose references are all same-document (safe to compile with the default guard). + * @throws Error if a reference targets a disallowed host, cannot be fetched within bounds, uses an + * external `$anchor` fragment (unsupported), or the document budget is exceeded. + */ +export async function resolveExternalSchemaRefs(schema: JsonSchemaType, options: ResolveExternalRefsOptions = {}): Promise { + const resolved: ResolvedOptions = { + allowlist: options.allowlist, + allowedProtocols: options.allowedProtocols ?? ['https:'], + timeoutMs: options.timeoutMs ?? DEFAULT_REF_FETCH_TIMEOUT_MS, + maxBytes: options.maxBytes ?? DEFAULT_REF_MAX_BYTES, + maxDocuments: options.maxDocuments ?? DEFAULT_REF_MAX_DOCUMENTS, + fetchImpl: options.fetch ?? globalThis.fetch, + onDereference: options.onDereference ?? (() => {}) + }; + if (typeof resolved.fetchImpl !== 'function') { + throw new TypeError('resolveExternalSchemaRefs: no fetch implementation available; pass options.fetch.'); + } + + // Map of base URI -> generated $defs slot key, and the collected bundle of fetched documents. + const slotByBase = new Map(); + const bundle: Record> = {}; + + const ensureDocument = async (base: string): Promise => { + const existing = slotByBase.get(base); + if (existing) { + return existing; + } + if (slotByBase.size >= resolved.maxDocuments) { + throw new Error(`Refusing to resolve more than ${resolved.maxDocuments} external schema documents.`); + } + const slot = `__externalRef_${slotByBase.size}`; + slotByBase.set(base, slot); + + const doc = await fetchDocument(base, resolved); + // Flatten the document into the root's $defs under `slot`. Its own identity/dialect keywords + // are dropped (it no longer is a standalone document), and its internal references are + // rewritten to root-relative pointers that target this slot. + const { $id: _id, $schema: _schema, ...rest } = doc; + void _id; + void _schema; + const flattened = await rewrite(rest, `/$defs/${slot}`, base); + bundle[slot] = flattened as Record; + return slot; + }; + + /** + * Rewrite references in `node` to root-relative same-document pointers. + * + * @param node - the schema (sub)tree. + * @param slotPrefix - `''` for the root document; `/$defs/` when rewriting a fetched + * document that is being flattened into that slot (so its internal `#/x` refs become + * `#/$defs//x`). + * @param containingDocumentUri - the fetched document URL used to resolve relative refs. + */ + async function rewrite(node: unknown, slotPrefix: string, containingDocumentUri?: string): Promise { + if (Array.isArray(node)) { + return Promise.all(node.map(item => rewrite(item, slotPrefix, containingDocumentUri))); + } + if (node === null || typeof node !== 'object') { + return node; + } + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + if ((key === '$ref' || key === '$dynamicRef') && typeof value === 'string') { + if (isExternalRef(value)) { + const { base, fragment } = splitRef(value); + if (fragment && !fragment.startsWith('/')) { + throw new Error( + `Cannot resolve external ${key} "${value}": external "$anchor" fragments are not supported. ` + + `Use a JSON Pointer fragment (e.g. "#/$defs/Foo") or restructure the schema.` + ); + } + const slot = await ensureDocument(resolveExternalBase(base, containingDocumentUri, value)); + out[key] = `#/$defs/${slot}${fragment}`; + } else if (slotPrefix === '') { + out[key] = value; + } else { + // Internal reference inside a flattened document: re-base it onto the slot. + const fragment = value.slice(1); + if (fragment !== '' && !fragment.startsWith('/')) { + throw new Error( + `Cannot flatten ${key} "${value}" from an external document: "$anchor" references inside ` + + `fetched schemas are not supported. Use JSON Pointer references (e.g. "#/$defs/Foo").` + ); + } + out[key] = `#${slotPrefix}${fragment}`; + } + } else if (slotPrefix !== '' && (key === '$id' || key === '$anchor' || key === '$dynamicAnchor')) { + // Scope-defining keywords inside a flattened document cannot be preserved once the + // document loses its own identity; reject rather than silently change semantics. + throw new Error( + `Cannot flatten external schema: nested "${key}" is not supported. ` + + `Restructure the referenced document to use plain JSON Pointer references.` + ); + } else if (DATA_VALUE_KEYWORDS.has(key)) { + out[key] = value; + } else if (SCHEMA_MAP_KEYWORDS.has(key) && value !== null && typeof value === 'object' && !Array.isArray(value)) { + out[key] = Object.fromEntries( + await Promise.all( + Object.entries(value as Record).map(async ([childKey, childValue]) => [ + childKey, + await rewrite(childValue, slotPrefix, containingDocumentUri) + ]) + ) + ); + } else { + out[key] = await rewrite(value, slotPrefix, containingDocumentUri); + } + } + return out; + } + + const rewrittenRoot = (await rewrite(schema, '')) as Record; + + if (slotByBase.size === 0) { + // No external references; return the (structurally identical) schema unchanged. + return rewrittenRoot as JsonSchemaType; + } + + const existingDefs = (rewrittenRoot.$defs as Record | undefined) ?? {}; + return { ...rewrittenRoot, $defs: { ...existingDefs, ...bundle } } as JsonSchemaType; +} diff --git a/packages/core-internal/src/validators/schemaBounds.ts b/packages/core-internal/src/validators/schemaBounds.ts new file mode 100644 index 0000000000..ecb28583d9 --- /dev/null +++ b/packages/core-internal/src/validators/schemaBounds.ts @@ -0,0 +1,106 @@ +/** + * Safety guards applied before a JSON Schema is compiled into a validator. + * + * SEP-2106 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 (e.g. a server's advertised tool definitions) — is + * handed to a validator: + * + * 1. **`$ref` SSRF / fetch-DoS.** JSON Schema 2020-12 allows `$ref` to point at an absolute URI. + * A naive validator that dereferences such a reference over the network gives an attacker a + * server-side request-forgery primitive. We never dereference non-local references; any + * `$ref`/`$dynamicRef` that is not a same-document reference (i.e. does not begin with `#`, + * 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. We bound 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. + * + * Consumers whose legitimate schemas exceed these (generous) defaults can supply their own + * `jsonSchemaValidator` implementation, which is the documented extension point and is not subject + * to these guards. + */ + +/** 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; + +/** Tunable limits for {@link assertSchemaSafeToCompile}. */ +export interface SchemaSafetyLimits { + /** Maximum nesting depth (default {@link DEFAULT_MAX_SCHEMA_DEPTH}). */ + maxDepth?: number; + /** Maximum total number of (sub)schema objects (default {@link DEFAULT_MAX_SUBSCHEMA_COUNT}). */ + maxSubschemas?: number; +} + +/** A `$ref`/`$dynamicRef` is "local" only when it targets the same document (begins with `#`). */ +function isSameDocumentReference(ref: string): boolean { + return ref.startsWith('#'); +} + +const DATA_VALUE_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']); +const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties']); + +/** + * Throws if a JSON Schema is unsafe to compile — either because it carries a non-local + * `$ref`/`$dynamicRef` (which we refuse to dereference) or because it exceeds the configured + * composition bounds. Safe schemas return normally. + * + * @param schema - the JSON Schema (or subschema) to inspect. + * @param limits - optional overrides for the depth / subschema-count caps. + * @throws Error when a non-same-document reference is present, or a bound is exceeded. + */ +export function assertSchemaSafeToCompile(schema: unknown, limits: SchemaSafetyLimits = {}): void { + const maxDepth = limits.maxDepth ?? DEFAULT_MAX_SCHEMA_DEPTH; + const maxSubschemas = limits.maxSubschemas ?? DEFAULT_MAX_SUBSCHEMA_COUNT; + let subschemaCount = 0; + + const walk = (node: unknown, depth: number): void => { + if (depth > maxDepth) { + throw new Error( + `JSON Schema is too deeply nested (exceeds max depth ${maxDepth}); refusing to compile to avoid excessive validation cost.` + ); + } + + if (Array.isArray(node)) { + for (const item of node) { + walk(item, depth + 1); + } + return; + } + + if (node === null || typeof node !== 'object') { + return; + } + + subschemaCount += 1; + if (subschemaCount > maxSubschemas) { + throw new Error( + `JSON Schema has too many subschemas (exceeds max ${maxSubschemas}); refusing to compile to avoid excessive validation cost.` + ); + } + + for (const [key, value] of Object.entries(node)) { + if ((key === '$ref' || key === '$dynamicRef') && typeof value === 'string' && !isSameDocumentReference(value)) { + throw new Error( + `JSON Schema contains a non-local "${key}" ("${value}"). External reference dereferencing is disabled; ` + + `only same-document references (e.g. "#/$defs/Foo" or "#anchor") are supported.` + ); + } + if (DATA_VALUE_KEYWORDS.has(key)) { + continue; + } + if (SCHEMA_MAP_KEYWORDS.has(key) && value !== null && typeof value === 'object' && !Array.isArray(value)) { + for (const child of Object.values(value)) { + walk(child, depth + 1); + } + continue; + } + walk(value, depth + 1); + } + }; + + walk(schema, 0); +} diff --git a/packages/core-internal/src/validators/schemaCompatibility.ts b/packages/core-internal/src/validators/schemaCompatibility.ts new file mode 100644 index 0000000000..074ab93f22 --- /dev/null +++ b/packages/core-internal/src/validators/schemaCompatibility.ts @@ -0,0 +1,84 @@ +import type { JsonSchemaType } from './types'; + +const DATA_VALUE_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']); +const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependencies', 'dependentSchemas', 'patternProperties', 'properties']); +const SCHEMA_ARRAY_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']); +const SCHEMA_VALUE_KEYWORDS = new Set([ + 'additionalProperties', + 'contains', + 'else', + 'if', + 'items', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties' +]); + +function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeSchemaObject(schema: Record): Record { + const tupleItems = Array.isArray(schema.items) ? schema.items : undefined; + const normalized: Record = {}; + + for (const [key, value] of Object.entries(schema)) { + if ((key === 'items' || key === 'additionalItems') && tupleItems !== undefined) { + continue; + } + + if (DATA_VALUE_KEYWORDS.has(key)) { + normalized[key] = value; + } else if (SCHEMA_MAP_KEYWORDS.has(key) && isJsonObject(value)) { + normalized[key] = Object.fromEntries( + Object.entries(value).map(([childKey, childValue]) => [childKey, normalizeSchema(childValue)]) + ); + } else if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) { + normalized[key] = value.map(child => normalizeSchema(child)); + } else if (SCHEMA_VALUE_KEYWORDS.has(key)) { + normalized[key] = normalizeSchema(value); + } else { + normalized[key] = value; + } + } + + if (tupleItems !== undefined) { + if (!('prefixItems' in normalized)) { + normalized.prefixItems = tupleItems.map(item => normalizeSchema(item)); + } + + if ('additionalItems' in schema && schema.additionalItems !== true) { + normalized.items = normalizeSchema(schema.additionalItems); + } + } + + return normalized; +} + +function normalizeSchema(schema: unknown): unknown { + if (schema === true || schema === false) { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => normalizeSchema(item)); + } + + if (!isJsonObject(schema)) { + return schema; + } + + return normalizeSchemaObject(schema); +} + +/** + * JSON Schema 2020-12 replaced draft-07 tuple syntax (`items: [...]` plus + * `additionalItems`) with `prefixItems` plus `items`. Normalize the legacy + * tuple form before handing schemas to 2020-12 validators so older advertised + * tool schemas remain callable. + */ +export function normalizeLegacyTupleSchema(schema: JsonSchemaType): JsonSchemaType { + return normalizeSchema(schema) as JsonSchemaType; +} diff --git a/packages/core-internal/src/validators/types.ts b/packages/core-internal/src/validators/types.ts index e2202b4a69..42bce1dfac 100644 --- a/packages/core-internal/src/validators/types.ts +++ b/packages/core-internal/src/validators/types.ts @@ -13,6 +13,17 @@ import type { JSONSchema } from 'json-schema-typed'; */ export type JsonSchemaType = JSONSchema.Interface; +/** + * The JSON Schema dialect MCP tool `inputSchema`/`outputSchema` default to when no explicit + * `$schema` is declared (SEP-2106). + * + * Both built-in validators are configured to this dialect — `AjvJsonSchemaValidator` via `Ajv2020` + * and `CfWorkerJsonSchemaValidator` via its `draft: '2020-12'` default — so the answer to "what + * dialect does MCP assume?" lives in exactly one place rather than being an implicit per-provider + * default. Custom `jsonSchemaValidator` implementations SHOULD also default to this dialect. + */ +export const MCP_DEFAULT_SCHEMA_DIALECT = '2020-12' as const; + /** * Result of a JSON Schema validation operation */ diff --git a/packages/core-internal/test/spec.types.2025-11-25.test.ts b/packages/core-internal/test/spec.types.2025-11-25.test.ts index ad3fec3f92..a5f8434170 100644 --- a/packages/core-internal/test/spec.types.2025-11-25.test.ts +++ b/packages/core-internal/test/spec.types.2025-11-25.test.ts @@ -245,9 +245,10 @@ const sdkTypeChecks = { spec = sdk; }, Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { - // @ts-expect-error 2025-11-25 types inputSchema/outputSchema properties as `object`; the SDK follows the 2026-07-28 schema's JSONValue sdk = spec; - // @ts-expect-error the SDK's JSONValue-typed inputSchema/outputSchema properties are not assignable to 2025-11-25's `object` + // @ts-expect-error SEP-2106 (2026-07-28) opens inputSchema to any keyword and drops the + // outputSchema `type: 'object'` requirement; the SDK's open schema is not assignable to + // 2025-11-25's narrower `{ type; properties?: object; required? }` shape. spec = sdk; }, ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { @@ -255,13 +256,14 @@ const sdkTypeChecks = { spec = sdk; }, ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above sdk = spec; // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above spec = sdk; }, CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { sdk = spec; + // @ts-expect-error SEP-2106 (2026-07-28) widens structuredContent to any JSON value (`unknown`); + // the SDK type is not assignable to 2025-11-25's `{ [key: string]: unknown }`. spec = sdk; }, CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { @@ -299,10 +301,14 @@ const sdkTypeChecks = { }, SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { sdk = spec; + // @ts-expect-error SamplingMessage content includes ToolResultContent, whose structuredContent + // SEP-2106 (2026-07-28) widens to `unknown`; not assignable to 2025-11-25's narrower shape. spec = sdk; }, CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { sdk = spec; + // @ts-expect-error result content includes ToolResultContent, whose structuredContent SEP-2106 + // (2026-07-28) widens to `unknown`; not assignable to 2025-11-25's narrower shape. spec = sdk; }, SetLevelRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SetLevelRequest) => { @@ -551,10 +557,14 @@ const sdkTypeChecks = { }, ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { sdk = spec; + // @ts-expect-error SEP-2106 (2026-07-28) widens structuredContent to any JSON value (`unknown`); + // the SDK type is not assignable to 2025-11-25's `{ [key: string]: unknown }`. spec = sdk; }, SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { sdk = spec; + // @ts-expect-error includes ToolResultContent, whose structuredContent SEP-2106 (2026-07-28) + // widens to `unknown`; not assignable to 2025-11-25's narrower shape. spec = sdk; }, Annotations: (sdk: SDKTypes.Annotations, spec: SpecTypes.Annotations) => { diff --git a/packages/core-internal/test/types.test.ts b/packages/core-internal/test/types.test.ts index c6c2b5c413..40a60761b4 100644 --- a/packages/core-internal/test/types.test.ts +++ b/packages/core-internal/test/types.test.ts @@ -489,16 +489,17 @@ describe('Types', () => { expect(result.success).toBe(false); }); - test('should still require type: object at root for outputSchema', () => { + test('should accept a non-object root for outputSchema (SEP-2106: full JSON Schema 2020-12)', () => { const tool = { name: 'test', inputSchema: { type: 'object' }, outputSchema: { - type: 'array' + type: 'array', + items: { type: 'number' } } }; const result = ToolSchema.safeParse(tool); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); test('should accept simple minimal schema (backward compatibility)', () => { diff --git a/packages/core-internal/test/types/specTypeSchema.test.ts b/packages/core-internal/test/types/specTypeSchema.test.ts index e04e9f1c45..850f63e2ec 100644 --- a/packages/core-internal/test/types/specTypeSchema.test.ts +++ b/packages/core-internal/test/types/specTypeSchema.test.ts @@ -2,6 +2,11 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth'; import * as schemas from '../../src/types/schemas'; +import type { + CallToolResult as GeneratedCallToolResult, + Tool as GeneratedTool, + ToolResultContent as GeneratedToolResultContent +} from '../../src/types/spec.types.2026-07-28'; import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema'; import { isSpecType, specTypeSchemas } from '../../src/types/specTypeSchema'; import type { @@ -12,7 +17,8 @@ import type { JSONRPCRequest, JSONValue, ResourceTemplateType, - Tool + Tool, + ToolResultContent } from '../../src/types/types'; describe('specTypeSchemas', () => { @@ -148,6 +154,38 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); }); +// SEP-2106 / R-2106-6/7/8: the generated spec interfaces in spec.types.ts and the runtime Zod +// schemas in schemas.ts must describe the same shape. These field-level checks make the mirror an +// explicit, enforced invariant: a future change that widens one file's +// `inputSchema`/`outputSchema`/`structuredContent` without mirroring the other fails *here*, +// pointing straight at the offending field. +describe('SEP-2106 spec.types ↔ schemas mirror (type-level)', () => { + it('Tool.inputSchema keeps a required root type:"object" but is otherwise open', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'object'>(); + // Open-ended: arbitrary 2020-12 keywords are accepted alongside `type`. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('Tool.outputSchema drops the root type:"object" requirement', () => { + expectTypeOf().toEqualTypeOf(); + // No required `type` member: indexing `type` resolves through the `[key: string]: unknown` + // index signature, not a `'object'` literal. + expectTypeOf['type']>().toEqualTypeOf(); + expectTypeOf['$schema']>().toEqualTypeOf(); + }); + + it('CallToolResult.structuredContent and ToolResultContent.structuredContent are any JSON value (unknown)', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + GeneratedToolResultContent['structuredContent'] + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); + describe('SPEC_SCHEMA_KEYS allowlist', () => { // Mirrors the exclusion comment in specTypeSchema.ts. If this list grows, confirm the new // entry has no public type in types.ts before adding it here; otherwise add it to the allowlist. diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..6864608e90 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -39,4 +39,35 @@ describe('standardSchemaToJsonSchema', () => { expect(keys.filter(k => k === 'type')).toHaveLength(1); expect(result.type).toBe('object'); }); + + // SEP-2106 / R-2106-7: a tool's `outputSchema` may be any valid JSON Schema 2020-12 — arrays, + // primitives, or compositions — so the `io: 'output'` branch must return the converted schema + // unchanged, never forcing (or rejecting based on) a root `type: 'object'`. + describe("io: 'output' (SEP-2106 outputSchema)", () => { + test('returns a non-object root unchanged (array)', () => { + const result = standardSchemaToJsonSchema(z.array(z.number()), 'output'); + + expect(result.type).toBe('array'); + expect(result.items).toBeDefined(); + }); + + test('returns a primitive root unchanged (number)', () => { + const result = standardSchemaToJsonSchema(z.number(), 'output'); + + expect(result.type).toBe('number'); + }); + + test('does not force type:object onto an object output schema', () => { + const result = standardSchemaToJsonSchema(z.object({ x: z.string() }), 'output'); + + const keys = Object.keys(result); + expect(keys.filter(k => k === 'type')).toHaveLength(1); + expect(result.type).toBe('object'); + }); + + test('does not throw for a non-object type (unlike input)', () => { + expect(() => standardSchemaToJsonSchema(z.string(), 'output')).not.toThrow(); + expect(() => standardSchemaToJsonSchema(z.array(z.string()), 'output')).not.toThrow(); + }); + }); }); diff --git a/packages/core-internal/test/validators/externalRefResolver.test.ts b/packages/core-internal/test/validators/externalRefResolver.test.ts new file mode 100644 index 0000000000..0361ff1832 --- /dev/null +++ b/packages/core-internal/test/validators/externalRefResolver.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider'; +import { resolveExternalSchemaRefs } from '../../src/validators/externalRefResolver'; +import { assertSchemaSafeToCompile } from '../../src/validators/schemaBounds'; +import type { JsonSchemaType } from '../../src/validators/types'; + +/** Build a `fetch` stub that serves a fixed map of URL -> JSON Schema document. */ +function fetchStub(docs: Record, init?: { status?: number; contentLength?: string }): typeof globalThis.fetch { + return vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const doc = docs[url]; + if (doc === undefined) { + return new Response('not found', { status: 404 }); + } + const headers = new Headers(); + if (init?.contentLength) { + headers.set('content-length', init.contentLength); + } + return new Response(JSON.stringify(doc), { status: init?.status ?? 200, headers }); + }) as unknown as typeof globalThis.fetch; +} + +describe('resolveExternalSchemaRefs', () => { + it('is exported from the curated public API', async () => { + const publicApi = await import('../../src/exports/public/index.js'); + + expect(publicApi.resolveExternalSchemaRefs).toBe(resolveExternalSchemaRefs); + expect(publicApi.MCP_DEFAULT_SCHEMA_DIALECT).toBe('2020-12'); + }); + + it('returns the schema unchanged when there are no external refs', async () => { + const schema: JsonSchemaType = { type: 'object', properties: { a: { type: 'string' } } }; + const fetchImpl = vi.fn(); + const out = await resolveExternalSchemaRefs(schema, { fetch: fetchImpl as unknown as typeof fetch }); + expect(out).toEqual(schema); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('bundles an external $ref and rewrites it to a same-document pointer', async () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { forecast: { $ref: 'https://schemas.example.com/forecast.json' } }, + required: ['forecast'] + }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/forecast.json': { type: 'array', items: { type: 'number' } } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl }); + + // The consuming ref is now local, and the document is flattened under $defs. + const props = (resolved as Record).properties as Record; + expect(props.forecast?.$ref).toMatch(/^#\/\$defs\/__externalRef_0$/); + const defs = (resolved as Record).$defs as Record; + expect(defs.__externalRef_0).toEqual({ type: 'array', items: { type: 'number' } }); + + // The result is fully local: the default safety guard accepts it and it compiles. + expect(() => assertSchemaSafeToCompile(resolved)).not.toThrow(); + }); + + it('matches allowlist hosts case-insensitively', async () => { + const schema: JsonSchemaType = { $ref: 'https://Schemas.Example.com/forecast.json' }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/forecast.json': { type: 'array', items: { type: 'number' } } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['Schemas.Example.com'], fetch: fetchImpl }); + + expect(resolved).toEqual({ + $ref: '#/$defs/__externalRef_0', + $defs: { __externalRef_0: { type: 'array', items: { type: 'number' } } } + }); + }); + + it('matches allowlist hosts with a trailing DNS root dot', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com./forecast.json' }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com./forecast.json': { type: 'string' } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl }); + + expect(resolved).toEqual({ + $ref: '#/$defs/__externalRef_0', + $defs: { __externalRef_0: { type: 'string' } } + }); + }); + + it.each([ + ['AJV', () => new AjvJsonSchemaValidator()], + ['CfWorker', () => new CfWorkerJsonSchemaValidator()] + ] as const)('produces a schema that validates correctly with %s (no network at validation time)', async (_name, make) => { + const schema: JsonSchemaType = { + type: 'object', + properties: { forecast: { $ref: 'https://schemas.example.com/forecast.json#/$defs/hourly' } }, + required: ['forecast'] + }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/forecast.json': { + $defs: { hourly: { type: 'array', items: { type: 'number' } } } + } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl }); + + const validate = make().getValidator(resolved as JsonSchemaType); + expect(validate({ forecast: [1, 2, 3] }).valid).toBe(true); + expect(validate({ forecast: ['x'] }).valid).toBe(false); + }); + + it('resolves transitive external refs (a fetched doc that references another doc)', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/a.json' }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/a.json': { + type: 'object', + properties: { b: { $ref: 'https://schemas.example.com/b.json' } }, + required: ['b'] + }, + 'https://schemas.example.com/b.json': { type: 'number' } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl }); + + expect(() => assertSchemaSafeToCompile(resolved)).not.toThrow(); + const validate = new AjvJsonSchemaValidator().getValidator(resolved as JsonSchemaType); + expect(validate({ b: 42 }).valid).toBe(true); + expect(validate({ b: 'no' }).valid).toBe(false); + }); + + it('resolves relative refs inside fetched documents against the containing document URL', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/models/person.json' }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/models/person.json': { + type: 'object', + properties: { + address: { $ref: '../common/address.json#/$defs/address' } + }, + required: ['address'] + }, + 'https://schemas.example.com/common/address.json': { + $defs: { + address: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'] + } + } + } + }); + + const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl }); + + expect(() => assertSchemaSafeToCompile(resolved)).not.toThrow(); + expect(fetchImpl).toHaveBeenCalledWith('https://schemas.example.com/common/address.json', expect.anything()); + const validate = new AjvJsonSchemaValidator().getValidator(resolved as JsonSchemaType); + expect(validate({ address: { city: 'Paris' } }).valid).toBe(true); + expect(validate({ address: {} }).valid).toBe(false); + }); + + it('does not dereference $ref-shaped data inside data-valued JSON Schema keywords', async () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + payload: { + type: 'object', + default: { $ref: 'https://data.example/default-value' }, + examples: [{ $ref: 'https://data.example/example-value' }] + } + } + }; + const fetchImpl = vi.fn(); + + const resolved = await resolveExternalSchemaRefs(schema, { + allowlist: ['data.example'], + fetch: fetchImpl as unknown as typeof fetch + }); + + expect(resolved).toEqual(schema); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('calls onDereference for each fetched URI (observability)', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/a.json' }; + const fetchImpl = fetchStub({ 'https://schemas.example.com/a.json': { type: 'string' } }); + const seen: string[] = []; + + await resolveExternalSchemaRefs(schema, { + allowlist: ['schemas.example.com'], + fetch: fetchImpl, + onDereference: uri => seen.push(uri) + }); + + expect(seen).toEqual(['https://schemas.example.com/a.json']); + }); + + describe('security: host / protocol restrictions', () => { + it('rejects a host not in the allowlist', async () => { + const schema: JsonSchemaType = { $ref: 'https://evil.example/x.json' }; + await expect(resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchStub({}) })).rejects.toThrow( + /not in the allowlist/i + ); + }); + + it.each([ + 'https://localhost/x.json', + 'https://localhost./x.json', + 'https://127.0.0.1/x.json', + 'https://10.0.0.5/x.json', + 'https://169.254.169.254/x.json', + 'https://metadata.google.internal./computeMetadata/v1', + 'https://[::1]/x.json', + 'https://[fd00::1]/x.json', + 'https://[fe80::1]/x.json', + 'https://[::ffff:127.0.0.1]/x.json' + ])('rejects loopback/link-local/private target %s when no allowlist is given', async uri => { + await expect(resolveExternalSchemaRefs({ $ref: uri } as JsonSchemaType, { fetch: fetchStub({}) })).rejects.toThrow( + /loopback\/link-local\/private/i + ); + }); + + it('allows public DNS hosts that start with IPv6 private-range prefixes', async () => { + const fetchImpl = fetchStub({ 'https://fcc.gov/schema.json': { type: 'string' } }); + const resolved = await resolveExternalSchemaRefs({ $ref: 'https://fcc.gov/schema.json' } as JsonSchemaType, { + fetch: fetchImpl + }); + + expect(resolved).toEqual({ $ref: '#/$defs/__externalRef_0', $defs: { __externalRef_0: { type: 'string' } } }); + }); + + it('rejects a disallowed protocol (http when only https is allowed)', async () => { + await expect( + resolveExternalSchemaRefs({ $ref: 'http://schemas.example.com/x.json' } as JsonSchemaType, { + allowlist: ['schemas.example.com'], + fetch: fetchStub({}) + }) + ).rejects.toThrow(/protocol "http:" is not allowed/i); + }); + }); + + describe('bounds and fail-closed behaviour', () => { + it('rejects when the fetch fails (fail-closed, not silent pass)', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/missing.json' }; + await expect(resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchStub({}) })).rejects.toThrow( + /HTTP 404/ + ); + }); + + it('rejects a response exceeding the byte limit (declared content-length)', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/big.json' }; + const fetchImpl = fetchStub({ 'https://schemas.example.com/big.json': { type: 'string' } }, { contentLength: '999999' }); + await expect( + resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl, maxBytes: 10 }) + ).rejects.toThrow(/exceeds max 10 bytes/i); + }); + + it('rejects when the document budget is exceeded', async () => { + const schema: JsonSchemaType = { + allOf: [{ $ref: 'https://schemas.example.com/a.json' }, { $ref: 'https://schemas.example.com/b.json' }] + }; + const fetchImpl = fetchStub({ + 'https://schemas.example.com/a.json': { type: 'object' }, + 'https://schemas.example.com/b.json': { type: 'object' } + }); + await expect( + resolveExternalSchemaRefs(schema, { allowlist: ['schemas.example.com'], fetch: fetchImpl, maxDocuments: 1 }) + ).rejects.toThrow(/more than 1 external schema documents/i); + }); + + it('rejects an external $anchor fragment (unsupported)', async () => { + const schema: JsonSchemaType = { $ref: 'https://schemas.example.com/a.json#someAnchor' }; + await expect( + resolveExternalSchemaRefs(schema, { + allowlist: ['schemas.example.com'], + fetch: fetchStub({ 'https://schemas.example.com/a.json': { type: 'string' } }) + }) + ).rejects.toThrow(/\$anchor.*not supported/i); + }); + + it('rejects a non-JSON response', async () => { + const badFetch = vi.fn(async () => new Response('nope', { status: 200 })) as unknown as typeof fetch; + await expect( + resolveExternalSchemaRefs({ $ref: 'https://schemas.example.com/a.json' } as JsonSchemaType, { + allowlist: ['schemas.example.com'], + fetch: badFetch + }) + ).rejects.toThrow(/not valid JSON/i); + }); + }); +}); diff --git a/packages/core-internal/test/validators/schemaBounds.test.ts b/packages/core-internal/test/validators/schemaBounds.test.ts new file mode 100644 index 0000000000..f73434a24a --- /dev/null +++ b/packages/core-internal/test/validators/schemaBounds.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for the SEP-2106 schema safety guards: non-local `$ref` rejection (SSRF) and + * composition bounds (depth / subschema count, composition-DoS). + */ + +import { assertSchemaSafeToCompile } from '../../src/validators/schemaBounds'; + +describe('assertSchemaSafeToCompile', () => { + describe('reference guards', () => { + it('accepts a same-document $ref into $defs', () => { + const schema = { + type: 'object', + $defs: { Name: { type: 'string' } }, + properties: { name: { $ref: '#/$defs/Name' } } + }; + expect(() => assertSchemaSafeToCompile(schema)).not.toThrow(); + }); + + it('accepts a same-document $dynamicRef anchor', () => { + expect(() => assertSchemaSafeToCompile({ $dynamicRef: '#meta' })).not.toThrow(); + }); + + it('rejects an http(s) $ref (SSRF guard)', () => { + expect(() => assertSchemaSafeToCompile({ $ref: 'https://evil.example/schema.json' })).toThrow(/non-local/i); + }); + + it('rejects a relative/file $ref as non-same-document', () => { + expect(() => assertSchemaSafeToCompile({ type: 'object', properties: { x: { $ref: 'other.json#/X' } } })).toThrow(/non-local/i); + }); + + it('rejects a non-local $dynamicRef', () => { + expect(() => assertSchemaSafeToCompile({ $dynamicRef: 'http://evil.example#x' })).toThrow(/non-local/i); + }); + + it('ignores URL-looking strings that are not $ref/$dynamicRef keywords', () => { + const schema = { type: 'string', description: 'see https://example.com/docs', default: 'http://x' }; + expect(() => assertSchemaSafeToCompile(schema)).not.toThrow(); + }); + + it('ignores $ref-like object fields inside data-valued JSON Schema keywords', () => { + const schema = { + type: 'object', + properties: { + payload: { + type: 'object', + const: { $ref: 'https://data.example/const-value' }, + default: { $ref: 'https://data.example/default-value' }, + enum: [{ $ref: 'https://data.example/enum-value' }], + examples: [{ $ref: 'https://data.example/example-value' }] + } + } + }; + + expect(() => assertSchemaSafeToCompile(schema)).not.toThrow(); + }); + + it('still rejects non-local refs in property schemas whose instance name matches a data keyword', () => { + const schema = { + type: 'object', + properties: { + default: { $ref: 'https://evil.example/schema.json' } + } + }; + + expect(() => assertSchemaSafeToCompile(schema)).toThrow(/non-local/i); + }); + }); + + describe('composition bounds', () => { + it('accepts composition keywords within bounds', () => { + const schema = { + type: 'object', + oneOf: [{ required: ['a'] }, { required: ['b'] }], + allOf: [{ type: 'object' }] + }; + expect(() => assertSchemaSafeToCompile(schema)).not.toThrow(); + }); + + it('rejects a schema nested deeper than the depth bound', () => { + let deep: Record = { type: 'object' }; + for (let i = 0; i < 12; i++) { + deep = { type: 'object', properties: { nested: deep } }; + } + expect(() => assertSchemaSafeToCompile(deep, { maxDepth: 4 })).toThrow(/too deeply nested/i); + }); + + it('rejects a schema with more subschemas than the count bound', () => { + const schema = { allOf: Array.from({ length: 20 }, () => ({ type: 'object' })) }; + expect(() => assertSchemaSafeToCompile(schema, { maxSubschemas: 5 })).toThrow(/too many subschemas/i); + }); + + it('accepts a large-but-bounded schema under the default limits', () => { + const schema = { anyOf: Array.from({ length: 100 }, (_unused, i) => ({ const: i })) }; + expect(() => assertSchemaSafeToCompile(schema)).not.toThrow(); + }); + }); +}); diff --git a/packages/core-internal/test/validators/validators.test.ts b/packages/core-internal/test/validators/validators.test.ts index 7ffb4d16dc..690eebf327 100644 --- a/packages/core-internal/test/validators/validators.test.ts +++ b/packages/core-internal/test/validators/validators.test.ts @@ -391,6 +391,53 @@ describe('JSON Schema Validators', () => { expect(validator('specific-value').valid).toBe(true); expect(validator('other-value').valid).toBe(false); }); + + // SEP-2106 / R-2106-2: the default validators MUST run the 2020-12 dialect, not draft-07. + // `prefixItems` is a 2020-12 keyword; draft-07 silently ignores it (accepting any tuple), + // so this is the canonical guard that the default dialect is wired correctly. A plain + // draft-07 `new Ajv()` would let `[1, 'a']` validate against a `[string, number]` tuple. + it('honors prefixItems (2020-12 tuple) on the default dialect', () => { + const schema: JsonSchemaType = { + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'number' }] + }; + const validator = provider.getValidator(schema); + + expect(validator(['a', 1]).valid).toBe(true); + // draft-07 would (incorrectly) accept this because it ignores prefixItems. + expect(validator([1, 'a']).valid).toBe(false); + }); + + it('normalizes draft-07 tuple items arrays on the default dialect', () => { + const schema = { + type: 'object', + properties: { + value: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }] + } + }, + required: ['value'] + } as unknown as JsonSchemaType; + const validator = provider.getValidator(schema); + + expect(validator({ value: ['a', 1] }).valid).toBe(true); + expect(validator({ value: [1, 'a'] }).valid).toBe(false); + expect(validator({ value: ['a', 1, true] }).valid).toBe(true); + }); + + it('normalizes draft-07 additionalItems false to a closed tuple', () => { + const schema = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + additionalItems: false + } as unknown as JsonSchemaType; + const validator = provider.getValidator(schema); + + expect(validator(['a', 1]).valid).toBe(true); + expect(validator([1, 'a']).valid).toBe(false); + expect(validator(['a', 1, true]).valid).toBe(false); + }); }); describe('Complex real-world schemas', () => { @@ -532,6 +579,27 @@ describe('JSON Schema Validators', () => { }); }); +describe('SEP-2106 schema safety guards', () => { + describe.each(validators)('$name Validator', ({ provider }) => { + it('refuses to compile a schema with a non-local $ref (SSRF guard)', () => { + const schema = { + type: 'object', + properties: { x: { $ref: 'https://evil.example/schema.json' } } + } as JsonSchemaType; + expect(() => provider.getValidator(schema)).toThrow(/non-local/i); + }); + + it('compiles a schema with a same-document $ref', () => { + const schema = { + type: 'object', + $defs: { Name: { type: 'string' } }, + properties: { name: { $ref: '#/$defs/Name' } } + } as JsonSchemaType; + expect(() => provider.getValidator(schema)).not.toThrow(); + }); + }); +}); + describe('Missing dependencies', () => { describe('AJV not installed but CfWorker is', () => { beforeEach(() => { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index be18a7c7cd..81aba907a2 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,6 +1,7 @@ import type { BaseMetadata, CallToolResult, + CallToolResultWithStructuredContent, CompleteRequestPrompt, CompleteRequestResourceTemplate, CompleteResult, @@ -171,7 +172,11 @@ 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; + + // Per SEP-2106, a server returning array or primitive structuredContent MUST also emit a + // TextContent block with the serialized JSON, so pre-SEP clients that only understand + // object-typed structuredContent can fall back to the text content. + return withStructuredContentTextFallback(result); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -239,7 +244,10 @@ export class McpServer { return; } - if (!result.structuredContent) { + // Per SEP-2106 structuredContent may be any JSON value, including falsy ones (0, false, "", + // null). Check explicitly for `undefined` rather than truthiness so a valid falsy value is + // not mistaken for "no structured content". + if (result.structuredContent === undefined) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` @@ -797,7 +805,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -808,7 +819,7 @@ export class McpServer { icons?: Icon[]; _meta?: Record; }, - cb: ToolCallback + cb: ToolCallback ): RegisteredTool; /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ registerTool( @@ -822,7 +833,7 @@ export class McpServer { icons?: Icon[]; _meta?: Record; }, - cb: LegacyToolCallback + cb: LegacyToolCallback ): RegisteredTool; registerTool( name: string, @@ -1065,10 +1076,29 @@ export type ZodRawShape = Record; /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ export type InferRawShape = z.infer>; +/** + * Maps a tool's declared `outputSchema` to the precise {@link CallToolResult} its handler returns. + * + * When an `outputSchema` is present, `structuredContent` is typed to the schema's inferred output, so + * the value the handler returns is checked against the schema at compile time. Tools without an + * `outputSchema` return a plain {@link CallToolResult} whose `structuredContent` may be any JSON value + * (per SEP-2106). + * + * @typeParam Output - the tool's `outputSchema`: a Standard Schema, a {@linkcode ZodRawShape}, or `undefined`. + */ +export type ToolResultFor = Output extends StandardSchemaWithJSON + ? CallToolResultWithStructuredContent> + : Output extends ZodRawShape + ? CallToolResultWithStructuredContent> + : CallToolResult; + /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ -export type LegacyToolCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise - : (ctx: ServerContext) => CallToolResult | Promise; +export type LegacyToolCallback< + Args extends ZodRawShape | undefined, + Output extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined +> = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => ToolResultFor | Promise> + : (ctx: ServerContext) => ToolResultFor | Promise>; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape @@ -1085,12 +1115,14 @@ export type BaseToolCallback< /** * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. + * + * When the tool declares an `outputSchema`, pass it as `Output` so the handler's returned + * `structuredContent` is checked against the schema's inferred output type at compile time. */ -export type ToolCallback = BaseToolCallback< - CallToolResult, - ServerContext, - Args ->; +export type ToolCallback< + Args extends StandardSchemaWithJSON | undefined = undefined, + Output extends StandardSchemaWithJSON | undefined = undefined +> = BaseToolCallback, ServerContext, Args>; /** * Tool handler callback type. @@ -1132,6 +1164,39 @@ export type RegisteredTool = { remove(): void; }; +/** + * Returns a {@link CallToolResult} with a backward-compatibility text block added when required by + * SEP-2106, without mutating the input. + * + * Servers that return array or primitive `structuredContent` MUST also include a {@link TextContent} + * block with the serialized JSON, so pre-SEP clients that only understand object-typed + * `structuredContent` can fall back to the text content. The original result is returned unchanged + * (same reference) when no fallback is needed: + * + * - no `structuredContent` present, or + * - `structuredContent` is a plain object (the only shape pre-SEP clients accept), or + * - the result already carries a text block — the handler is assumed to have provided its own + * representation. + * + * Otherwise a new result is returned with a serialized text block appended; the input is left + * untouched so the request handler stays a side-effect-free pipeline. + */ +function withStructuredContentTextFallback(result: CallToolResult): CallToolResult { + const structuredContent = result.structuredContent; + if (structuredContent === undefined) { + return result; + } + const isPlainObject = structuredContent !== null && typeof structuredContent === 'object' && !Array.isArray(structuredContent); + if (isPlainObject) { + return result; + } + const content = Array.isArray(result.content) ? result.content : []; + if (content.some(block => block.type === 'text')) { + return result; + } + return { ...result, content: [...content, { type: 'text', text: JSON.stringify(structuredContent) }] }; +} + /** * Creates an executor that invokes the handler with the appropriate arguments. * When `inputSchema` is defined, the handler is called with `(args, ctx)`. diff --git a/packages/server/src/validators/ajv.ts b/packages/server/src/validators/ajv.ts index 8bd1a78a8d..13476c75c5 100644 --- a/packages/server/src/validators/ajv.ts +++ b/packages/server/src/validators/ajv.ts @@ -1,14 +1,14 @@ /** - * 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 `Ajv2020` + `addFormats` from + * the SDK's bundled copy, so customising the validator needs no extra installs. * * @example * ```ts - * import { Ajv, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + * import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; * - * const ajv = new Ajv({ strict: true, allErrors: true }); + * const ajv = new Ajv2020({ strict: true, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ -export { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +export { addFormats, Ajv, Ajv2020, AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 6c87e25f12..5fadb744e5 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import type { CallToolResultWithStructuredContent, JSONRPCMessage } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; @@ -119,6 +119,50 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = await server.close(); }); + + it('adds text fallback for structuredContent when an untyped handler omits content', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + const untypedHandler = (async () => ({ structuredContent: [1, 2, 3] })) as unknown as () => Promise< + CallToolResultWithStructuredContent + >; + + server.registerTool('forecast', { outputSchema: z.array(z.number()) }, untypedHandler); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'forecast', arguments: {} } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const response = responses.find(r => 'id' in r && r.id === 2) as { + result?: { content?: Array<{ type: string; text?: string }>; structuredContent?: unknown }; + }; + expect(response.result?.structuredContent).toEqual([1, 2, 3]); + expect(response.result?.content).toEqual([{ type: 'text', text: '[1,2,3]' }]); + + await server.close(); + }); }); describe('InferRawShape', () => { @@ -127,3 +171,61 @@ describe('InferRawShape', () => { expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); }); }); + +// SEP-2106 / R-2106-3: when a tool declares an `outputSchema`, `registerTool` infers it as the +// `Output` type param so the handler's returned `structuredContent` is checked against the schema's +// inferred output at compile time. These cases pin that contract: correct shapes compile, wrong +// shapes fail to type-check (guarded by @ts-expect-error so a regression that loosens the typing +// turns these into compile errors). Type-only — registration side effects are covered above. +describe('registerTool compile-time outputSchema typing (SEP-2106)', () => { + it('accepts structuredContent matching a Standard Schema outputSchema', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({ + content: [{ type: 'text' as const, text: '22.9' }], + structuredContent: { bmi: 22.9 } + })); + }); + + it('rejects structuredContent that does not match the outputSchema', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + // The return-type mismatch surfaces at the registerTool call (the handler's return type is + // contextually checked against ToolResultFor), so the directive sits on this line. + // @ts-expect-error - bmi must be a number, not a string + server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({ + content: [{ type: 'text' as const, text: 'x' }], + structuredContent: { bmi: 'not-a-number' } + })); + }); + + it('allows omitting structuredContent at compile time (the MUST-return rule is runtime-enforced)', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + // CallToolResultWithStructuredContent types structuredContent as optional (`?: T`), so a + // handler that omits it still compiles. The "outputSchema implies structuredContent" rule is + // enforced at runtime by validateToolOutput (covered in client/server runtime tests), not by + // the type system — this documents and pins that boundary. + server.registerTool('bmi', { outputSchema: z.object({ bmi: z.number() }) }, async () => ({ + content: [{ type: 'text' as const, text: 'x' }] + })); + }); + + it('supports a non-object (array) outputSchema per SEP-2106', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('forecast', { outputSchema: z.array(z.object({ temp: z.number() })) }, async () => ({ + content: [{ type: 'text' as const, text: '[]' }], + structuredContent: [{ temp: 1 }] + })); + }); + + it('allows any JSON value in structuredContent when no outputSchema is declared', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('free', {}, async () => ({ + content: [{ type: 'text' as const, text: '42' }], + structuredContent: 42 + })); + }); +}); diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..452a8f97fe 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -31,8 +31,6 @@ client: # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - http-custom-headers - http-invalid-tool-headers - # 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 6603c4a0e8..e09aced0fc 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -144,6 +144,12 @@ async function runToolsCallClient(serverUrl: string): Promise { registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); +// ============================================================================ +// JSON Schema $ref scenario (SEP-2106) +// ============================================================================ + +registerScenario('json-schema-ref-no-deref', runBasicClient); + // ============================================================================ // Auth scenarios - well-behaved client // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 387054f0b1..852784c1f6 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { fromJsonSchema, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -597,19 +597,43 @@ function createMcpServer() { } ); - // SEP-1613: JSON Schema 2020-12 conformance test tool + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. The `json-schema-2020-12` + // scenario asserts that `$schema`, `$defs`/`$anchor`, `additionalProperties`, composition + // (`allOf`/`anyOf`), and conditional (`if`/`then`/`else`) keywords are preserved verbatim in + // the tools/list response, so the schema is registered as raw JSON Schema via fromJsonSchema(). 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<{ name?: string; address?: { street?: string; city?: string } }>({ + $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 -- JSON Schema conditional keyword, not a Promise + then: { required: ['phone'] }, + else: { required: ['email'] }, + additionalProperties: false }) }, async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { diff --git a/test/e2e/scenarios/sampling.test.ts b/test/e2e/scenarios/sampling.test.ts index d1b04893b8..456ee96daa 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -20,6 +20,16 @@ import { tapWire, wire } from '../helpers/index'; import { verifies } from '../helpers/verifies'; import type { TestArgs } from '../types'; +/** Shape of the `structuredContent` returned by the `sampling-passthrough` test tool. */ +type SamplingPassthroughResult = { ok: boolean; code?: number; message?: string }; + +function asSamplingPassthroughResult(value: unknown): SamplingPassthroughResult { + if (typeof value !== 'object' || value === null || Array.isArray(value) || typeof (value as { ok?: unknown }).ok !== 'boolean') { + throw new Error('Expected sampling-passthrough structuredContent'); + } + return value as SamplingPassthroughResult; +} + const newClient = (capabilities?: ClientCapabilities) => new Client({ name: 'c', version: '0' }, { capabilities: capabilities ?? { sampling: {} } }); @@ -199,10 +209,14 @@ verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => { await using _ = await wire(transport, passthroughServer, client); - const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } }); + const r = await client.callTool({ + name: 'sampling-passthrough', + arguments: { messages: [], maxTokens: 10 } + }); + const structuredContent = asSamplingPassthroughResult(r.structuredContent); - expect(r.structuredContent).toMatchObject({ ok: false, code: -1 }); - expect(r.structuredContent?.message).toMatch(/User rejected sampling request/); + expect(structuredContent).toMatchObject({ ok: false, code: -1 }); + expect(structuredContent.message).toMatch(/User rejected sampling request/); }); verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => { @@ -338,9 +352,10 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs maxTokens: 10 } }); + const structuredContent = asSamplingPassthroughResult(r.structuredContent); - expect(r.structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams }); - expect(r.structuredContent?.message).toMatch(/tool.?result/i); + expect(structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams }); + expect(structuredContent.message).toMatch(/tool.?result/i); }); verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => { @@ -423,27 +438,30 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, tools: [{ name: 'n', inputSchema: { type: 'object' as const } }] } }); + const withToolsContent = asSamplingPassthroughResult(withTools.structuredContent); - expect(withTools.structuredContent).toMatchObject({ ok: false }); - expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i); + expect(withToolsContent).toMatchObject({ ok: false }); + expect(withToolsContent.message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const withChoice = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, toolChoice: { mode: 'auto' } } }); + const withChoiceContent = asSamplingPassthroughResult(withChoice.structuredContent); - expect(withChoice.structuredContent).toMatchObject({ ok: false }); - expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i); + expect(withChoiceContent).toMatchObject({ ok: false }); + expect(withChoiceContent.message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const empty = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, tools: [], toolChoice: { mode: 'required' } } }); + const emptyContent = asSamplingPassthroughResult(empty.structuredContent); - expect(empty.structuredContent).toMatchObject({ ok: false }); - expect(empty.structuredContent?.message).toMatch(/sampling.*tools/i); + expect(emptyContent).toMatchObject({ ok: false }); + expect(emptyContent.message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); }); diff --git a/test/e2e/scenarios/standard-schema.test.ts b/test/e2e/scenarios/standard-schema.test.ts index a8f3406577..f41796432f 100644 --- a/test/e2e/scenarios/standard-schema.test.ts +++ b/test/e2e/scenarios/standard-schema.test.ts @@ -48,7 +48,10 @@ verifies('standardschema:tool:arktype-input', async ({ transport }: TestArgs) => type: 'object', properties: { sku: { type: 'string' }, quantity: { type: 'number' } } }); - expect([...(tool.inputSchema.required ?? [])].toSorted()).toEqual(['quantity', 'sku']); + // Per SEP-2106 `inputSchema` allows arbitrary JSON Schema 2020-12 keywords, so `required` is + // loosely typed (`unknown`). Narrow at runtime instead of asserting a type. + const required = tool.inputSchema.required; + expect((Array.isArray(required) ? required : []).toSorted()).toEqual(['quantity', 'sku']); const r = await client.callTool({ name: 'submit-order', arguments: { sku: 'SKU-1042', quantity: 3 } }); expect(r.isError).toBeFalsy(); @@ -85,7 +88,10 @@ verifies('standardschema:tool:valibot-input', async ({ transport }: TestArgs) => type: 'object', properties: { sku: { type: 'string' }, quantity: { type: 'number' } } }); - expect([...(tool.inputSchema.required ?? [])].toSorted()).toEqual(['quantity', 'sku']); + // Per SEP-2106 `inputSchema` allows arbitrary JSON Schema 2020-12 keywords, so `required` is + // loosely typed (`unknown`). Narrow at runtime instead of asserting a type. + const required = tool.inputSchema.required; + expect((Array.isArray(required) ? required : []).toSorted()).toEqual(['quantity', 'sku']); const r = await client.callTool({ name: 'restock-item', arguments: { sku: 'SKU-7', quantity: 2 } }); expect(r.isError).toBeFalsy(); @@ -135,12 +141,14 @@ verifies('standardschema:tool:output-schema-validation', async ({ transport }: T structuredContent: { healthy: true, uptimeSeconds: 12_345 }, content: [{ type: 'text', text: JSON.stringify({ healthy: true, uptimeSeconds: 12_345 }) }] })); - s.registerTool( - 'get-server-status-corrupt', - { inputSchema: type({}), outputSchema }, - // intentionally nonconforming structuredContent (server-side output validation must reject it) - () => ({ structuredContent: { healthy: 'definitely', uptimeSeconds: 'a while' }, content: [] }) - ); + // Intentionally non-conforming structuredContent: the typed callback correctly rejects this at + // compile time, so suppress the error to exercise the server's runtime output validation + // (simulating an untyped or non-TypeScript server). + // @ts-expect-error structuredContent does not match outputSchema — that is the point of this test + s.registerTool('get-server-status-corrupt', { inputSchema: type({}), outputSchema }, () => ({ + structuredContent: { healthy: 'definitely', uptimeSeconds: 'a while' }, + content: [] + })); return s; }; const client = newClient(); diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 195aa0ce07..0abaa0f0f5 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -88,12 +88,13 @@ function schemaServer(): McpServer { { inputSchema: z.object({ n: z.number() }), outputSchema: z.object({ doubled: z.number().int() }) }, ({ n }) => ({ structuredContent: { doubled: n * 2 }, content: [{ type: 'text', text: JSON.stringify({ doubled: n * 2 }) }] }) ); - s.registerTool( - 'structured-mismatch', - { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, - // intentionally invalid structuredContent (tests server-side validation rejects it) - () => ({ structuredContent: { value: 'not-a-number' }, content: [] }) - ); + // Intentionally invalid structuredContent: the typed callback correctly rejects this at compile + // time, so suppress the error to exercise the server's runtime output validation. + // @ts-expect-error structuredContent does not match outputSchema — that is the point of this test + s.registerTool('structured-mismatch', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, () => ({ + structuredContent: { value: 'not-a-number' }, + content: [] + })); s.registerTool('structured-missing', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, () => ({ content: [{ type: 'text', text: 'handler-body-no-structured' }] })); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a2f53ff71e..1bdd4160f0 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1411,6 +1411,94 @@ test('should handle resource list changed notification with auto refresh', async expect(notifications[0]![1]?.[1]!.name).toBe('test-resource'); }); +test('should auto-refresh all paginated prompts after prompt list changed notification', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { prompts: { listChanged: true } } }); + const listRequests: Array = []; + const notifications: [Error | null, Prompt[] | null][] = []; + let resolveNotification: () => void = () => {}; + const notification = new Promise(resolve => { + resolveNotification = resolve; + }); + + server.setRequestHandler('prompts/list', async request => { + listRequests.push(request.params?.cursor); + if (request.params?.cursor === 'page-2') { + return { prompts: [{ name: 'prompt-2', description: 'second page' }] }; + } + return { prompts: [{ name: 'prompt-1', description: 'first page' }], nextCursor: 'page-2' }; + }); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + prompts: { + debounceMs: 0, + onChanged: (error, prompts) => { + notifications.push([error, prompts]); + resolveNotification(); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await server.notification({ method: 'notifications/prompts/list_changed' }); + await notification; + + expect(listRequests).toEqual([undefined, 'page-2']); + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]?.map(prompt => prompt.name)).toEqual(['prompt-1', 'prompt-2']); +}); + +test('should auto-refresh all paginated resources after resource list changed notification', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + const listRequests: Array = []; + const notifications: [Error | null, Resource[] | null][] = []; + let resolveNotification: () => void = () => {}; + const notification = new Promise(resolve => { + resolveNotification = resolve; + }); + + server.setRequestHandler('resources/list', async request => { + listRequests.push(request.params?.cursor); + if (request.params?.cursor === 'page-2') { + return { resources: [{ name: 'resource-2', uri: 'file:///resource-2.txt' }] }; + } + return { resources: [{ name: 'resource-1', uri: 'file:///resource-1.txt' }], nextCursor: 'page-2' }; + }); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + resources: { + debounceMs: 0, + onChanged: (error, resources) => { + notifications.push([error, resources]); + resolveNotification(); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await server.notification({ method: 'notifications/resources/list_changed' }); + await notification; + + expect(listRequests).toEqual([undefined, 'page-2']); + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]?.map(resource => resource.name)).toEqual(['resource-1', 'resource-2']); +}); + /*** * Test: Handle Multiple List Changed Handlers */ @@ -1940,6 +2028,141 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: A single tool with an uncompilable outputSchema does not break listTools() + * or the use of other tools (SEP-2106 safety-guard isolation). + */ + test('preserves outputSchema validation metadata across paginated tool listings', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + const listRequests: Array = []; + const listChangedNotifications: Array<[Error | null, Tool[] | null]> = []; + let resolveListChangedNotification: () => void = () => {}; + const listChangedNotification = new Promise(resolve => { + resolveListChangedNotification = resolve; + }); + let lastPageToolReturnsInvalid = false; + let lastPageBadToolCalled = false; + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler('tools/list', async request => { + listRequests.push(request.params?.cursor); + + if (request.params?.cursor === 'page-2') { + return { + tools: [ + { + name: 'last-page-tool', + description: 'a tool on the final page', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } + }, + { + name: 'last-page-bad-tool', + description: 'a final-page tool with an outputSchema the SEP-2106 guard rejects', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { $ref: 'https://evil.example/final-page-schema.json' } + } + ] + }; + } + + return { + tools: [ + { + name: 'bad-tool', + description: 'advertises a non-local $ref the SEP-2106 guard rejects', + inputSchema: { type: 'object', properties: {} }, + // Non-same-document $ref: assertSchemaSafeToCompile throws when this is compiled. + outputSchema: { $ref: 'https://evil.example/schema.json' } + }, + { + name: 'validated-tool', + description: 'a normal earlier-page tool that must keep client-side validation', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } + } + ], + nextCursor: 'page-2' + }; + }); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'last-page-tool') { + return { content: [], structuredContent: { ok: lastPageToolReturnsInvalid ? 'not-a-boolean' : true } }; + } + if (request.params.name === 'last-page-bad-tool') { + lastPageBadToolCalled = true; + return { content: [], structuredContent: { irrelevant: true } }; + } + if (request.params.name === 'validated-tool') { + return { content: [], structuredContent: { ok: 'not-a-boolean' } }; + } + return { content: [], structuredContent: { irrelevant: true } }; + }); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (error, tools) => { + listChangedNotifications.push([error, tools]); + resolveListChangedNotification(); + } + } + } + } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // listTools() must NOT reject just because one tool's schema is uncompilable. + const firstPage = await client.listTools(); + expect(firstPage.tools.map(t => t.name).toSorted()).toEqual(['bad-tool', 'validated-tool']); + const secondPage = await client.listTools({ cursor: firstPage.nextCursor }); + expect(secondPage.tools.map(t => t.name).toSorted()).toEqual(['last-page-bad-tool', 'last-page-tool']); + + // The final page's tool is fully usable. + const lastPage = await client.callTool({ name: 'last-page-tool' }); + expect(lastPage.structuredContent).toEqual({ ok: true }); + + // A valid outputSchema from an earlier page still validates structuredContent after pagination. + await expect(client.callTool({ name: 'validated-tool' })).rejects.toThrow(/Structured content does not match/); + + // An earlier-page schema compile error also survives pagination and is scoped to that tool. + await expect(client.callTool({ name: 'bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i); + + // A final-page schema compile error also survives pagination and is scoped to that tool. + await expect(client.callTool({ name: 'last-page-bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i); + expect(lastPageBadToolCalled).toBe(false); + + listRequests.length = 0; + lastPageToolReturnsInvalid = true; + await server.notification({ method: 'notifications/tools/list_changed' }); + await listChangedNotification; + + expect(listRequests).toEqual([undefined, 'page-2']); + expect(listChangedNotifications).toHaveLength(1); + expect(listChangedNotifications[0]![0]).toBeNull(); + expect(listChangedNotifications[0]![1]?.map(t => t.name).toSorted()).toEqual([ + 'bad-tool', + 'last-page-bad-tool', + 'last-page-tool', + 'validated-tool' + ]); + + await expect(client.callTool({ name: 'last-page-tool' })).rejects.toThrow(/Structured content does not match/); + lastPageBadToolCalled = false; + await expect(client.callTool({ name: 'last-page-bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i); + expect(lastPageBadToolCalled).toBe(false); + }); + /*** * Test: Handle Tools Without outputSchema Normally */ diff --git a/test/integration/test/sep2106.test.ts b/test/integration/test/sep2106.test.ts new file mode 100644 index 0000000000..976f63694b --- /dev/null +++ b/test/integration/test/sep2106.test.ts @@ -0,0 +1,172 @@ +/** + * Integration tests for SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 2020-12, + * and `structuredContent` may be any JSON value. + * + * Covers, end-to-end (client <-> server over an in-memory transport): + * - array and primitive `structuredContent` round-trips and is validated against `outputSchema` + * - falsy structured values (`0`, `false`, `""`) are not mistaken for "no structured content" + * - the server auto-emits a serialized `TextContent` fallback for non-object `structuredContent` + * (pre-SEP client interop) but not for object `structuredContent` or when text already exists + * - clients narrow `structuredContent` at runtime before reading properties + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { TextContent } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; +import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +describe('SEP-2106: JSON Schema 2020-12 tool output', () => { + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + mcpServer = new McpServer({ name: 'sep2106 server', version: '1.0' }); + client = new Client({ name: 'sep2106 client', version: '1.0' }); + }); + + async function connect() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Prime the client's cached output-schema validators. + await client.listTools(); + } + + function textBlocks(content: ReadonlyArray<{ type: string }>): TextContent[] { + return content.filter((block): block is TextContent => block.type === 'text'); + } + + test('round-trips array structuredContent and validates it against outputSchema', async () => { + mcpServer.registerTool('hourly', { outputSchema: z.array(z.object({ hour: z.string(), temp: z.number() })) }, () => ({ + content: [], + structuredContent: [ + { hour: '09:00', temp: 68 }, + { hour: '10:00', temp: 72 } + ] + })); + await connect(); + + const result = await client.callTool({ name: 'hourly', arguments: {} }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual([ + { hour: '09:00', temp: 68 }, + { hour: '10:00', temp: 72 } + ]); + expect(Array.isArray(result.structuredContent)).toBe(true); + const structuredContent = result.structuredContent as Array<{ hour: string; temp: number }>; + expect(structuredContent[0]?.hour).toBe('09:00'); + }); + + test('auto-injects a serialized TextContent fallback for array structuredContent', async () => { + mcpServer.registerTool('nums', { outputSchema: z.array(z.number()) }, () => ({ content: [], structuredContent: [1, 2, 3] })); + await connect(); + + const result = await client.callTool({ name: 'nums', arguments: {} }); + + const texts = textBlocks(result.content); + expect(texts).toHaveLength(1); + expect(JSON.parse(texts[0].text)).toEqual([1, 2, 3]); + }); + + test('accepts a falsy primitive (0) as valid structured content', async () => { + mcpServer.registerTool('count', { outputSchema: z.number() }, () => ({ content: [], structuredContent: 0 })); + await connect(); + + const result = await client.callTool({ name: 'count', arguments: {} }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toBe(0); + // Non-object value gets a serialized text fallback. + expect(textBlocks(result.content).map(t => t.text)).toEqual(['0']); + }); + + // R-2106-6 + the `=== undefined` (not truthiness) fix in client/server: every falsy JSON value + // must round-trip as real structured content, not be mistaken for "absent". `0` is covered above; + // this pins `false`, `""`, and `null` so the truthiness bug cannot regress. + test.each([ + { name: 'false', schema: z.boolean(), value: false, text: 'false' }, + { name: 'empty-string', schema: z.string(), value: '', text: '""' }, + { name: 'null', schema: z.null(), value: null, text: 'null' } + ])('round-trips falsy structured content: $name', async ({ name, schema, value, text }) => { + mcpServer.registerTool(name, { outputSchema: schema }, () => ({ content: [], structuredContent: value })); + await connect(); + + const result = await client.callTool({ name, arguments: {} }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toBe(value); + // Non-object falsy values also get a serialized text fallback for pre-SEP clients. + expect(textBlocks(result.content).map(t => t.text)).toEqual([text]); + }); + + // R-2106-9/11/12: the SSRF / composition-DoS guards are wired into the *shipped default* validator, + // not just unit-tested in isolation. Registering a raw JSON Schema outputSchema via the public + // `fromJsonSchema` entry point compiles it through that default validator, so an unsafe schema + // surfaces as a clean, descriptive error at registration — never an opaque crash or a network fetch. + describe('schema safety guards surface cleanly through the default validator', () => { + test('rejects a non-local $ref outputSchema (SSRF guard)', () => { + expect(() => fromJsonSchema({ $ref: 'https://evil.example/schema.json' })).toThrow(/non-local|external reference/i); + }); + + test('rejects an over-deep outputSchema (composition-DoS depth bound)', () => { + // Build a schema nested far deeper than the default depth bound (64). + let deep: Record = { type: 'object' }; + for (let i = 0; i < 200; i++) { + deep = { type: 'object', properties: { nested: deep } }; + } + expect(() => fromJsonSchema(deep)).toThrow(/too deeply nested|max depth/i); + }); + + test('accepts a same-document $ref outputSchema (local refs are allowed)', () => { + expect(() => + fromJsonSchema({ + type: 'object', + properties: { self: { $ref: '#/$defs/node' } }, + $defs: { node: { type: 'string' } } + }) + ).not.toThrow(); + }); + }); + + test('does not add a text fallback for object structuredContent', async () => { + mcpServer.registerTool('obj', { outputSchema: z.object({ ok: z.boolean() }) }, () => ({ + content: [], + structuredContent: { ok: true } + })); + await connect(); + + const result = await client.callTool({ name: 'obj', arguments: {} }); + + expect(result.structuredContent).toEqual({ ok: true }); + expect(textBlocks(result.content)).toHaveLength(0); + }); + + test('does not duplicate an existing text block when one is already present', async () => { + mcpServer.registerTool('nums-with-text', { outputSchema: z.array(z.number()) }, () => ({ + content: [{ type: 'text', text: 'pre-existing summary' }], + structuredContent: [9, 8, 7] + })); + await connect(); + + const result = await client.callTool({ name: 'nums-with-text', arguments: {} }); + + const texts = textBlocks(result.content); + expect(texts).toHaveLength(1); + expect(texts[0].text).toBe('pre-existing summary'); + }); + + test('rejects array structuredContent that does not conform to outputSchema (server-side)', async () => { + mcpServer.registerTool('bad-nums', { outputSchema: z.array(z.number()) }, () => ({ + content: [], + // @ts-expect-error intentionally non-conforming output to exercise server-side validation + structuredContent: ['not', 'numbers'] + })); + await connect(); + + const result = await client.callTool({ name: 'bad-nums', arguments: {} }); + expect(result.isError).toBe(true); + expect(textBlocks(result.content)[0]?.text).toMatch(/output validation error/i); + }); +});