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..d4667a091d --- /dev/null +++ b/.changeset/sep-2106-json-schema-2020-12.md @@ -0,0 +1,21 @@ +--- +'@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 narrowing guard or a type argument. +- `client.callTool()` is now generic so callers get a precisely typed `structuredContent` (defaults to `JSONValue`). New `CallToolResultWithStructuredContent` type. +- `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`). +- 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/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..f45b60c472 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -537,7 +537,36 @@ Validator behavior: `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. - To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. -## 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) | +| `CallToolResult.structuredContent` type | `{ [key: string]: unknown }` | `unknown` (**source-breaking**) | +| `client.callTool(...)` | returns `structuredContent` as object | generic `client.callTool(...)`; `structuredContent` typed as `T` (defaults to `JSONValue`) | +| `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, recommended: +const result = await client.callTool<{ temperature: number }>({ name: 'get_weather', arguments: { city: 'SF' } }); +const temp = result.structuredContent?.temperature; // typed +// After, manual narrowing: +const sc = result.structuredContent; +const temp = sc && typeof sc === 'object' && !Array.isArray(sc) ? (sc as Record).temperature : undefined; +``` + +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 a custom `jsonSchemaValidator` to change this. + +## 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` @@ -549,4 +578,5 @@ 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. Verify: build with `tsc` / run tests +11. If you read properties off `result.structuredContent`, add a type argument to `callTool()` or a narrowing guard — it is now typed `unknown` (section 15) +12. Verify: build with `tsc` / run tests diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..7de0ed856f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -977,6 +977,37 @@ subpath in some files and rely on the default in others. To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. +### 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), option A — narrow yourself: +const sc = result.structuredContent; +if (sc && typeof sc === 'object' && !Array.isArray(sc)) { + const temp = (sc as Record).temperature; +} + +// After (v2), option B — pass the expected shape to callTool (recommended): +const result = await client.callTool<{ temperature: number }>({ name: 'get_weather', arguments: { city: 'SF' } }); +const temp = result.structuredContent?.temperature; // typed as number +``` + +**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. 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/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 b08694cfbd..ff9eb263c5 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -102,14 +102,14 @@ async function Client_callTool_basic(client: Client) { */ async function Client_callTool_structuredOutput(client: Client) { //#region Client_callTool_structuredOutput - const result = await client.callTool({ + const result = await client.callTool<{ bmi: number }>({ name: 'calculate-bmi', arguments: { weightKg: 70, heightM: 1.75 } }); // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + if (result.structuredContent !== undefined) { + console.log(result.structuredContent.bmi); // typed as number } //#endregion Client_callTool_structuredOutput } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 36a98521cd..7ce89a3b1c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,6 +2,8 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, + CallToolResultWithStructuredContent, ClientCapabilities, ClientContext, ClientNotification, @@ -13,6 +15,7 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, + JSONValue, ListChangedHandlers, ListChangedOptions, ListPromptsRequest, @@ -215,6 +218,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; @@ -763,27 +773,48 @@ 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). The return type's `structuredContent` defaults to {@linkcode JSONValue}; + * pass a type argument to get a precise type for a tool whose output shape you know: + * * @example Structured output * ```ts source="./client.examples.ts#Client_callTool_structuredOutput" - * const result = await client.callTool({ + * const result = await client.callTool<{ bmi: number }>({ * name: 'calculate-bmi', * arguments: { weightKg: 70, heightM: 1.75 } * }); * * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * if (result.structuredContent !== undefined) { + * console.log(result.structuredContent.bmi); // typed as number * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + callTool( + params: CallToolRequest['params'], + options?: RequestOptions + ): Promise>; + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + // If the tool advertised an outputSchema that failed to compile (e.g. a SEP-2106 safety-guard + // rejection), surface that error now — scoped to this tool — rather than silently skipping + // output validation. + 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}` + ); + } + // 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` @@ -791,7 +822,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); @@ -823,12 +854,19 @@ export class Client extends Protocol { */ private cacheToolMetadata(tools: Tool[]): void { this._cachedToolOutputValidators.clear(); + this._toolOutputValidatorErrors.clear(); for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator + // 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))); + } } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a704267ee3..0b47d461b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,5 +18,7 @@ export * from './util/zodCompat.js'; // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. export type { AjvJsonSchemaValidator } from './validators/ajvProvider.js'; export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from './validators/cfWorkerProvider.js'; +export * from './validators/externalRefResolver.js'; export * from './validators/fromJsonSchema.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; +export { MCP_DEFAULT_SCHEMA_DIALECT } from './validators/types.js'; diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index f472a36ff9..a76946529e 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1360,25 +1360,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(), @@ -1425,11 +1427,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. @@ -1656,7 +1662,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/src/types/types.ts b/packages/core/src/types/types.ts index 123de7fe84..ce44304f06 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -317,6 +317,20 @@ 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 — used by {@link CallToolResult}-returning APIs such as + * `client.callTool()` and by tool handlers whose `outputSchema` is known — so consumers + * get a typed `structuredContent` instead of writing narrowing guards by hand. + * + * @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/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..7b0bd87570 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/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/src/validators/ajvProvider.ts b/packages/core/src/validators/ajvProvider.ts index f62a8469ae..75c3b7d0ed 100644 --- a/packages/core/src/validators/ajvProvider.ts +++ b/packages/core/src/validators/ajvProvider.ts @@ -2,9 +2,11 @@ * 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.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; /** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */ @@ -22,7 +24,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, @@ -71,6 +79,9 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { } getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // SEP-2106: reject non-local $refs (SSRF) and over-budget schemas (composition DoS) before compiling. + assertSchemaSafeToCompile(schema); + const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) diff --git a/packages/core/src/validators/cfWorkerProvider.ts b/packages/core/src/validators/cfWorkerProvider.ts index 6fcc3d507e..03f476705e 100644 --- a/packages/core/src/validators/cfWorkerProvider.ts +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -10,7 +10,9 @@ import { Validator } from '@cfworker/json-schema'; +import { assertSchemaSafeToCompile } from './schemaBounds.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; +import { MCP_DEFAULT_SCHEMA_DIALECT } from './types.js'; /** * JSON Schema draft version supported by `@cfworker/json-schema`. @@ -47,7 +49,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,6 +62,9 @@ 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); + // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible const validator = new Validator(schema as ConstructorParameters[0], this.draft, this.shortcircuit); diff --git a/packages/core/src/validators/externalRefResolver.examples.ts b/packages/core/src/validators/externalRefResolver.examples.ts new file mode 100644 index 0000000000..1fbce3036c --- /dev/null +++ b/packages/core/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.js'; +import type { JsonSchemaType } from './types.js'; + +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/src/validators/externalRefResolver.ts b/packages/core/src/validators/externalRefResolver.ts new file mode 100644 index 0000000000..8b5fd9a44f --- /dev/null +++ b/packages/core/src/validators/externalRefResolver.ts @@ -0,0 +1,347 @@ +/** + * 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.js'; + +/** 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 {@link DEFAULT_REF_FETCH_TIMEOUT_MS}). */ + timeoutMs?: number; + /** Maximum size of a single fetched document in bytes (default {@link DEFAULT_REF_MAX_BYTES}). */ + maxBytes?: number; + /** Maximum number of distinct documents fetched (default {@link DEFAULT_REF_MAX_DOCUMENTS}). */ + 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('#'); +} + +/** + * 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 = url.hostname.toLowerCase(); + + if (options.allowlist) { + if (!options.allowlist.includes(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 blocked = + host === 'localhost' || + host === '::1' || + 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) || + host.startsWith('fc') || + host.startsWith('fd') || + host.startsWith('fe80'); + 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}`); + 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`). + */ + async function rewrite(node: unknown, slotPrefix: string): Promise { + if (Array.isArray(node)) { + return Promise.all(node.map(item => rewrite(item, slotPrefix))); + } + 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(base); + 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 { + out[key] = await rewrite(value, slotPrefix); + } + } + 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/src/validators/schemaBounds.ts b/packages/core/src/validators/schemaBounds.ts new file mode 100644 index 0000000000..ce6b39d4bc --- /dev/null +++ b/packages/core/src/validators/schemaBounds.ts @@ -0,0 +1,94 @@ +/** + * 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('#'); +} + +/** + * 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.` + ); + } + walk(value, depth + 1); + } + }; + + walk(schema, 0); +} diff --git a/packages/core/src/validators/types.ts b/packages/core/src/validators/types.ts index e2202b4a69..42bce1dfac 100644 --- a/packages/core/src/validators/types.ts +++ b/packages/core/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/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index bf0903cd1a..da90360f5f 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/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/test/types.test.ts b/packages/core/test/types.test.ts index a92615bceb..41bdc13207 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/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/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..bb67ee4fe2 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -12,7 +12,8 @@ import type { JSONRPCRequest, JSONValue, ResourceTemplateType, - Tool + Tool, + ToolResultContent } from '../../src/types/types.js'; describe('specTypeSchemas', () => { @@ -148,6 +149,38 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); }); +// SEP-2106 / R-2106-6/7/8: the hand-written interfaces in spec.types.ts and the runtime Zod schemas +// in schemas.ts must describe the same shape. The whole-type assertions above already enforce this +// for `Tool`/`CallToolResult`, but 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<'object'>(); + // Open-ended: arbitrary 2020-12 keywords are accepted alongside `type`. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('Tool.outputSchema drops the root type:"object" requirement', () => { + // 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(); + }); + + it('the inferred (schemas.ts) types equal the hand-written (spec.types.ts) types end to end', () => { + expectTypeOf().toEqualTypeOf(); + 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/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 6c3de99d77..19730188ac 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/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/test/validators/externalRefResolver.test.ts b/packages/core/test/validators/externalRefResolver.test.ts new file mode 100644 index 0000000000..463902bb3a --- /dev/null +++ b/packages/core/test/validators/externalRefResolver.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider.js'; +import { resolveExternalSchemaRefs } from '../../src/validators/externalRefResolver.js'; +import { assertSchemaSafeToCompile } from '../../src/validators/schemaBounds.js'; +import type { JsonSchemaType } from '../../src/validators/types.js'; + +/** 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('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.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('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://127.0.0.1/x.json', 'https://10.0.0.5/x.json', 'https://169.254.169.254/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('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/test/validators/schemaBounds.test.ts b/packages/core/test/validators/schemaBounds.test.ts new file mode 100644 index 0000000000..6c59f5d4e7 --- /dev/null +++ b/packages/core/test/validators/schemaBounds.test.ts @@ -0,0 +1,69 @@ +/** + * 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.js'; + +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(); + }); + }); + + 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/test/validators/validators.test.ts b/packages/core/test/validators/validators.test.ts index 6c543cb058..953b7b3038 100644 --- a/packages/core/test/validators/validators.test.ts +++ b/packages/core/test/validators/validators.test.ts @@ -391,6 +391,22 @@ 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); + }); }); describe('Complex real-world schemas', () => { @@ -532,6 +548,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 40ec8bb1eb..d81956a071 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, @@ -27,6 +28,7 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + isCallToolResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -151,7 +153,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 isCallToolResult(result) ? withStructuredContentTextFallback(result) : result; } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -219,7 +225,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` @@ -770,7 +779,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -780,7 +792,7 @@ export class McpServer { annotations?: ToolAnnotations; _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( @@ -793,7 +805,7 @@ export class McpServer { annotations?: ToolAnnotations; _meta?: Record; }, - cb: LegacyToolCallback + cb: LegacyToolCallback ): RegisteredTool; registerTool( name: string, @@ -1026,10 +1038,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 @@ -1046,12 +1077,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. @@ -1091,6 +1124,38 @@ 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; + } + if (result.content.some(block => block.type === 'text')) { + return result; + } + return { ...result, content: [...result.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/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..eb0a9ac63e 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -127,3 +127,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 f4b7ce4213..6be55e3b32 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -21,8 +21,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 05103eb26d..bad61e3e40 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 f3925aeea8..fd15b060bc 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 f251a9ef5f..89bce0c548 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -20,6 +20,9 @@ import { tapWire, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; +/** Shape of the `structuredContent` returned by the `sampling-passthrough` test tool. */ +type SamplingPassthroughResult = { ok: boolean; code?: number; message?: string }; + const newClient = (capabilities?: ClientCapabilities) => new Client({ name: 'c', version: '0' }, { capabilities: capabilities ?? { sampling: {} } }); @@ -199,7 +202,10 @@ 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 } + }); expect(r.structuredContent).toMatchObject({ ok: false, code: -1 }); expect(r.structuredContent?.message).toMatch(/User rejected sampling request/); @@ -322,7 +328,7 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs await using _ = await wire(transport, passthroughServer, client); - const r = await client.callTool({ + const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [ @@ -419,7 +425,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test await using _ = await wire(transport, passthroughServer, client); - const withTools = await client.callTool({ + const withTools = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, tools: [{ name: 'n', inputSchema: { type: 'object' as const } }] } }); @@ -428,7 +434,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); - const withChoice = await client.callTool({ + const withChoice = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, toolChoice: { mode: 'auto' } } }); @@ -437,7 +443,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); - const empty = await client.callTool({ + const empty = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, tools: [], toolChoice: { mode: 'required' } } }); diff --git a/test/e2e/scenarios/standard-schema.test.ts b/test/e2e/scenarios/standard-schema.test.ts index f646b573f3..c866840c10 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 408712f23a..026fc8d42a 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 a7613b24e4..75002cc255 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1940,6 +1940,60 @@ 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('isolates a tool whose outputSchema fails to compile from the rest of the server', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler('tools/list', async () => ({ + 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: 'good-tool', + description: 'a normal tool that must remain usable', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'good-tool') { + return { content: [], structuredContent: { ok: true } }; + } + return { content: [], structuredContent: { irrelevant: true } }; + }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // listTools() must NOT reject just because one tool's schema is uncompilable. + const listed = await client.listTools(); + expect(listed.tools.map(t => t.name).toSorted()).toEqual(['bad-tool', 'good-tool']); + + // The good tool is fully usable. + const good = await client.callTool({ name: 'good-tool' }); + expect(good.structuredContent).toEqual({ ok: true }); + + // The bad tool surfaces a scoped, descriptive error only when it is called. + await expect(client.callTool({ name: 'bad-tool' })).rejects.toThrow(/output schema that could not be compiled/i); + }); + /*** * 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..922493345e --- /dev/null +++ b/test/integration/test/sep2106.test.ts @@ -0,0 +1,171 @@ +/** + * 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 + * - the typed `client.callTool()` generic surfaces a precise `structuredContent` type + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { TextContent } from '@modelcontextprotocol/core'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +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 } + ]); + // The typed generic lets us index without a narrowing guard. + expect(result.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<{ ok: boolean }>({ 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); + }); +});