Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .changeset/sep-2106-json-schema-2020-12.md
Original file line number Diff line number Diff line change
@@ -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<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).
34 changes: 32 additions & 2 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 @@ -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<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 @@ -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<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
27 changes: 20 additions & 7 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 @@ -763,35 +766,45 @@ 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);

// 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
30 changes: 18 additions & 12 deletions packages/core/src/types/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),

/**
Expand Down
37 changes: 19 additions & 18 deletions packages/core/src/types/spec.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading