Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8b631d2
feat(core,server,client): implement SEP-2106 JSON Schema 2020-12 tool…
mattzcarey Jun 2, 2026
104ca37
fix(conformance): pass json-schema-ref-no-deref client scenario
mattzcarey Jun 3, 2026
274786e
feat(core,server): default to Ajv2020 dialect + close SEP-2106 test gaps
mattzcarey Jun 5, 2026
eafccc5
fix(client): isolate per-tool outputSchema compile failures (PR #2249…
mattzcarey Jun 5, 2026
e291c7b
feat(core): opt-in external $ref resolver (SEP-2106 R-2106-10)
mattzcarey Jun 5, 2026
0837580
fix(conformance): advertise raw 2020-12 schema in json-schema-2020-12…
mattzcarey Jun 9, 2026
b56bb80
fix(client): document runtime narrowing for structuredContent
mattzcarey Jun 25, 2026
1b9c36d
fix(core): harden external ref resolver exports
mattzcarey Jun 25, 2026
8a53ea0
fix(sep-2106): address validator review followups
mattzcarey Jun 25, 2026
76f4b51
fix(docs): avoid typedoc links to internal ref defaults
mattzcarey Jun 25, 2026
de66d37
fix(core): handle data refs and relative schema refs
mattzcarey Jun 25, 2026
862f446
fix(server): add structured content fallback without content
mattzcarey Jun 25, 2026
058ef68
fix: preserve tool schema metadata across pagination
mattzcarey Jun 25, 2026
a8b74ea
fix(client): paginate tools listChanged refresh
mattzcarey Jun 25, 2026
551a105
fix(core): normalize external ref host policy checks
mattzcarey Jun 25, 2026
946137b
fix(core): avoid regex in host normalization
mattzcarey Jun 25, 2026
dc19947
fix(core): tolerate legacy tuple schemas
mattzcarey Jun 25, 2026
06f9982
fix(core): align SEP-2106 with core-internal split
mattzcarey Jun 25, 2026
327817c
fix(client): paginate prompt and resource list refresh
mattzcarey Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/sep-2106-json-schema-2020-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---

Implement SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 2020-12, and `structuredContent` may be any JSON value.

- `inputSchema` still requires `type: "object"` at the root but now accepts any JSON Schema 2020-12 keyword (`oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`$anchor`, …).
- `outputSchema` may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions — instead of being restricted to `type: "object"`.
- `CallToolResult.structuredContent` widens from `{ [key: string]: unknown }` to `unknown`. **This is a source-breaking type change** for typed consumers: property access now requires a runtime narrowing guard before reading object properties.
- New `CallToolResultWithStructuredContent<T>` type for APIs whose output shape is known ahead of time (for example, server-side handlers typed from `outputSchema`).
- `McpServer.registerTool` type-checks a handler's returned `structuredContent` against the tool's `outputSchema` inferred output.
- Servers returning array or primitive `structuredContent` automatically also emit a serialized `TextContent` block, so pre-SEP clients can fall back to the text content.
- Built-in validators refuse to dereference non-same-document `$ref`/`$dynamicRef` (SSRF guard) and reject schemas exceeding depth / subschema-count bounds (composition-DoS guard).
- `Client.listTools()` no longer rejects when a single advertised tool's `outputSchema` fails to compile (e.g. it trips the safety guards above): the failure is scoped to the offending tool. Every other tool stays listable and callable; calling the offending tool throws a
descriptive error instead of silently skipping output validation.
- The default Node validator now uses `Ajv2020`, so the 2020-12 dialect is honored by default (previously `new Ajv()` ran draft-07 semantics and silently ignored keywords such as `prefixItems`). Both built-in validators now default to the `2020-12` dialect
(`MCP_DEFAULT_SCHEMA_DIALECT`).
- For compatibility with existing draft-07 tuple schemas, built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should prefer
`prefixItems` directly.
- New opt-in `resolveExternalSchemaRefs(schema, options)` helper (the SEP's optional external-`$ref` mode): fetches and inlines non-local `$ref`s ahead of time into a self-contained schema. Disabled by default, enforces a host allowlist (and rejects loopback/link-local/private
targets otherwise), `https`-only by default, with fetch timeout / response-size / document-count limits, dereference logging, and fail-closed on unresolved references.
10 changes: 7 additions & 3 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ const result = await client.callTool({
console.log(result.content);
```

Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM:
Tool results may include a `structuredContent` field — a machine-readable JSON value for programmatic use by the client application, complementing `content` which is for the LLM. Since it can be an object, array, primitive, or null, narrow it at runtime before reading object properties:

```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput"
const result = await client.callTool({
Expand All @@ -248,8 +248,12 @@ const result = await client.callTool({
});

// Machine-readable output for the client application
if (result.structuredContent) {
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
const structuredContent = result.structuredContent;
if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) {
const bmi = (structuredContent as Record<string, unknown>).bmi;
if (typeof bmi === 'number') {
console.log(bmi); // e.g. 22.86
}
}
```

Expand Down
49 changes: 45 additions & 4 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,11 +550,51 @@ Validator behavior:

- Do not add validator imports for normal migrations.
- Do not install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path; client/server bundle the runtime-selected defaults and the root entry point does not pull either dep in.
- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`,
- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import `Ajv2020`, `addFormats`, and `AjvJsonSchemaValidator` from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv`,
`@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
- Use `Ajv2020` for AJV customization. A plain `Ajv` instance uses draft-07 semantics and does not match MCP's JSON Schema 2020-12 default.
- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface.

## 15. Migration Steps (apply in this order)
## 15. JSON Schema 2020-12 Tool Schemas & `structuredContent` (SEP-2106)

Tool schemas conform to full JSON Schema 2020-12, and `structuredContent` may be any JSON value.

| Aspect | v1 / pre-SEP | v2 / SEP-2106 |
| ---------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `inputSchema` root | `type: "object"` + `properties`/`required` only | `type: "object"` required, **plus** any 2020-12 keyword (`oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`$anchor`) |
| `outputSchema` root | `type: "object"` only | **any** valid JSON Schema 2020-12 (object, array, primitive, composition) |
| `Tool.inputSchema` / `Tool.outputSchema` types | object schema with typed `properties`/`required` members | broad JSON Schema records; narrow keyword field values before using them (**source-breaking**) |
| `CallToolResult.structuredContent` type | `{ [key: string]: unknown }` | `unknown` (**source-breaking**) |
| `client.callTool(...)` | returns `structuredContent` as object | returns `structuredContent` as `unknown`; narrow it before property access |
| `registerTool` handler return | `structuredContent` untyped | type-checked against the tool's `outputSchema` inferred output |

Source-breaking fix — property access on `structuredContent` needs a type or a guard:

```typescript
// Before: result.structuredContent?.temperature (compiled, but unsound for non-object output)
// After:
const result = await client.callTool({ name: 'get_weather', arguments: { city: 'SF' } });
const sc = result.structuredContent;
const temp = typeof sc === 'object' && sc !== null && !Array.isArray(sc) ? (sc as Record<string, unknown>).temperature : undefined;
```

Source-breaking fix — property access on `Tool.inputSchema` / `Tool.outputSchema` keyword field values also needs narrowing:

```typescript
const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
const properties = tool.inputSchema.properties;
if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) {
const propertyNames = Object.keys(properties);
}
```

Behavior notes:

- A server returning array/primitive `structuredContent` automatically also emits a serialized `TextContent` block (old-client interop). No action required.
- Built-in validators reject non-same-document `$ref`/`$dynamicRef` (SSRF) and over-budget schemas (composition DoS). Use `resolveExternalSchemaRefs(schema, { allowlist })` to fetch and inline approved external refs before validation, or use a custom `jsonSchemaValidator` to
change validator behavior.

## 16. Migration Steps (apply in this order)

1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages
2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport`
Expand All @@ -566,6 +606,7 @@ Validator behavior:
8. If using server SSE transport, migrate to Streamable HTTP
9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to external IdP/OAuth library
10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true`
11. Format the changed files with the project's formatter (`prettier --write`, `eslint --fix`, or `biome format --write`) — edits are not reformatted automatically, and the wrapped schemas (step 5) and rewritten `setRequestHandler` method strings (section 9) frequently need it to
11. If you read properties off `Tool.inputSchema`, `Tool.outputSchema`, or `result.structuredContent`, add narrowing guards (section 15)
12. Format the changed files with the project's formatter (`prettier --write`, `eslint --fix`, or `biome format --write`) — edits are not reformatted automatically, and the wrapped schemas (step 5) and rewritten `setRequestHandler` method strings (section 9) frequently need it to
satisfy lint
12. Verify: build with `tsc` / run tests
13. Verify: build with `tsc` / run tests
51 changes: 46 additions & 5 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -978,11 +978,9 @@ You do not need to install or import validator packages for the default behavior
If you want to customize the **built-in** backend (for example, pre-register schemas by `$id`, register custom AJV formats, or change the `@cfworker/json-schema` draft), import the named class from the explicit subpath and pass an instance through `jsonSchemaValidator`:

```typescript
import { Ajv } from 'ajv';
import addFormats from 'ajv-formats';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
import { Ajv2020, addFormats, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';

const ajv = new Ajv({ strict: true, allErrors: true });
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);

const server = new McpServer(
Expand All @@ -1009,10 +1007,53 @@ const server = new McpServer(
(both subpaths are also available on `@modelcontextprotocol/client/validators/...`)

If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the
subpath in some files and rely on the default in others.
subpath in some files and rely on the default in others. For AJV customization, use the re-exported `Ajv2020` class; a plain `Ajv` instance uses draft-07 semantics and will not validate JSON Schema 2020-12 keywords such as `prefixItems` the same way as MCP's default validator.

For compatibility with existing draft-07 tuple schemas, the built-in validators using the default 2020-12 dialect normalize legacy `items: [...]` plus `additionalItems` syntax to the equivalent 2020-12 `prefixItems`/`items` form before compiling. New schemas should use
`prefixItems` directly.

To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above.

### Tool schemas conform to JSON Schema 2020-12; `structuredContent` may be any JSON value (SEP-2106)

Per [SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2106-json-schema-2020-12.md), tool schemas are no longer restricted to the `type`/`properties`/`required` subset, and a tool's structured output may be any JSON value:

- **`inputSchema`** still requires `type: "object"` at the root (tool arguments are always objects), but may now use any JSON Schema 2020-12 keyword alongside it — composition (`oneOf`/`anyOf`/`allOf`/`not`), conditional (`if`/`then`/`else`), references
(`$ref`/`$defs`/`$anchor`), etc.
- **`outputSchema`** may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions. It is no longer restricted to `type: "object"`.
- **`structuredContent`** may now be any JSON value (object, array, string, number, boolean, or null), not just an object.

**Source-breaking type change.** `CallToolResult.structuredContent` widened from `{ [key: string]: unknown }` to `unknown`. Property access without a narrowing guard no longer type-checks (the previous type was inaccurate whenever a tool returned a non-object):

```typescript
// Before (v1): compiled, but was a lie for non-object output
const temp = result.structuredContent?.temperature;
Comment thread
claude[bot] marked this conversation as resolved.

// After (v2): narrow before property access
const sc = result.structuredContent;
if (typeof sc === 'object' && sc !== null && !Array.isArray(sc)) {
const temp = (sc as Record<string, unknown>).temperature;
}
```

The generated `Tool.inputSchema` and `Tool.outputSchema` types also widened to reflect full JSON Schema 2020-12. Keyword fields such as `properties`, `required`, and analogous `outputSchema` fields now have broad JSON values. Narrow the keyword field value before using it:

```typescript
const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
const properties = tool.inputSchema.properties;
if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) {
const propertyNames = Object.keys(properties);
}
```

**Stronger server-side typing.** When a tool declares an `outputSchema`, `registerTool` now type-checks the handler's returned `structuredContent` against the schema's inferred output type at compile time — a mismatch is a type error rather than a runtime-only failure.

**Old-client interoperability.** A server that returns array or primitive `structuredContent` will automatically also emit a `TextContent` block containing the serialized JSON, so pre-SEP clients that only understand object-typed `structuredContent` can fall back to the text
content. Object `structuredContent` (and results that already include a text block) are left unchanged.

**Security.** The built-in validators never dereference non-same-document `$ref`/`$dynamicRef` (anything not beginning with `#`) — such schemas are rejected rather than fetched, preventing SSRF. If you intentionally need external references, resolve and inline them before
validation with `resolveExternalSchemaRefs(schema, { allowlist })`. Schemas exceeding a generous depth / subschema-count bound are also rejected to prevent composition-based validation DoS. Supply your own `jsonSchemaValidator` implementation if you need different behavior.

## Unchanged APIs

The following APIs are unchanged between v1 and v2 (only the import paths changed):
Expand Down
10 changes: 0 additions & 10 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,6 @@ server.registerTool(
);
```

> [!NOTE]
> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`:
>
> ```ts
> type BmiResult = { bmi: number }; // assignable
> interface BmiResult { bmi: number } // type error
> ```
>
> Alternatively, spread the value: `structuredContent: { ...result }`.

### `ResourceLink` outputs

Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need:
Expand Down
8 changes: 6 additions & 2 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,12 @@ async function callTool_structuredOutput(client: Client) {
});

// Machine-readable output for the client application
if (result.structuredContent) {
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
const structuredContent = result.structuredContent;
if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) {
const bmi = (structuredContent as Record<string, unknown>).bmi;
if (typeof bmi === 'number') {
console.log(bmi); // e.g. 22.86
}
}
//#endregion callTool_structuredOutput
}
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
8 changes: 6 additions & 2 deletions packages/client/src/client/client.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,12 @@ async function Client_callTool_structuredOutput(client: Client) {
});

// Machine-readable output for the client application
if (result.structuredContent) {
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
const structuredContent = result.structuredContent;
if (typeof structuredContent === 'object' && structuredContent !== null && !Array.isArray(structuredContent)) {
const bmi = (structuredContent as Record<string, unknown>).bmi;
if (typeof bmi === 'number') {
console.log(bmi);
}
}
//#endregion Client_callTool_structuredOutput
}
Expand Down
Loading
Loading