From ea5c666c1374b8958c109d30b9bcd088717cd2ae Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 7 Jul 2026 12:08:54 +0100 Subject: [PATCH] fix: complete SEP-2106 output and schema safety --- .changeset/sep-2106-output-safety-followup.md | 6 + docs/migration/upgrade-to-v2.md | 11 +- .../src/validators/ajvProvider.ts | 5 + .../src/validators/cfWorkerProvider.ts | 2 + .../src/validators/schemaBounds.ts | 118 ++++++++++++++++++ .../test/validators/schemaBounds.test.ts | 103 +++++++++++++++ .../test/validators/validators.test.ts | 34 +++++ packages/server/src/index.ts | 1 + packages/server/src/server/mcp.ts | 58 +++++++-- .../server/test/server/mcp.compat.test.ts | 18 +-- test/e2e/scenarios/standard-schema.test.ts | 4 +- test/e2e/scenarios/tools.test.ts | 13 +- 12 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 .changeset/sep-2106-output-safety-followup.md create mode 100644 packages/core-internal/src/validators/schemaBounds.ts create mode 100644 packages/core-internal/test/validators/schemaBounds.test.ts diff --git a/.changeset/sep-2106-output-safety-followup.md b/.changeset/sep-2106-output-safety-followup.md new file mode 100644 index 0000000000..67fb00dea3 --- /dev/null +++ b/.changeset/sep-2106-output-safety-followup.md @@ -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. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..2e42adcee2 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -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 | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/packages/core-internal/src/validators/ajvProvider.ts b/packages/core-internal/src/validators/ajvProvider.ts index 0a24fe4533..70b9044708 100644 --- a/packages/core-internal/src/validators/ajvProvider.ts +++ b/packages/core-internal/src/validators/ajvProvider.ts @@ -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'; /** @@ -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)) diff --git a/packages/core-internal/src/validators/cfWorkerProvider.ts b/packages/core-internal/src/validators/cfWorkerProvider.ts index 8af5ff6393..ca8c369093 100644 --- a/packages/core-internal/src/validators/cfWorkerProvider.ts +++ b/packages/core-internal/src/validators/cfWorkerProvider.ts @@ -10,6 +10,7 @@ import { Validator } from '@cfworker/json-schema'; +import { assertSchemaSafeToCompile } from './schemaBounds'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; /** @@ -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[0], draft, this.shortcircuit); diff --git a/packages/core-internal/src/validators/schemaBounds.ts b/packages/core-internal/src/validators/schemaBounds.ts new file mode 100644 index 0000000000..df2345bd27 --- /dev/null +++ b/packages/core-internal/src/validators/schemaBounds.ts @@ -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(); + const active = new WeakSet(); + 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); +} diff --git a/packages/core-internal/test/validators/schemaBounds.test.ts b/packages/core-internal/test/validators/schemaBounds.test.ts new file mode 100644 index 0000000000..5f32e45944 --- /dev/null +++ b/packages/core-internal/test/validators/schemaBounds.test.ts @@ -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 = { 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 = { type: 'object' }; + schema.self = schema; + expect(() => assertSchemaSafeToCompile(schema)).toThrow(/cyclic object graph/i); + }); + }); +}); diff --git a/packages/core-internal/test/validators/validators.test.ts b/packages/core-internal/test/validators/validators.test.ts index 3318b94d10..8f4b3333e7 100644 --- a/packages/core-internal/test/validators/validators.test.ts +++ b/packages/core-internal/test/validators/validators.test.ts @@ -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 diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index dd720eb954..eae17e9ad3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './se export type { AnyToolHandler, BaseToolCallback, + CallToolResultWithStructuredContent, CompleteResourceTemplateCallback, ListResourcesCallback, PromptCallback, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e0d10b5a8e..ed0f49d229 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -942,7 +942,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -953,7 +956,7 @@ export class McpServer { icons?: Icon[]; _meta?: Record; }, - cb: ToolCallback + cb: ToolCallback ): RegisteredTool; /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ registerTool( @@ -967,7 +970,7 @@ export class McpServer { icons?: Icon[]; _meta?: Record; }, - cb: LegacyToolCallback + cb: LegacyToolCallback ): RegisteredTool; registerTool( name: string, @@ -980,7 +983,9 @@ export class McpServer { icons?: Icon[]; _meta?: Record; }, - cb: ToolCallback | LegacyToolCallback + cb: + | ToolCallback + | LegacyToolCallback ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -998,7 +1003,7 @@ export class McpServer { icons, undefined, _meta, - cb as ToolCallback + cb as ToolCallback ); } @@ -1210,13 +1215,38 @@ export type ZodRawShape = Record; /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ export type InferRawShape = z.infer>; +/** + * A {@link CallToolResult} whose `structuredContent` is typed to a known output shape. + * Used when a tool's declared `outputSchema` lets callers know the structured result type. + */ +export type CallToolResultWithStructuredContent = CallToolResult & { structuredContent: T }; + +/** + * Maps a tool's declared `outputSchema` to the precise {@link CallToolResult} its handler returns. + * + * When an `outputSchema` is present, `structuredContent` is typed to the schema's inferred output, so + * the value the handler returns is checked against the schema at compile time. Tools without an + * `outputSchema` return a plain {@link CallToolResult} whose `structuredContent` may be any JSON value + * (per SEP-2106). + * + * @typeParam Output - the tool's `outputSchema`: a Standard Schema, a {@linkcode ZodRawShape}, or `undefined`. + */ +export type ToolResultFor = Output extends StandardSchemaWithJSON + ? CallToolResultWithStructuredContent> | (CallToolResult & { isError: true }) + : Output extends ZodRawShape + ? CallToolResultWithStructuredContent> | (CallToolResult & { isError: true }) + : CallToolResult; + /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ -export type LegacyToolCallback = Args extends ZodRawShape +export type LegacyToolCallback< + Args extends ZodRawShape | undefined, + Output extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined +> = Args extends ZodRawShape ? ( args: InferRawShape, ctx: ServerContext - ) => CallToolResult | InputRequiredResult | Promise - : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise; + ) => ToolResultFor | InputRequiredResult | Promise | InputRequiredResult> + : (ctx: ServerContext) => ToolResultFor | InputRequiredResult | Promise | InputRequiredResult>; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape @@ -1236,12 +1266,14 @@ export type BaseToolCallback< /** * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. + * + * When the tool declares an `outputSchema`, pass it as `Output` so the handler's returned + * `structuredContent` is checked against the schema's inferred output type at compile time. */ -export type ToolCallback = BaseToolCallback< - CallToolResult | InputRequiredResult, - ServerContext, - Args ->; +export type ToolCallback< + Args extends StandardSchemaWithJSON | undefined = undefined, + Output extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined +> = BaseToolCallback | InputRequiredResult, ServerContext, Args>; /** * Tool handler callback type. diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index ae7a0438b5..afb3c1cae4 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -135,14 +135,18 @@ describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () content: [], structuredContent: [n, n + 1] satisfies number[] })); - // NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is - // captured but does NOT currently flow into the callback's return type — ToolCallback's - // SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so - // a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput) - // is the guard. Tightening the generic is out of this commit's scope. - server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({ + // @ts-expect-error structuredContent must match the declared outputSchema. + server.registerTool('arr-strict', { outputSchema: z.array(z.number()) }, async () => ({ content: [], - structuredContent: 'not-an-array' // compiles: structuredContent is `unknown` + structuredContent: 'not-an-array' + })); + server.registerTool('arr-error', { outputSchema: z.array(z.number()) }, async () => ({ + content: [{ type: 'text', text: 'failed' }], + isError: true + })); + server.registerTool('untyped-output', {}, async () => ({ + content: [], + structuredContent: 'any JSON value remains valid without outputSchema' })); expectTypeOf().toMatchTypeOf>>>(); }); diff --git a/test/e2e/scenarios/standard-schema.test.ts b/test/e2e/scenarios/standard-schema.test.ts index a8f3406577..3d19bd927c 100644 --- a/test/e2e/scenarios/standard-schema.test.ts +++ b/test/e2e/scenarios/standard-schema.test.ts @@ -138,8 +138,8 @@ verifies('standardschema:tool:output-schema-validation', async ({ transport }: T s.registerTool( 'get-server-status-corrupt', { inputSchema: type({}), outputSchema }, - // intentionally nonconforming structuredContent (server-side output validation must reject it) - () => ({ structuredContent: { healthy: 'definitely', uptimeSeconds: 'a while' }, content: [] }) + // Deliberately bypass the compile-time output check to exercise the runtime validation guard. + (() => ({ structuredContent: { healthy: 'definitely', uptimeSeconds: 'a while' }, content: [] })) as never ); return s; }; diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 741665143f..6be054d064 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -91,12 +91,15 @@ function schemaServer(): McpServer { s.registerTool( 'structured-mismatch', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, - // intentionally invalid structuredContent (tests server-side validation rejects it) - () => ({ structuredContent: { value: 'not-a-number' }, content: [] }) + // Deliberately bypass the compile-time output check to exercise the runtime validation guard. + (() => ({ structuredContent: { value: 'not-a-number' }, content: [] })) as never + ); + s.registerTool( + 'structured-missing', + { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, + // Deliberately bypass the compile-time output check to exercise the runtime missing-output guard. + (() => ({ content: [{ type: 'text', text: 'handler-body-no-structured' }] })) as never ); - s.registerTool('structured-missing', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, () => ({ - content: [{ type: 'text', text: 'handler-body-no-structured' }] - })); s.registerTool('structured-error-skip', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, () => ({ isError: true, content: [{ type: 'text', text: 'handler-returned-isError' }]