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
6 changes: 6 additions & 0 deletions .changeset/sep-2106-output-safety-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': minor
---

Type-check `structuredContent` returned by `McpServer.registerTool` handlers against the declared `outputSchema`. Bound schema depth and subschema count in the built-in validators, and reject non-local references before compilation; custom validators retain control of their own reference and resource policies.
11 changes: 8 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1456,12 +1456,17 @@ drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves
The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020`
instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas
declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`.
Built-in validators also reject non-local `$ref` / `$dynamicRef` values and schemas over
the SDK's depth or subschema-count limits before compilation. Same-document references
continue to work; supply a custom validator or Ajv instance when you need a different
reference or resource policy.

`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External
`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile,
surfaced per-tool on `callTool`).
`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now).
`McpServer.registerTool()` now infers the handler's successful `structuredContent` from
`outputSchema`; narrow or annotate dynamically produced values before returning them.
`isError: true` results and tools without an `outputSchema` remain unrestricted.

| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
Expand Down
5 changes: 5 additions & 0 deletions packages/core-internal/src/validators/ajvProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Ajv as Draft7Ajv } from 'ajv';
import { Ajv2020 } from 'ajv/dist/2020.js';
import _addFormats from 'ajv-formats';

import { assertSchemaSafeToCompile } from './schemaBounds';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';

/**
Expand Down Expand Up @@ -113,6 +114,10 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
);
}

if (!this._userAjv) {
assertSchemaSafeToCompile(schema);
}

const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
Expand Down
2 changes: 2 additions & 0 deletions packages/core-internal/src/validators/cfWorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Validator } from '@cfworker/json-schema';

import { assertSchemaSafeToCompile } from './schemaBounds';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';

/**
Expand Down Expand Up @@ -91,6 +92,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
}

const draft = this.draft ?? '2020-12';
assertSchemaSafeToCompile(schema);
// Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible
const validator = new Validator(schema as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);

Expand Down
118 changes: 118 additions & 0 deletions packages/core-internal/src/validators/schemaBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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('#');
}

const DATA_VALUE_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties']);

/**
* 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;
const visited = new WeakSet<object>();
const active = new WeakSet<object>();
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 (node === null || typeof node !== 'object') {
return;
}
if (active.has(node)) {
throw new Error('JSON Schema contains a cyclic object graph; refusing to compile.');
}
if (visited.has(node)) {
return;
}
visited.add(node);
active.add(node);

if (Array.isArray(node)) {
for (const item of node) {
walk(item, depth + 1);
}
active.delete(node);
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.`
);
}
if (DATA_VALUE_KEYWORDS.has(key)) {
continue;
}
if (SCHEMA_MAP_KEYWORDS.has(key) && value !== null && typeof value === 'object' && !Array.isArray(value)) {
for (const child of Object.values(value)) {
walk(child, depth + 1);
}
continue;
}
walk(value, depth + 1);
}
active.delete(node);
};

walk(schema, 0);
}
103 changes: 103 additions & 0 deletions packages/core-internal/test/validators/schemaBounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* 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';

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();
});

it('ignores $ref-like object fields inside data-valued JSON Schema keywords', () => {
const schema = {
type: 'object',
properties: {
payload: {
type: 'object',
const: { $ref: 'https://data.example/const-value' },
default: { $ref: 'https://data.example/default-value' },
enum: [{ $ref: 'https://data.example/enum-value' }],
examples: [{ $ref: 'https://data.example/example-value' }]
}
}
};

expect(() => assertSchemaSafeToCompile(schema)).not.toThrow();
});

it('still rejects non-local refs in property schemas whose instance name matches a data keyword', () => {
const schema = {
type: 'object',
properties: {
default: { $ref: 'https://evil.example/schema.json' }
}
};

expect(() => assertSchemaSafeToCompile(schema)).toThrow(/non-local/i);
});
});

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<string, unknown> = { 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();
});

it('rejects a cyclic object graph without recursing indefinitely', () => {
const schema: Record<string, unknown> = { type: 'object' };
schema.self = schema;
expect(() => assertSchemaSafeToCompile(schema)).toThrow(/cyclic object graph/i);
});
});
});
34 changes: 34 additions & 0 deletions packages/core-internal/test/validators/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,40 @@ describe('Missing dependencies', () => {
});
});

describe('schema compilation safety', () => {
describe.each(validators)('$name', ({ provider }) => {
it('rejects non-local references before compiling', () => {
const schema = {
type: 'object',
properties: { value: { $ref: 'https://schemas.example.com/value.json' } }
} as JsonSchemaType;
expect(() => provider.getValidator(schema)).toThrow(/non-local/i);
});

it('continues to compile same-document references', () => {
const schema = {
type: 'object',
$defs: { Value: { type: 'string' } },
properties: { value: { $ref: '#/$defs/Value' } }
} as JsonSchemaType;
expect(() => provider.getValidator(schema)).not.toThrow();
});
});

it('allows caller-supplied Ajv-compatible engines to own reference policy', () => {
const validate = Object.assign(
vi.fn(() => true),
{ errors: undefined }
);
const custom = new AjvJsonSchemaValidator({
compile: vi.fn(() => validate),
getSchema: vi.fn(() => undefined),
errorsText: vi.fn(() => '')
});
expect(() => custom.getValidator({ $ref: 'https://schemas.example.com/value.json' } as JsonSchemaType)).not.toThrow();
});
});

/**
* SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers
* validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './se
export type {
AnyToolHandler,
BaseToolCallback,
CallToolResultWithStructuredContent,
CompleteResourceTemplateCallback,
ListResourcesCallback,
PromptCallback,
Expand Down
Loading
Loading