diff --git a/.changeset/register-tool-output-typing.md b/.changeset/register-tool-output-typing.md new file mode 100644 index 0000000000..c7afaa24cc --- /dev/null +++ b/.changeset/register-tool-output-typing.md @@ -0,0 +1,16 @@ +--- +'@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. + +Successful typed results may omit `content`. `McpServer` then sends the JSON of `structuredContent` as a text block (previously a content-less result was sent with `content: []`). + +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..ec3afdf77e 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -116,7 +116,13 @@ 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()`. + +When `outputSchema` is set you may leave out `content`; the server then sends the JSON of `structuredContent` as a single text block, so clients that ignore `structuredContent` still see the result. Supply your own `content` to control that text. Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: diff --git a/packages/server/package.json b/packages/server/package.json index e147d8b516..1d6facdb4e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -164,8 +164,6 @@ }, "devDependencies": { "@cfworker/json-schema": "catalog:runtimeShared", - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", @@ -174,6 +172,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", @@ -182,6 +184,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..44bb43317c 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -307,12 +307,13 @@ export class McpServer { const result = await this.executeToolHandler(tool, args, ctx); await this.validateToolOutput(tool, result, request.params.name); if (isInputRequiredResult(result)) return result; + const completed = withStructuredContentText(result); // SEP-2106 result-side projection (the era-agnostic TextContent auto-append; the // `{result:…}` wrap on the 2025 era) lives behind the wire codec's // `projectCallToolResult`. The codec receives the SAME advertised JSON Schema // `tools/list` emits (and that the codec's `encodeResult('tools/list', …)` may have // wrapped) so the listing and the call cannot diverge. - return this.server.projectCallToolResult(result, tool.outputSchemaJson); + return this.server.projectCallToolResult(completed, tool.outputSchemaJson); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -1029,6 +1030,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 +1071,10 @@ export class McpServer { * ); * ``` */ - registerTool( + registerTool< + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, + InputArgs extends StandardSchemaWithJSON | undefined = undefined + >( name: string, config: { title?: string; @@ -1068,22 +1087,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 +1101,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 +1119,7 @@ export class McpServer { undefined, scopeChallenge, _meta, - cb as ToolCallback + cb as AnyToolHandler ); } @@ -1335,6 +1339,20 @@ export class ResourceTemplate { } } +/** + * Gives a tool result that omits `content` a text block holding the JSON of its + * `structuredContent`, so clients that only read `content` still see the result (the spec asks + * tools returning structured content to also return it serialized as text). A result that + * supplies `content`, even an empty array, is left alone. + */ +function withStructuredContentText(result: CallToolResult): CallToolResult { + // `content` is required by the type but optional for typed successes (ToolResultWithStructuredContent). + if ((result as { content?: unknown }).content !== undefined || result.structuredContent === undefined) { + return result; + } + return { ...result, content: [{ type: 'text', text: JSON.stringify(result.structuredContent) }] }; +} + /** * A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by * `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`. @@ -1345,13 +1363,101 @@ 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}. + * + * `content` is optional here: when it is omitted, the server sends the JSON of + * `structuredContent` as a single text block. Supply your own `content` to control what + * clients that ignore `structuredContent` see. + */ +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>; + +/** + * Gate for the deprecated raw-shape `registerTool` overload. That overload sits ahead of the + * Standard Schema overload, so TypeScript reports the Standard Schema overload's error (the + * useful one). It must therefore match only configs that really use a raw shape: an inferred raw + * `inputSchema`, or no `inputSchema` and an `outputSchema` that may be a raw shape. Everything + * else, including `any`-typed schemas, gets an unsatisfiable required key and falls through. + * + * The gate is on `config`, not `cb`: a `never` callback type would make TypeScript check the + * callback without a contextual type and reuse that widened type for the next 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 +1477,22 @@ 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>; + +/** The `registerTool` implementation-signature callback: the union of every overload's callback shape. */ +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..6cf7d361cd 100644 --- a/packages/server/test/server/lazyToolSchemaConversion.test.ts +++ b/packages/server/test/server/lazyToolSchemaConversion.test.ts @@ -59,7 +59,7 @@ 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 () => ({ structuredContent: { b: 1 } })); expect(input.calls).toEqual({ input: 0, output: 0 }); expect(output.calls).toEqual({ input: 0, output: 0 }); @@ -86,7 +86,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 () => ({ structuredContent: { b: 1 } })); expect(output.calls.output).toBe(0); const first = tool.outputSchemaJson; @@ -99,7 +99,7 @@ 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 () => ({ structuredContent: { b: 1 } })); expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); const replacement = counted(z.object({ c: z.string() })); @@ -135,7 +135,9 @@ 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 () => ({ + 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..5a4059a467 --- /dev/null +++ b/packages/server/test/server/registerToolOutputTyping.test.ts @@ -0,0 +1,299 @@ +/** + * 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'; +import { invoke } from '../../src/server/invoke'; + +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 ? { structuredContent: { data: 1 } } : { content: [], isError: true })); + }); + + it('lets typed successes omit content (the server fills it in)', () => { + const outputSchema = z.object({ data: z.string() }); + server.registerTool('no-content', { outputSchema }, () => ({ structuredContent: { data: 'x' } })); + // The rest of CallToolResult keeps its types on the typed branch. + server.registerTool('meta', { outputSchema }, () => ({ _meta: { trace: 'abc' }, structuredContent: { data: 'x' } })); + // @ts-expect-error content blocks are still checked when supplied + server.registerTool('bad-block', { outputSchema }, () => ({ content: [{ type: 'bogus' }], structuredContent: { data: 'x' } })); + // Schema-less and error results keep the CallToolResult contract (content required). + // @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 }, () => ({ structuredContent: { result: 'ok' } })); + // @ts-expect-error ArkType: wrong field type + server.registerTool('ark-bad', { outputSchema: ark }, () => ({ structuredContent: { result: 1 } })); + + const valibot = toStandardJsonSchema(v.object({ result: v.string() })); + server.registerTool('valibot', { outputSchema: valibot }, () => ({ structuredContent: { result: 'ok' } })); + // @ts-expect-error Valibot: missing required field + server.registerTool('valibot-bad', { outputSchema: valibot }, () => ({ 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) }, () => ({ 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) }, () => ({ 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' }) }, () => ({ 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 }, () => ({ structuredContent: { id: 'x' } })); + // The one-argument form stays broad (unchanged for existing annotations). + const broad: ToolCallback = () => ({ content: [], structuredContent: 'anything' }); + void broad; + }); +}); + +describe('registerTool: runtime affordances the types rely on', () => { + const call = async (s: McpServer, name: string): Promise => { + const response = await invoke( + s, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } }, + { classification: { era: 'legacy' } } + ); + return ((await response.json()) as { result: CallToolResult }).result; + }; + + it('sends structuredContent as a JSON text block when content is omitted', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('obj', { outputSchema: z.object({ data: z.string() }) }, () => ({ structuredContent: { data: 'x' } })); + s.registerTool('arr', { outputSchema: z.array(z.number()) }, () => ({ structuredContent: [1, 2] })); + + const obj = await call(s, 'obj'); + expect(obj.isError).toBeUndefined(); + expect(obj.content).toEqual([{ type: 'text', text: '{"data":"x"}' }]); + expect(obj.structuredContent).toEqual({ data: 'x' }); + + const arr = await call(s, 'arr'); + expect(arr.isError).toBeUndefined(); + expect(arr.content).toEqual([{ type: 'text', text: '[1,2]' }]); + + // Explicit content, even empty, is left alone. + s.registerTool('empty', { outputSchema: z.object({ data: z.string() }) }, () => ({ + content: [], + structuredContent: { data: 'x' } + })); + expect((await call(s, 'empty')).content).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 060f3c4b42..ef7d888af5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1843,12 +1843,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 @@ -1873,6 +1879,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.9.1)(@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/behavioral-changes.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md index e12d54a800..0611462148 100644 --- a/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md @@ -197,9 +197,10 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see - **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An inbound result without `content` defaults to `[]` (deployed servers omit it alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is - unchanged and era-independent: the TypeScript surface requires `content` on handler - results, and a content-less handler result is normalized to `content: []` before it - reaches the wire. One sharpening remains: a content-less body carrying another + era-independent: the TypeScript surface requires `content` on handler results, except + successful results of a `registerTool` tool with an `outputSchema`, which may omit it. + `McpServer` gives such a result a JSON text block of its `structuredContent`; any other + content-less handler result is normalized to `content: []` before it reaches the wire. One sharpening remains: a content-less body carrying another result family's vocabulary (a task handle or an `input_required` round) is still rejected loudly — tolerance never turns a different result kind into a silent empty success. A body whose only foreign key was `resultType` strips to an empty object 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..4dc204cf53 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,25 @@ 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. Successful typed results may leave out `content`; the server then sends +the JSON of `structuredContent` as a text block. 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 +114,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();