Skip to content
21 changes: 21 additions & 0 deletions .changeset/sep-2106-json-schema-2020-12.md
Original file line number Diff line number Diff line change
@@ -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<T>()` is now generic so callers get a precisely typed `structuredContent` (defaults to `JSONValue`). New `CallToolResultWithStructuredContent<T>` 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.
34 changes: 32 additions & 2 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,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<T>(...)`; `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<string, unknown>).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`
Expand All @@ -558,4 +587,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<T>()` or a narrowing guard — it is now typed `unknown` (section 15)
12. Verify: build with `tsc` / run tests
31 changes: 31 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,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<string, unknown>).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):
Expand Down
8 changes: 5 additions & 3 deletions examples/server/src/mcpServerOutputSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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'
}
};

Expand Down
6 changes: 3 additions & 3 deletions packages/client/src/client/client.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
58 changes: 48 additions & 10 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'
import type {
BaseContext,
CallToolRequest,
CallToolResult,
CallToolResultWithStructuredContent,
ClientCapabilities,
ClientContext,
ClientNotification,
Expand All @@ -13,6 +15,7 @@ import type {
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
JSONValue,
ListChangedHandlers,
ListChangedOptions,
ListPromptsRequest,
Expand Down Expand Up @@ -221,6 +224,13 @@ export class Client extends Protocol<ClientContext> {
private _instructions?: string;
private _jsonSchemaValidator: jsonSchemaValidator;
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = 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<string, Error> = new Map();
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
private _pendingListChangedConfig?: ListChangedHandlers;
private _enforceStrictCapabilities: boolean;
Expand Down Expand Up @@ -785,35 +795,56 @@ export class Client extends Protocol<ClientContext> {
* 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<StructuredContent = JSONValue>(
params: CallToolRequest['params'],
options?: RequestOptions
): Promise<CallToolResultWithStructuredContent<StructuredContent>>;
async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise<CallToolResult> {
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`
);
}

// 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);
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -845,12 +876,19 @@ export class Client extends Protocol<ClientContext> {
*/
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)));
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading