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..a5d33b8454 --- /dev/null +++ b/.changeset/sep-2106-json-schema-2020-12.md @@ -0,0 +1,15 @@ +--- +'@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). 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..6acfcec254 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, @@ -763,27 +766,37 @@ 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); // 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 +804,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); diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..16cbb4c3bb 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1309,25 +1309,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(), @@ -1374,11 +1376,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. @@ -1563,7 +1569,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/spec.types.ts b/packages/core/src/types/spec.types.ts index a03f21f134..9f23ad5722 100644 --- a/packages/core/src/types/spec.types.ts +++ b/packages/core/src/types/spec.types.ts @@ -1550,9 +1550,12 @@ export interface CallToolResult extends Result { content: ContentBlock[]; /** - * An optional JSON object that represents the structured result of the tool call. + * An optional JSON value that represents the structured result of the tool call. + * + * This can be any JSON value (object, array, string, number, boolean, or null) + * that conforms to the tool's outputSchema if one is defined. */ - structuredContent?: { [key: string]: unknown }; + structuredContent?: unknown; /** * Whether the tool call ended in an error. @@ -1734,13 +1737,16 @@ export interface Tool extends BaseMetadata, Icons { /** * A JSON Schema object defining the expected parameters for the tool. + * + * Tool arguments are always JSON objects, so `type: "object"` is required at the root. + * Beyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including + * composition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords + * (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other + * standard validation or annotation keywords. + * + * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. */ - inputSchema: { - $schema?: string; - type: 'object'; - properties?: { [key: string]: JSONValue }; - required?: string[]; - }; + inputSchema: { $schema?: string; type: 'object'; [key: string]: unknown }; /** * Execution-related properties for this tool. @@ -1749,17 +1755,11 @@ export interface Tool extends BaseMetadata, Icons { /** * An optional JSON Schema object defining the structure of the tool's output returned in - * the structuredContent field of a {@link CallToolResult}. + * the structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12. * * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. - * Currently restricted to `type: "object"` at the root level. */ - outputSchema?: { - $schema?: string; - type: 'object'; - properties?: { [key: string]: JSONValue }; - required?: string[]; - }; + outputSchema?: { $schema?: string; [key: string]: unknown }; /** * Optional additional tool information. @@ -2454,11 +2454,12 @@ export interface ToolResultContent { content: ContentBlock[]; /** - * An optional structured result object. + * An optional structured result value. * + * This can be any JSON value (object, array, string, number, boolean, or null). * If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema. */ - structuredContent?: { [key: string]: unknown }; + structuredContent?: unknown; /** * Whether the tool use resulted in an error. diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index a92deec8e1..1c0b3604fe 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -305,6 +305,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..3bb4e15588 100644 --- a/packages/core/src/validators/ajvProvider.ts +++ b/packages/core/src/validators/ajvProvider.ts @@ -5,6 +5,7 @@ import { Ajv } from 'ajv'; 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}. */ @@ -71,6 +72,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..73ef81d6f5 100644 --- a/packages/core/src/validators/cfWorkerProvider.ts +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -10,6 +10,7 @@ import { Validator } from '@cfworker/json-schema'; +import { assertSchemaSafeToCompile } from './schemaBounds.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; /** @@ -59,6 +60,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/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/test/types.test.ts b/packages/core/test/types.test.ts index 280f2ede9f..18b296e18b 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -480,16 +480,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/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..7b45840e55 100644 --- a/packages/core/test/validators/validators.test.ts +++ b/packages/core/test/validators/validators.test.ts @@ -532,6 +532,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..df6d9e011e 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,6 +153,13 @@ 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); + + // 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. + if (isCallToolResult(result)) { + ensureStructuredContentTextFallback(result); + } return result; } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { @@ -219,7 +228,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 +782,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -780,7 +795,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 +808,7 @@ export class McpServer { annotations?: ToolAnnotations; _meta?: Record; }, - cb: LegacyToolCallback + cb: LegacyToolCallback ): RegisteredTool; registerTool( name: string, @@ -1026,10 +1041,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 +1080,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 +1127,30 @@ export type RegisteredTool = { remove(): void; }; +/** + * Ensures backward compatibility for non-object `structuredContent` (SEP-2106). + * + * 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. Object `structuredContent` (the only shape + * pre-SEP clients accept) needs no fallback, and a result that already carries a text block is left + * untouched — the handler is assumed to have provided its own representation. + */ +function ensureStructuredContentTextFallback(result: CallToolResult): void { + const structuredContent = result.structuredContent; + if (structuredContent === undefined) { + return; + } + const isPlainObject = structuredContent !== null && typeof structuredContent === 'object' && !Array.isArray(structuredContent); + if (isPlainObject) { + return; + } + if (result.content.some(block => block.type === 'text')) { + 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/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/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/sep2106.test.ts b/test/integration/test/sep2106.test.ts new file mode 100644 index 0000000000..81de976997 --- /dev/null +++ b/test/integration/test/sep2106.test.ts @@ -0,0 +1,123 @@ +/** + * 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 { 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']); + }); + + 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); + }); +});