From b1b9efb2af3aa47fc85010346eb4cba8958d7133 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 11:13:29 +0000 Subject: [PATCH] fix(server): type registerTool structuredContent from outputSchema A tool's outputSchema now types its callback's successful result: structuredContent is required and must match the schema, for the Standard Schema overload and the deprecated raw-shape overload. isError results, input-required results and schema-less tools keep their existing types. Types only; runtime behaviour is unchanged. The required value satisfies both sides of the schema, because the server validates and sends the handler's raw value while tools/list advertises the output-side JSON Schema. undefined is excluded because the runtime treats it as absent; an unknown schema output accepts any value. The raw-shape overload moves ahead of the Standard Schema overload so overload errors point at structuredContent. A gate on config lets it match only configs that use a raw shape, so optional or any-typed schemas keep resolving as before. Closes #2754. --- .changeset/register-tool-output-typing.md | 14 + docs/servers/tools.md | 6 +- packages/server/package.json | 7 +- packages/server/src/index.ts | 6 +- packages/server/src/server/mcp.ts | 141 +++++++-- .../server/lazyToolSchemaConversion.test.ts | 17 +- .../server/test/server/mcp.compat.test.ts | 18 +- .../server/registerToolOutputTyping.test.ts | 268 ++++++++++++++++++ pnpm-lock.yaml | 9 + .../references/server-registration.md | 21 +- test/e2e/scenarios/standard-schema.test.ts | 4 +- test/e2e/scenarios/tools.test.ts | 13 +- test/integration/test/server/mcp.test.ts | 58 ++-- 13 files changed, 504 insertions(+), 78 deletions(-) create mode 100644 .changeset/register-tool-output-typing.md create mode 100644 packages/server/test/server/registerToolOutputTyping.test.ts diff --git a/.changeset/register-tool-output-typing.md b/.changeset/register-tool-output-typing.md new file mode 100644 index 0000000000..5b4c90381b --- /dev/null +++ b/.changeset/register-tool-output-typing.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/server': minor +--- + +`McpServer.registerTool` now type-checks a callback's successful result against the tool's `outputSchema`, for Standard Schema and deprecated raw-shape schemas. A successful result must include `structuredContent` that matches the schema and is not `undefined`; `isError: true` results, input-required results and tools without an `outputSchema` are unchanged. Runtime output validation is unchanged. + +New exported types: `ToolResultFor`, `ToolStructuredContent`, `ToolResultWithStructuredContent`, `ToolErrorResult`. `ToolCallback` takes an optional second type parameter for the output schema. + +Code that compiled before can now fail to compile, for a tool with an `outputSchema`, when the callback: + +- returns `structuredContent` that does not match the schema, or a successful result without it; +- builds the value from a wider type than the schema (e.g. `string` for an enum); +- has its result typed as plain `CallToolResult` (a `Promise` annotation, a helper returning `CallToolResult`, or `ToolCallback`); use `ToolCallback` or drop the annotation; +- is registered with an `outputSchema` typed `S | undefined`, which is checked as if present. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index ac158d7301..26a666920c 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -116,7 +116,11 @@ server.registerTool( ); ``` -The SDK validates `structuredContent` against `outputSchema` before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too. +TypeScript checks the callback against `outputSchema`: in this example, returning a string, leaving out `price`, or returning `price: '24'` is a compile error, and so is a successful result with no `structuredContent`. Results with `isError: true` and [input-required results](input-required.md) are exempt. `structuredContent: undefined` counts as missing; `null` is fine when the schema allows it. + +The SDK validates the value you return and sends it as is, without the parsed result. So the type TypeScript asks for is one that satisfies both the input and the output side of the schema: fields with a `.default()` must be supplied, coerced fields must already have the output type, and a `.transform()` whose output is not a valid input cannot be satisfied at all. Values built from wider types, such as a `string` that should be one of an enum's members, need narrowing or a `satisfies` annotation before you return them. + +The SDK also validates `structuredContent` against `outputSchema` at runtime before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too. The runtime check is still the one that counts: it covers JavaScript callers, values cast with `as`, and constraints TypeScript cannot express, such as `.min()`, `.email()` or `.refine()`. Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: diff --git a/packages/server/package.json b/packages/server/package.json index b61acddc68..8682d531b5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -168,8 +168,6 @@ "devDependencies": { "@opentelemetry/api": "catalog:otel", "@cfworker/json-schema": "catalog:runtimeShared", - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", @@ -178,6 +176,10 @@ "@modelcontextprotocol/vitest-config": "workspace:^", "@types/eventsource": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", + "@valibot/to-json-schema": "catalog:devTools", + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", + "arktype": "catalog:devTools", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", @@ -186,6 +188,7 @@ "tsdown": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", + "valibot": "catalog:devTools", "vitest": "catalog:devTools" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 32605dd7e7..fa733a18ba 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -33,7 +33,11 @@ export type { RegisteredResourceTemplate, RegisteredTool, ResourceMetadata, - ToolCallback + ToolCallback, + ToolErrorResult, + ToolResultFor, + ToolResultWithStructuredContent, + ToolStructuredContent } from './server/mcp'; export { McpServer, ResourceTemplate } from './server/mcp'; // Runtime-neutral Bearer authentication for web-standard hosts; the Express diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..6f38085330 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1029,6 +1029,21 @@ export class McpServer { return 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( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + } & RawShapeOverloadGate, + cb: LegacyToolCallback<[InputArgs] extends [never] ? undefined : InputArgs, OutputArgs> + ): RegisteredTool; /** * Registers a tool with a config object and callback. * @@ -1055,7 +1070,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -1068,22 +1086,7 @@ export class McpServer { scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, - 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( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - icons?: Icon[]; - scopeChallenge?: ScopeChallengeHandler; - _meta?: Record; - }, - cb: LegacyToolCallback + cb: ToolCallback ): RegisteredTool; registerTool( name: string, @@ -1097,7 +1100,7 @@ export class McpServer { scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, - cb: ToolCallback | LegacyToolCallback + cb: ToolCallbackImplementation ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -1115,7 +1118,7 @@ export class McpServer { undefined, scopeChallenge, _meta, - cb as ToolCallback + cb as AnyToolHandler ); } @@ -1345,13 +1348,88 @@ export type ZodRawShape = Record; /** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ export type InferRawShape = z.infer>; +/** + * The `structuredContent` value a tool handler must return for a given `outputSchema`. + * + * The SDK validates the handler's value against `outputSchema` and then sends **that value** + * (not the parsed result), while `tools/list` advertises the schema's output-side JSON Schema. + * The value therefore has to satisfy both sides of the schema: it is the schema's output type + * when that type is also a valid input (the common case, including defaults and coercion), and + * the intersection of input and output otherwise — so a transforming schema whose output is not + * a valid input cannot be satisfied by accident. `undefined` is excluded because the runtime + * treats `structuredContent: undefined` as absent; `null` is kept when the schema allows it. + * When the schema's output type is `unknown` (an untyped `fromJsonSchema(...)`, `z.unknown()`), + * any value is accepted, so forwarded `unknown` values type-check; the key is still required. + * A branded output (Zod `.brand()`) requires a branded value, as brands are meant to. + * + * @typeParam Output - the tool's `outputSchema`: a Standard Schema or a {@linkcode ZodRawShape}. + */ +export type ToolStructuredContent = Output extends StandardSchemaWithJSON + ? unknown extends StandardSchemaWithJSON.InferOutput + ? unknown + : ([StandardSchemaWithJSON.InferOutput] extends [StandardSchemaWithJSON.InferInput] + ? StandardSchemaWithJSON.InferOutput + : StandardSchemaWithJSON.InferInput & StandardSchemaWithJSON.InferOutput) & + (NonNullable | null) + : Output extends ZodRawShape + ? [z.output>] extends [z.input>] + ? z.output> + : z.input> & z.output> + : never; + +/** + * The successful result of a tool that declares an `outputSchema`: `structuredContent` is + * required and typed by {@linkcode ToolStructuredContent}. + */ +export type ToolResultWithStructuredContent = { + // `Omit` would collapse CallToolResult's known keys into its index signature; key remapping keeps them. + [K in keyof CallToolResult as K extends 'content' | 'structuredContent' | 'isError' ? never : K]: CallToolResult[K]; +} & { + content: CallToolResult['content']; + structuredContent: T; + isError?: false; +}; + +/** A tool error result. Exempt from `outputSchema` validation, so `structuredContent` is unconstrained. */ +export type ToolErrorResult = CallToolResult & { isError: true }; + +/** + * Maps a tool's declared `outputSchema` to the {@link CallToolResult} its handler may return. + * + * With an `outputSchema`, a successful result must carry matching `structuredContent` + * ({@linkcode ToolResultWithStructuredContent}); `isError: true` results are unconstrained. + * Without one, any {@link CallToolResult} is allowed. A schema typed `S | undefined` also allows + * any result here, but `registerTool`'s optional `outputSchema` property drops the `undefined` + * when inferring, so a forwarded optional schema is checked as if present. + * Input-required results are added by {@linkcode ToolCallback} and are never constrained. + * + * @typeParam Output - the tool's `outputSchema`: a Standard Schema, a {@linkcode ZodRawShape}, or `undefined`. + */ +export type ToolResultFor = [Output] extends [ + StandardSchemaWithJSON | ZodRawShape +] + ? ToolResultWithStructuredContent> | ToolErrorResult + : 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>; + +/** Lets the deprecated raw-shape `registerTool` overload match only configs that use a raw shape; others fall through to the Standard Schema overload. */ +export type RawShapeOverloadGate = 0 extends (1 & InputArgs) | (1 & OutputArgs) + ? { readonly __standardSchemaOverload: never } + : [InputArgs] extends [never] + ? [OutputArgs] extends [StandardSchemaWithJSON | undefined] + ? { readonly __standardSchemaOverload: never } + : unknown + : unknown; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape @@ -1371,12 +1449,21 @@ export type BaseToolCallback< /** * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. + * + * `registerTool` passes the tool's `outputSchema` as `Output`, so a successful result's + * `structuredContent` is checked against the schema at compile time (see {@linkcode ToolResultFor}). + * Runtime validation still runs and remains authoritative. */ -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>; + +type ToolCallbackImplementation = + | ToolCallback + | ToolCallback + | LegacyToolCallback + | LegacyToolCallback; /** * Tool handler callback type. diff --git a/packages/server/test/server/lazyToolSchemaConversion.test.ts b/packages/server/test/server/lazyToolSchemaConversion.test.ts index 073ce16f87..3f1cc4eb51 100644 --- a/packages/server/test/server/lazyToolSchemaConversion.test.ts +++ b/packages/server/test/server/lazyToolSchemaConversion.test.ts @@ -59,7 +59,10 @@ describe('lazy tool schema conversion', () => { const output = counted(z.object({ b: z.number() })); const server = new McpServer({ name: 'lazy', version: '0' }); - server.registerTool('t', { inputSchema: input.schema, outputSchema: output.schema }, async () => ({ content: [] })); + server.registerTool('t', { inputSchema: input.schema, outputSchema: output.schema }, async () => ({ + content: [], + structuredContent: { b: 1 } + })); expect(input.calls).toEqual({ input: 0, output: 0 }); expect(output.calls).toEqual({ input: 0, output: 0 }); @@ -86,7 +89,7 @@ describe('lazy tool schema conversion', () => { it('outputSchemaJson converts on first read and memoises', () => { const output = counted(z.object({ b: z.number() })); const server = new McpServer({ name: 'lazy', version: '0' }); - const tool = server.registerTool('t', { outputSchema: output.schema }, async () => ({ content: [] })); + const tool = server.registerTool('t', { outputSchema: output.schema }, async () => ({ content: [], structuredContent: { b: 1 } })); expect(output.calls.output).toBe(0); const first = tool.outputSchemaJson; @@ -99,7 +102,10 @@ describe('lazy tool schema conversion', () => { it('update({ outputSchema }) replaces outputSchemaJson', () => { const server = new McpServer({ name: 'lazy', version: '0' }); - const tool = server.registerTool('t', { outputSchema: z.object({ b: z.number() }) }, async () => ({ content: [] })); + const tool = server.registerTool('t', { outputSchema: z.object({ b: z.number() }) }, async () => ({ + content: [], + structuredContent: { b: 1 } + })); expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); const replacement = counted(z.object({ c: z.string() })); @@ -135,7 +141,10 @@ describe('lazy tool schema conversion', () => { const server = new McpServer({ name: 'lazy', version: '0' }); const inputSchema = z.object({ a: z.string().describe('first'), n: z.number().optional() }); const outputSchema = z.object({ ok: z.boolean() }); - server.registerTool('t', { title: 'T', description: 'd', inputSchema, outputSchema }, async () => ({ content: [] })); + server.registerTool('t', { title: 'T', description: 'd', inputSchema, outputSchema }, async () => ({ + content: [], + structuredContent: { ok: true } + })); server.registerTool('bare', {}, async () => ({ content: [] })); const { body } = await listTools(server); 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/packages/server/test/server/registerToolOutputTyping.test.ts b/packages/server/test/server/registerToolOutputTyping.test.ts new file mode 100644 index 0000000000..24e4ac5bac --- /dev/null +++ b/packages/server/test/server/registerToolOutputTyping.test.ts @@ -0,0 +1,268 @@ +/** + * Compile-time contract for `McpServer.registerTool` handler results (SEP-2106 / #2754). + * + * These are type-level tests: every `@ts-expect-error` line must fail to compile and every + * other registration must compile. `pnpm typecheck` enforces both. The runtime assertions only + * pin the authoring affordances the types rely on (content auto-fill). + */ +import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { CallToolResult, ServerContext, ToolCallback, ToolResultFor, ToolStructuredContent } from '../../src/index'; +import { fromJsonSchema, inputRequired, McpServer } from '../../src/index'; + +const server = new McpServer({ name: 's', version: '1' }); + +describe('registerTool: structuredContent is typed from outputSchema', () => { + it('rejects the exact #2754 repro', () => { + // The issue's snippet verbatim, with the callback opened on the registerTool line: tsc reports + // overload errors at the call and tsgo at the callback, so one line keeps both compilers honest. + // prettier-ignore + // @ts-expect-error structuredContent is a string but outputSchema is an object + server.registerTool('ping', { description: 'Returns a string pong with a timestamp', outputSchema: z.object({ data: z.string() }) }, async () => { + const str = `Pong at ${new Date().toISOString()}`; + return { + content: [{ type: 'text', text: str }], + structuredContent: str + }; + }); + + server.registerTool('ping-ok', { outputSchema: z.object({ data: z.string() }) }, async () => { + const str = `Pong at ${new Date().toISOString()}`; + return { content: [{ type: 'text', text: str }], structuredContent: { data: str } }; + }); + }); + + it('checks object fields, missing fields and missing structuredContent', () => { + const outputSchema = z.object({ data: z.string(), count: z.number() }); + server.registerTool('obj', { outputSchema }, () => ({ content: [], structuredContent: { data: 'ok', count: 1 } })); + + // @ts-expect-error missing required field + server.registerTool('obj-missing-field', { outputSchema }, () => ({ content: [], structuredContent: { data: 'x' } })); + // @ts-expect-error wrong field type + server.registerTool('obj-wrong-field', { outputSchema }, () => ({ content: [], structuredContent: { data: 'x', count: '1' } })); + // @ts-expect-error successful results must carry structuredContent when outputSchema is declared + server.registerTool('obj-no-structured', { outputSchema }, () => ({ content: [{ type: 'text', text: 'no structured' }] })); + // @ts-expect-error runtime treats undefined structuredContent as absent + server.registerTool('obj-undefined', { outputSchema }, () => ({ content: [], structuredContent: undefined })); + // @ts-expect-error isError: false is a success and is validated + server.registerTool('obj-false-error', { outputSchema }, () => ({ content: [], isError: false })); + }); + + it('works with inputSchema, sync and async handlers', () => { + const inputSchema = z.object({ n: z.number() }); + const outputSchema = z.object({ doubled: z.number() }); + server.registerTool('sync', { inputSchema, outputSchema }, ({ n }) => { + expectTypeOf(n).toEqualTypeOf(); + return { content: [], structuredContent: { doubled: n * 2 } }; + }); + server.registerTool('async', { inputSchema, outputSchema }, async ({ n }) => ({ + content: [], + structuredContent: { doubled: n * 2 } + })); + // @ts-expect-error wrong type with an inputSchema present too + server.registerTool('async-bad', { inputSchema, outputSchema }, async ({ n }) => ({ + content: [], + structuredContent: { doubled: `${n}` } + })); + }); + + it('supports non-object output roots (SEP-2106)', () => { + server.registerTool('arr', { outputSchema: z.array(z.number()) }, () => ({ content: [], structuredContent: [1, 2] })); + // @ts-expect-error array schema rejects a string + server.registerTool('arr-bad', { outputSchema: z.array(z.number()) }, () => ({ content: [], structuredContent: 'nope' })); + server.registerTool('str', { outputSchema: z.string() }, () => ({ content: [], structuredContent: 'ok' })); + // @ts-expect-error string schema rejects a number + server.registerTool('str-bad', { outputSchema: z.string() }, () => ({ content: [], structuredContent: 1 })); + server.registerTool('nul', { outputSchema: z.null() }, () => ({ content: [], structuredContent: null })); + server.registerTool('unk', { outputSchema: z.unknown() }, () => ({ content: [], structuredContent: null })); + // An unknown output accepts any value, so forwarded `unknown` values type-check. The cost: a + // literal `undefined` here is left to the runtime "no structured content" check. + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error an optional root still requires a defined value at runtime + server.registerTool('opt-undef', { outputSchema: z.string().optional() }, () => ({ content: [], structuredContent: undefined })); + server.registerTool('opt-val', { outputSchema: z.string().optional() }, () => ({ content: [], structuredContent: 'x' })); + }); + + it('requires values valid on both sides of the schema (defaults, coercion, transforms)', () => { + // Defaults: the advertised (output-side) JSON Schema requires `n`, so the handler must supply it. + const withDefault = z.object({ n: z.number().default(0) }); + expectTypeOf>().toEqualTypeOf<{ n: number }>(); + // @ts-expect-error the value is sent unparsed, so the default is never applied on the wire + server.registerTool('default-missing', { outputSchema: withDefault }, () => ({ content: [], structuredContent: {} })); + + // Coercion: input is unknown, output is number — a number satisfies both. + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error a string passes coercion at runtime but does not match the advertised schema + server.registerTool('coerce-str', { outputSchema: z.coerce.number() }, () => ({ content: [], structuredContent: '1' })); + + // Transforms: the runtime validates and sends the raw value; a string → number transform has + // no value that is valid input AND matches the output-side schema. + const transformed = z.string().transform(s => s.length); + expectTypeOf>().toEqualTypeOf(); + }); + + it('allows isError results and input-required results without structuredContent', () => { + const inputSchema = z.object({ ok: z.boolean() }); + const outputSchema = z.object({ data: z.string() }); + server.registerTool('err', { outputSchema }, () => ({ content: [{ type: 'text', text: 'failed' }], isError: true })); + server.registerTool('err-any-structured', { outputSchema }, () => ({ + content: [], + isError: true, + structuredContent: 'diagnostic' + })); + server.registerTool('mixed', { inputSchema, outputSchema }, async ({ ok }) => + ok ? { content: [], structuredContent: { data: 'ok' } } : { content: [], isError: true } + ); + server.registerTool('ir', { outputSchema }, () => inputRequired({ requestState: 'opaque' })); + + // prettier-ignore + // @ts-expect-error a bad success branch cannot hide behind a valid error branch + server.registerTool('mixed-bad', { inputSchema, outputSchema }, ({ ok }) => (ok ? { content: [], structuredContent: { data: 1 } } : { content: [], isError: true })); + }); + + it('requires content on every result, typed or not', () => { + const outputSchema = z.object({ data: z.string() }); + // @ts-expect-error content is required on typed successes too + server.registerTool('no-content', { outputSchema }, () => ({ structuredContent: { data: 'x' } })); + // The rest of CallToolResult keeps its types on the typed branch. + server.registerTool('meta', { outputSchema }, () => ({ content: [], _meta: { trace: 'abc' }, structuredContent: { data: 'x' } })); + // @ts-expect-error content blocks are still checked + server.registerTool('bad-block', { outputSchema }, () => ({ content: [{ type: 'bogus' }], structuredContent: { data: 'x' } })); + // @ts-expect-error content is required without an outputSchema + server.registerTool('schemaless-no-content', {}, () => ({ structuredContent: 'x' })); + // @ts-expect-error content is required on error results + server.registerTool('err-no-content', { outputSchema }, () => ({ isError: true })); + }); + + it('leaves schema-less tools unchanged', () => { + server.registerTool('none', {}, () => ({ content: [], structuredContent: 'anything' })); + server.registerTool('none-input', { inputSchema: z.object({ v: z.string() }) }, ({ v }) => ({ + content: [], + structuredContent: v.length + })); + expectTypeOf>().toEqualTypeOf(); + // A schema that is only possibly present stays broad when spelled out explicitly... + expectTypeOf>().toEqualTypeOf(); + // ...but `outputSchema?:` absorbs `| undefined` at the call site, so registerTool checks it as present. + const maybe = (on: boolean): z.ZodObject<{ a: z.ZodString }> | undefined => (on ? z.object({ a: z.string() }) : undefined); + server.registerTool('maybe', { outputSchema: maybe(false) }, () => ({ content: [], structuredContent: { a: 'x' } })); + // @ts-expect-error possibly-absent schema is checked as if present + server.registerTool('maybe-bad', { outputSchema: maybe(false) }, () => ({ content: [], structuredContent: 42 })); + }); + + it('is provider-agnostic (any StandardSchemaWithJSON)', () => { + const objectOut = {} as StandardSchemaWithJSON<{ answer: string }, { answer: string }>; + server.registerTool('std', { outputSchema: objectOut }, () => ({ content: [], structuredContent: { answer: 'ok' } })); + // @ts-expect-error provider-agnostic: wrong field type + server.registerTool('std-bad', { outputSchema: objectOut }, () => ({ content: [], structuredContent: { answer: 1 } })); + + const unknownOut = {} as StandardSchemaWithJSON; + server.registerTool('std-unknown', { outputSchema: unknownOut }, () => ({ content: [], structuredContent: { any: 'thing' } })); + // @ts-expect-error unknown output still needs a defined structuredContent + server.registerTool('std-unknown-missing', { outputSchema: unknownOut }, () => ({ content: [] })); + }); + + it('works with ArkType and Valibot output schemas', () => { + const ark = type({ result: 'string' }); + server.registerTool('ark', { outputSchema: ark }, () => ({ content: [], structuredContent: { result: 'ok' } })); + // @ts-expect-error ArkType: wrong field type + server.registerTool('ark-bad', { outputSchema: ark }, () => ({ content: [], structuredContent: { result: 1 } })); + + const valibot = toStandardJsonSchema(v.object({ result: v.string() })); + server.registerTool('valibot', { outputSchema: valibot }, () => ({ content: [], structuredContent: { result: 'ok' } })); + // @ts-expect-error Valibot: missing required field + server.registerTool('valibot-bad', { outputSchema: valibot }, () => ({ content: [], structuredContent: {} })); + }); + + it('covers the deprecated raw-shape overload', () => { + const inputSchema = { n: z.number() }; + const outputSchema = { result: z.string() }; + server.registerTool('raw', { inputSchema, outputSchema }, ({ n }) => { + expectTypeOf(n).toEqualTypeOf(); + return { content: [], structuredContent: { result: String(n) } }; + }); + // @ts-expect-error raw output shape: wrong field type + server.registerTool('raw-bad', { inputSchema, outputSchema }, ({ n }) => ({ content: [], structuredContent: { result: n } })); + // Raw input shape with a Standard Schema output. + server.registerTool('raw-in-std-out', { inputSchema, outputSchema: z.array(z.number()) }, ({ n }) => ({ + content: [], + structuredContent: [n] + })); + // @ts-expect-error raw input shape with a Standard Schema output: wrong root + server.registerTool('raw-in-std-out-bad', { inputSchema, outputSchema: z.array(z.number()) }, ({ n }) => ({ + content: [], + structuredContent: n + })); + server.registerTool('raw-err', { inputSchema, outputSchema }, () => ({ content: [], isError: true })); + }); + + it('keeps every config the base overloads accepted compiling', () => { + // A forwarded, possibly-undefined raw input shape (v1 wrapper idiom). + const maybeRawInput = (on: boolean): { n: z.ZodNumber } | undefined => (on ? { n: z.number() } : undefined); + server.registerTool('fwd-raw-in', { inputSchema: maybeRawInput(true) }, args => ({ + content: [{ type: 'text', text: String(args.n) }] + })); + const config: { inputSchema?: { n: z.ZodNumber } } = {}; + server.registerTool('cfg-raw-in', config, () => ({ content: [] })); + + // A raw output shape typed as possibly undefined, or as a union with a Standard Schema. + const maybeRawOutput = (on: boolean): { r: z.ZodString } | undefined => (on ? { r: z.string() } : undefined); + server.registerTool('fwd-raw-out', { outputSchema: maybeRawOutput(true) }, () => ({ content: [], structuredContent: { r: 'x' } })); + const rawOrStd = (on: boolean): { r: z.ZodString } | z.ZodObject<{ r: z.ZodString }> => + on ? { r: z.string() } : z.object({ r: z.string() }); + server.registerTool('raw-or-std-out', { outputSchema: rawOrStd(true) }, () => ({ content: [], structuredContent: { r: 'x' } })); + + // An `any`-typed schema resolves to the Standard Schema overload as on main (not the deprecated + // raw-shape one, which would type args as Record and reject this annotation). + const anySchema = z.object({ city: z.string() }) as any; // eslint-disable-line @typescript-eslint/no-explicit-any + server.registerTool('any-in', { inputSchema: anySchema }, async (args: { city: string }) => ({ + content: [], + _meta: { city: args.city } + })); + + // No inputSchema: the callback still receives only the context. + server.registerTool('ctx-only', {}, ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return { content: [] }; + }); + }); + + it('accepts an unknown-typed value for an untyped fromJsonSchema output', () => { + const forwarded: unknown = JSON.parse('{"a":1}'); + server.registerTool('json-out', { outputSchema: fromJsonSchema({ type: 'object' }) }, () => ({ + content: [], + structuredContent: forwarded + })); + // @ts-expect-error structuredContent is still required on success + server.registerTool('json-out-missing', { outputSchema: fromJsonSchema({ type: 'object' }) }, () => ({ content: [] })); + }); + + it('types standalone callbacks via ToolCallback', () => { + const inputSchema = z.object({ q: z.string() }); + const outputSchema = z.object({ hits: z.number() }); + const cb: ToolCallback = ({ q }) => ({ + content: [], + structuredContent: { hits: q.length } + }); + server.registerTool('standalone', { inputSchema, outputSchema }, cb); + // @ts-expect-error standalone callback: wrong structuredContent + const bad: ToolCallback = () => ({ content: [], structuredContent: { hits: 'x' } }); + void bad; + // A result typed as plain CallToolResult no longer fits a tool with an outputSchema (documented break). + // prettier-ignore + // @ts-expect-error CallToolResult has optional, untyped structuredContent + server.registerTool('widened', { inputSchema, outputSchema }, async (): Promise => ({ content: [], structuredContent: { hits: 1 } })); + // Branded outputs require branded values. + const branded = z.object({ id: z.string().brand<'Id'>() }); + // @ts-expect-error a plain string is not a branded Id + server.registerTool('brand', { outputSchema: branded }, () => ({ content: [], structuredContent: { id: 'x' } })); + // The one-argument form stays broad (unchanged for existing annotations). + const broad: ToolCallback = () => ({ content: [], structuredContent: 'anything' }); + void broad; + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a095e50e80..6d677340d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1923,12 +1923,18 @@ importers: '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20260327.2 + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) ajv: specifier: catalog:runtimeShared version: 8.18.0 ajv-formats: specifier: catalog:runtimeShared version: 3.0.1(ajv@8.18.0) + arktype: + specifier: catalog:devTools + version: 2.2.0 eslint: specifier: catalog:devTools version: 9.39.4 @@ -1953,6 +1959,9 @@ importers: typescript-eslint: specifier: catalog:devTools version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md index 898c80caad..c00fc4dbd6 100644 --- a/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md @@ -29,6 +29,23 @@ server.registerTool('ping', { description: 'Liveness check' }, async ctx => ({ c A one-parameter callback typechecks under either reading, so remember that the first parameter here is the context object, not an args object. +#### `outputSchema` types the callback's `structuredContent` + +When a tool declares `outputSchema`, `registerTool` now checks the callback's successful +result against it. Earlier v2 releases typed `structuredContent` as `unknown`, and v1 as +`{ [k: string]: unknown }`; neither connected it to `outputSchema`. Code that compiled +before can now fail to compile when it: + +- returns a `structuredContent` that does not match the schema (wrong root, missing field, wrong field type); +- returns a successful result with no `structuredContent` (the server already rejected this at runtime); +- builds the value from a wider type, e.g. a `string` where the schema has an enum. Narrow it, or annotate it with `satisfies`; +- types the callback's result as plain `CallToolResult`, e.g. a `Promise` return annotation, a helper returning `CallToolResult`, or a callback annotated `ToolCallback`. Drop the annotation, or use `ToolCallback` / `ToolResultFor`; +- passes an `outputSchema` typed as possibly `undefined` (a registry forwarding an optional schema). TypeScript drops the `undefined` at the call site, so the callback is checked as if the schema were present. + +`isError: true` results, input-required results, and tools without an `outputSchema` +are unchanged. Runtime validation is unchanged and still authoritative. A test fixture +that deliberately returns invalid output can cast the result: `() => ({ ... }) as never`. + #### Standard Schema objects (raw shapes deprecated) v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) @@ -95,8 +112,8 @@ and fails at the first `tools/list`** (the quiet runtime path above). With `~standard.jsonSchema` (zod 4.0–4.1, and zod 3.25.x via the `zod/v4` subpath) — the same code **runs** through the bundled fallback but **fails to compile**: `registerTool`/`registerPrompt` reject the schema with `TS2769: No overload matches -this call` listing both overloads. The real cause is buried in the first overload's -elaboration — `Property 'jsonSchema' is missing in type …` (that property is +this call` listing both overloads. The real cause is buried in the Standard Schema +overload's elaboration (listed first for `registerPrompt`, last for `registerTool`) — `Property 'jsonSchema' is missing in type …` (that property is `~standard.jsonSchema`, added in zod 4.2.0) — and a follow-on implicit-`any` error on the handler's arguments usually appears below it. If you see that two-overload error on a registration call with a zod schema, check the installed zod version before anything diff --git a/test/e2e/scenarios/standard-schema.test.ts b/test/e2e/scenarios/standard-schema.test.ts index a8f3406577..0b1fe719bc 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 b21c997820..517f5daa5f 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -98,12 +98,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' }] diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 964499b9c3..969a3bb181 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1385,15 +1385,17 @@ describe('Zod v4', () => { resultType: z.string() }) }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ] - }) + // Deliberately bypass the compile-time output check to exercise the runtime guard. + async ({ input }) => + ({ + // Only return content without structuredContent + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) as never ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -1507,25 +1509,27 @@ describe('Zod v4', () => { timestamp: z.string() }) }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - }) + // Deliberately bypass the compile-time output check to exercise the runtime guard. + async ({ input }) => + ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + }) + } + ], + structuredContent: { + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema } - ], - structuredContent: { - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - } - }) + }) as never ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();