Skip to content

Commit ea7a9fc

Browse files
committed
feat(core,server,client): implement SEP-2106 JSON Schema 2020-12 tool schemas
Tools' inputSchema/outputSchema now conform to full JSON Schema 2020-12 and structuredContent may be any JSON value (#2192). - outputSchema accepts any 2020-12 schema (arrays/primitives/objects/compositions); inputSchema keeps `type: "object"` but allows all 2020-12 keywords. Updated the generated spec types, the zod schemas, and standardSchemaToJsonSchema (output path no longer forces `type: "object"`). - structuredContent widens from `{ [key: string]: unknown }` to `unknown` (source-breaking for typed consumers). - Stronger typing: new CallToolResultWithStructuredContent<T>; client.callTool<T>() generic (cast-free via overload); registerTool type-checks a handler's structuredContent against its outputSchema's inferred output. - Fix falsy-structuredContent bug (=== undefined) in server + client. - Server auto-emits a serialized TextContent fallback for non-object structuredContent (pre-SEP client interop). - Security: validators reject non-same-document $ref/$dynamicRef (SSRF) and schemas exceeding depth/subschema bounds (composition DoS) via schemaBounds. Adds unit + integration tests, migration docs (migration.md + migration-SKILL.md), and a changeset.
1 parent ab552c3 commit ea7a9fc

21 files changed

Lines changed: 603 additions & 90 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/server': minor
4+
'@modelcontextprotocol/client': minor
5+
---
6+
7+
Implement SEP-2106: tool `inputSchema`/`outputSchema` conform to JSON Schema 2020-12, and `structuredContent` may be any JSON value.
8+
9+
- `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`, …).
10+
- `outputSchema` may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions — instead of being restricted to `type: "object"`.
11+
- `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.
12+
- `client.callTool<T>()` is now generic so callers get a precisely typed `structuredContent` (defaults to `JSONValue`). New `CallToolResultWithStructuredContent<T>` type.
13+
- `McpServer.registerTool` type-checks a handler's returned `structuredContent` against the tool's `outputSchema` inferred output.
14+
- Servers returning array or primitive `structuredContent` automatically also emit a serialized `TextContent` block, so pre-SEP clients can fall back to the text content.
15+
- Built-in validators refuse to dereference non-same-document `$ref`/`$dynamicRef` (SSRF guard) and reject schemas exceeding depth / subschema-count bounds (composition-DoS guard).

docs/migration-SKILL.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,36 @@ Validator behavior:
537537
`@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
538538
- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface.
539539

540-
## 15. Migration Steps (apply in this order)
540+
## 15. JSON Schema 2020-12 Tool Schemas & `structuredContent` (SEP-2106)
541+
542+
Tool schemas conform to full JSON Schema 2020-12, and `structuredContent` may be any JSON value.
543+
544+
| Aspect | v1 / pre-SEP | v2 / SEP-2106 |
545+
| --- | --- | --- |
546+
| `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`) |
547+
| `outputSchema` root | `type: "object"` only | **any** valid JSON Schema 2020-12 (object, array, primitive, composition) |
548+
| `CallToolResult.structuredContent` type | `{ [key: string]: unknown }` | `unknown` (**source-breaking**) |
549+
| `client.callTool(...)` | returns `structuredContent` as object | generic `client.callTool<T>(...)`; `structuredContent` typed as `T` (defaults to `JSONValue`) |
550+
| `registerTool` handler return | `structuredContent` untyped | type-checked against the tool's `outputSchema` inferred output |
551+
552+
Source-breaking fix — property access on `structuredContent` needs a type or a guard:
553+
554+
```typescript
555+
// Before: result.structuredContent?.temperature (compiled, but unsound for non-object output)
556+
// After, recommended:
557+
const result = await client.callTool<{ temperature: number }>({ name: 'get_weather', arguments: { city: 'SF' } });
558+
const temp = result.structuredContent?.temperature; // typed
559+
// After, manual narrowing:
560+
const sc = result.structuredContent;
561+
const temp = sc && typeof sc === 'object' && !Array.isArray(sc) ? (sc as Record<string, unknown>).temperature : undefined;
562+
```
563+
564+
Behavior notes:
565+
566+
- A server returning array/primitive `structuredContent` automatically also emits a serialized `TextContent` block (old-client interop). No action required.
567+
- Built-in validators reject non-same-document `$ref`/`$dynamicRef` (SSRF) and over-budget schemas (composition DoS). Use a custom `jsonSchemaValidator` to change this.
568+
569+
## 16. Migration Steps (apply in this order)
541570

542571
1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages
543572
2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport``NodeStreamableHTTPServerTransport`
@@ -549,4 +578,5 @@ Validator behavior:
549578
8. If using server SSE transport, migrate to Streamable HTTP
550579
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
551580
10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true`
552-
11. Verify: build with `tsc` / run tests
581+
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)
582+
12. Verify: build with `tsc` / run tests

docs/migration.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,37 @@ subpath in some files and rely on the default in others.
977977

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

980+
### Tool schemas conform to JSON Schema 2020-12; `structuredContent` may be any JSON value (SEP-2106)
981+
982+
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:
983+
984+
- **`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.
985+
- **`outputSchema`** may now be **any** valid JSON Schema 2020-12 — objects, arrays, primitives, or compositions. It is no longer restricted to `type: "object"`.
986+
- **`structuredContent`** may now be any JSON value (object, array, string, number, boolean, or null), not just an object.
987+
988+
**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):
989+
990+
```typescript
991+
// Before (v1): compiled, but was a lie for non-object output
992+
const temp = result.structuredContent?.temperature;
993+
994+
// After (v2), option A — narrow yourself:
995+
const sc = result.structuredContent;
996+
if (sc && typeof sc === 'object' && !Array.isArray(sc)) {
997+
const temp = (sc as Record<string, unknown>).temperature;
998+
}
999+
1000+
// After (v2), option B — pass the expected shape to callTool (recommended):
1001+
const result = await client.callTool<{ temperature: number }>({ name: 'get_weather', arguments: { city: 'SF' } });
1002+
const temp = result.structuredContent?.temperature; // typed as number
1003+
```
1004+
1005+
**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.
1006+
1007+
**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.
1008+
1009+
**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.
1010+
9801011
## Unchanged APIs
9811012

9821013
The following APIs are unchanged between v1 and v2 (only the import paths changed):

examples/server/src/mcpServerOutputSchema.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ server.registerTool(
3939
// Parameters are available but not used in this example
4040
void city;
4141
void country;
42-
// Simulate weather API call
42+
// Simulate weather API call. The option arrays are typed so that the values flowing into
43+
// `structuredContent` are checked against `outputSchema` at compile time (per SEP-2106).
4344
const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10;
44-
const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)];
45+
const conditionOptions: Array<'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'> = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'];
46+
const conditions = conditionOptions[Math.floor(Math.random() * conditionOptions.length)] ?? 'sunny';
4547

4648
const structuredContent = {
4749
temperature: {
@@ -52,7 +54,7 @@ server.registerTool(
5254
humidity: Math.round(Math.random() * 100),
5355
wind: {
5456
speed_kmh: Math.round(Math.random() * 50),
55-
direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]
57+
direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] ?? 'N'
5658
}
5759
};
5860

packages/client/src/client/client.examples.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ async function Client_callTool_basic(client: Client) {
102102
*/
103103
async function Client_callTool_structuredOutput(client: Client) {
104104
//#region Client_callTool_structuredOutput
105-
const result = await client.callTool({
105+
const result = await client.callTool<{ bmi: number }>({
106106
name: 'calculate-bmi',
107107
arguments: { weightKg: 70, heightM: 1.75 }
108108
});
109109

110110
// Machine-readable output for the client application
111-
if (result.structuredContent) {
112-
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
111+
if (result.structuredContent !== undefined) {
112+
console.log(result.structuredContent.bmi); // typed as number
113113
}
114114
//#endregion Client_callTool_structuredOutput
115115
}

packages/client/src/client/client.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'
22
import type {
33
BaseContext,
44
CallToolRequest,
5+
CallToolResult,
6+
CallToolResultWithStructuredContent,
57
ClientCapabilities,
68
ClientContext,
79
ClientNotification,
@@ -13,6 +15,7 @@ import type {
1315
JsonSchemaType,
1416
JsonSchemaValidator,
1517
jsonSchemaValidator,
18+
JSONValue,
1619
ListChangedHandlers,
1720
ListChangedOptions,
1821
ListPromptsRequest,
@@ -763,35 +766,45 @@ export class Client extends Protocol<ClientContext> {
763766
* console.log(result.content);
764767
* ```
765768
*
769+
* Per SEP-2106 `structuredContent` may be any JSON value (object, array, string, number,
770+
* boolean, or null). The return type's `structuredContent` defaults to {@linkcode JSONValue};
771+
* pass a type argument to get a precise type for a tool whose output shape you know:
772+
*
766773
* @example Structured output
767774
* ```ts source="./client.examples.ts#Client_callTool_structuredOutput"
768-
* const result = await client.callTool({
775+
* const result = await client.callTool<{ bmi: number }>({
769776
* name: 'calculate-bmi',
770777
* arguments: { weightKg: 70, heightM: 1.75 }
771778
* });
772779
*
773780
* // Machine-readable output for the client application
774-
* if (result.structuredContent) {
775-
* console.log(result.structuredContent); // e.g. { bmi: 22.86 }
781+
* if (result.structuredContent !== undefined) {
782+
* console.log(result.structuredContent.bmi); // typed as number
776783
* }
777784
* ```
778785
*/
779-
async callTool(params: CallToolRequest['params'], options?: RequestOptions) {
786+
callTool<StructuredContent = JSONValue>(
787+
params: CallToolRequest['params'],
788+
options?: RequestOptions
789+
): Promise<CallToolResultWithStructuredContent<StructuredContent>>;
790+
async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise<CallToolResult> {
780791
const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options);
781792

782793
// Check if the tool has an outputSchema
783794
const validator = this.getToolOutputValidator(params.name);
784795
if (validator) {
785-
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
786-
if (!result.structuredContent && !result.isError) {
796+
// If tool has outputSchema, it MUST return structuredContent (unless it's an error).
797+
// Per SEP-2106 structuredContent may be a falsy JSON value (0, false, "", null), so
798+
// check explicitly for `undefined` rather than truthiness.
799+
if (result.structuredContent === undefined && !result.isError) {
787800
throw new ProtocolError(
788801
ProtocolErrorCode.InvalidRequest,
789802
`Tool ${params.name} has an output schema but did not return structured content`
790803
);
791804
}
792805

793806
// Only validate structured content if present (not when there's an error)
794-
if (result.structuredContent) {
807+
if (result.structuredContent !== undefined) {
795808
try {
796809
// Validate the structured content against the schema
797810
const validationResult = validator(result.structuredContent);

packages/core/src/types/schemas.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,25 +1309,27 @@ export const ToolSchema = z.object({
13091309
description: z.string().optional(),
13101310
/**
13111311
* A JSON Schema 2020-12 object defining the expected parameters for the tool.
1312-
* Must have `type: 'object'` at the root level per MCP spec.
1312+
*
1313+
* Tool arguments are always JSON objects, so `type: 'object'` is required at the root.
1314+
* Beyond that, any JSON Schema 2020-12 keyword may appear — composition (`oneOf`/`anyOf`/
1315+
* `allOf`/`not`), conditional (`if`/`then`/`else`), reference (`$ref`/`$defs`/`$anchor`), etc.
13131316
*/
13141317
inputSchema: z
13151318
.object({
1316-
type: z.literal('object'),
1317-
properties: z.record(z.string(), JSONValueSchema).optional(),
1318-
required: z.array(z.string()).optional()
1319+
$schema: z.string().optional(),
1320+
type: z.literal('object')
13191321
})
13201322
.catchall(z.unknown()),
13211323
/**
13221324
* An optional JSON Schema 2020-12 object defining the structure of the tool's output
13231325
* returned in the `structuredContent` field of a `CallToolResult`.
1324-
* Must have `type: 'object'` at the root level per MCP spec.
1326+
*
1327+
* Per SEP-2106 this may be any valid JSON Schema 2020-12 — objects, arrays, primitives,
1328+
* or compositions. It is no longer restricted to `type: 'object'` at the root.
13251329
*/
13261330
outputSchema: z
13271331
.object({
1328-
type: z.literal('object'),
1329-
properties: z.record(z.string(), JSONValueSchema).optional(),
1330-
required: z.array(z.string()).optional()
1332+
$schema: z.string().optional()
13311333
})
13321334
.catchall(z.unknown())
13331335
.optional(),
@@ -1374,11 +1376,15 @@ export const CallToolResultSchema = ResultSchema.extend({
13741376
content: z.array(ContentBlockSchema).default([]),
13751377

13761378
/**
1377-
* An object containing structured tool output.
1379+
* A JSON value containing structured tool output.
1380+
*
1381+
* If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON value that matches the schema.
13781382
*
1379-
* If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema.
1383+
* Per SEP-2106 this may be any JSON value (object, array, string, number, boolean, or null),
1384+
* not just an object. Servers returning a non-object value SHOULD also emit a `TextContent`
1385+
* block with the serialized JSON so pre-SEP clients can fall back to the text content.
13801386
*/
1381-
structuredContent: z.record(z.string(), z.unknown()).optional(),
1387+
structuredContent: z.unknown().optional(),
13821388

13831389
/**
13841390
* Whether the tool call ended in an error.
@@ -1563,7 +1569,7 @@ export const ToolResultContentSchema = z.object({
15631569
type: z.literal('tool_result'),
15641570
toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'),
15651571
content: z.array(ContentBlockSchema).default([]),
1566-
structuredContent: z.object({}).loose().optional(),
1572+
structuredContent: z.unknown().optional(),
15671573
isError: z.boolean().optional(),
15681574

15691575
/**

packages/core/src/types/spec.types.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,9 +1550,12 @@ export interface CallToolResult extends Result {
15501550
content: ContentBlock[];
15511551

15521552
/**
1553-
* An optional JSON object that represents the structured result of the tool call.
1553+
* An optional JSON value that represents the structured result of the tool call.
1554+
*
1555+
* This can be any JSON value (object, array, string, number, boolean, or null)
1556+
* that conforms to the tool's outputSchema if one is defined.
15541557
*/
1555-
structuredContent?: { [key: string]: unknown };
1558+
structuredContent?: unknown;
15561559

15571560
/**
15581561
* Whether the tool call ended in an error.
@@ -1734,13 +1737,16 @@ export interface Tool extends BaseMetadata, Icons {
17341737

17351738
/**
17361739
* A JSON Schema object defining the expected parameters for the tool.
1740+
*
1741+
* Tool arguments are always JSON objects, so `type: "object"` is required at the root.
1742+
* Beyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including
1743+
* composition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords
1744+
* (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other
1745+
* standard validation or annotation keywords.
1746+
*
1747+
* Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided.
17371748
*/
1738-
inputSchema: {
1739-
$schema?: string;
1740-
type: 'object';
1741-
properties?: { [key: string]: JSONValue };
1742-
required?: string[];
1743-
};
1749+
inputSchema: { $schema?: string; type: 'object'; [key: string]: unknown };
17441750

17451751
/**
17461752
* Execution-related properties for this tool.
@@ -1749,17 +1755,11 @@ export interface Tool extends BaseMetadata, Icons {
17491755

17501756
/**
17511757
* An optional JSON Schema object defining the structure of the tool's output returned in
1752-
* the structuredContent field of a {@link CallToolResult}.
1758+
* the structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.
17531759
*
17541760
* Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided.
1755-
* Currently restricted to `type: "object"` at the root level.
17561761
*/
1757-
outputSchema?: {
1758-
$schema?: string;
1759-
type: 'object';
1760-
properties?: { [key: string]: JSONValue };
1761-
required?: string[];
1762-
};
1762+
outputSchema?: { $schema?: string; [key: string]: unknown };
17631763

17641764
/**
17651765
* Optional additional tool information.
@@ -2454,11 +2454,12 @@ export interface ToolResultContent {
24542454
content: ContentBlock[];
24552455

24562456
/**
2457-
* An optional structured result object.
2457+
* An optional structured result value.
24582458
*
2459+
* This can be any JSON value (object, array, string, number, boolean, or null).
24592460
* If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema.
24602461
*/
2461-
structuredContent?: { [key: string]: unknown };
2462+
structuredContent?: unknown;
24622463

24632464
/**
24642465
* Whether the tool use resulted in an error.

0 commit comments

Comments
 (0)