Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/typed-tool-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Check `registerTool` callback results against the inferred `outputSchema` type. Successful callbacks with an output schema must return matching, non-undefined `structuredContent`; error and input-required results remain supported. This catches incompatible output at compile time for Standard Schema providers and deprecated raw Zod shapes, while retaining runtime validation. Previously accepted callbacks that omit structured output or return the wrong type now produce a TypeScript error.
6 changes: 5 additions & 1 deletion docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
When you supply an `outputSchema` with an inferred output type, TypeScript checks that successful callbacks return matching `structuredContent`. In this example, a string, a missing `price`, or a non-numeric `price` produces a compiler error. Successful output cannot be `undefined`; `null` is valid when the schema permits it. Results with `isError: true` and [input-required results](input-required.md) do not need matching structured output.

The callback returns the schema's output type, including when coercion or defaults make its input and output types differ. Output validation does not replace the returned value with parsed or transformed data.

The SDK also 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. Runtime validation remains necessary for JavaScript callers and constraints TypeScript cannot check, such as numeric ranges.

Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings:

Expand Down
3 changes: 3 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@
"@modelcontextprotocol/vitest-config": "workspace:^",
"@types/eventsource": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"@valibot/to-json-schema": "catalog:devTools",
"arktype": "catalog:devTools",
"eslint": "catalog:devTools",
"eslint-config-prettier": "catalog:devTools",
"eslint-plugin-n": "catalog:devTools",
Expand All @@ -156,6 +158,7 @@
"tsdown": "catalog:devTools",
"typescript": "catalog:devTools",
"typescript-eslint": "catalog:devTools",
"valibot": "catalog:devTools",
"vitest": "catalog:devTools"
}
}
65 changes: 53 additions & 12 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,10 @@ export class McpServer {
/**
* Registers a tool with a config object and callback.
*
* When `outputSchema` is supplied, successful callbacks must return defined
* `structuredContent` matching the schema's inferred output type. Error and
* input-required results are exempt; runtime validation still applies.
*
* @example
* ```ts source="./mcp.examples.ts#McpServer_registerTool_basic"
* server.registerTool(
Expand All @@ -950,7 +954,10 @@ export class McpServer {
* );
* ```
*/
registerTool<OutputArgs extends StandardSchemaWithJSON, InputArgs extends StandardSchemaWithJSON | undefined = undefined>(
registerTool<
OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
InputArgs extends StandardSchemaWithJSON | undefined = undefined
>(
name: string,
config: {
title?: string;
Expand All @@ -961,7 +968,7 @@ export class McpServer {
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
cb: ToolCallback<InputArgs, NoInfer<OutputArgs>>
): 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<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined>(
Expand All @@ -975,7 +982,7 @@ export class McpServer {
icons?: Icon[];
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
cb: LegacyToolCallback<InputArgs, NoInfer<OutputArgs>>
): RegisteredTool;
registerTool(
name: string,
Expand Down Expand Up @@ -1218,13 +1225,32 @@ export type ZodRawShape = Record<string, z.ZodType>;
/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
export type InferRawShape<S extends ZodRawShape> = z.infer<z.ZodObject<S>>;

/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (
args: InferRawShape<Args>,
ctx: ServerContext
) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>
: (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>;
/**
* {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}.
* When an output schema is supplied, successful results must include matching
* `structuredContent`; error and input-required results remain available.
*/
export type LegacyToolCallback<
Args extends ZodRawShape | undefined,
OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined
> = BaseToolCallback<
[OutputArgs] extends [ZodRawShape | StandardSchemaWithJSON]
?
| (CallToolResult & {
structuredContent: (OutputArgs extends ZodRawShape
? InferRawShape<OutputArgs>
: OutputArgs extends StandardSchemaWithJSON
? StandardSchemaWithJSON.InferOutput<OutputArgs>
: never) &
(NonNullable<unknown> | null);
isError?: false;
})
| (CallToolResult & { isError: true })
| InputRequiredResult
: CallToolResult | InputRequiredResult,
ServerContext,
Args extends ZodRawShape ? z.ZodObject<Args> : undefined
>;

/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
Expand All @@ -1244,9 +1270,24 @@ export type BaseToolCallback<

/**
* Callback for a tool handler registered with {@linkcode McpServer.registerTool}.
* The second type parameter links a supplied output schema to successful
* `structuredContent` while preserving the broad result type when omitted.
* The defined-value intersection excludes `undefined` even for an `unknown`
* schema output, while preserving `null` (`Exclude<unknown, undefined>` would not).
*/
export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback<
CallToolResult | InputRequiredResult,
export type ToolCallback<
Args extends StandardSchemaWithJSON | undefined = undefined,
OutputArgs extends StandardSchemaWithJSON | undefined = undefined
> = BaseToolCallback<
[OutputArgs] extends [StandardSchemaWithJSON]
?
| (CallToolResult & {
structuredContent: StandardSchemaWithJSON.InferOutput<OutputArgs> & (NonNullable<unknown> | null);
isError?: false;
})
| (CallToolResult & { isError: true })
| InputRequiredResult
: CallToolResult | InputRequiredResult,
ServerContext,
Args
>;
Expand Down
136 changes: 122 additions & 14 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } 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, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index';
import type { InferRawShape } from '../../src/server/mcp';
import { inputRequired, McpServer } from '../../src/index';
import type { InferRawShape, ToolCallback } from '../../src/server/mcp';
import { completable } from '../../src/server/completable';

describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
Expand Down Expand Up @@ -128,22 +131,127 @@ describe('InferRawShape', () => {
});
});

describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () => {
it('accepts z.array(z.number()) as outputSchema and a number[] structuredContent compiles', () => {
describe('registerTool output schema callback typing', () => {
it('requires successful structuredContent to match object output schemas', () => {
const server = new McpServer({ name: 's', version: '1' });
server.registerTool('arr', { inputSchema: z.object({ n: z.number() }), outputSchema: z.array(z.number()) }, async ({ n }) => ({

const outputSchema = z.object({ data: z.string(), count: z.number() });
server.registerTool('object-valid', { outputSchema }, () => ({
content: [],
structuredContent: { data: 'ok', count: 1 }
}));

// @ts-expect-error structuredContent must match outputSchema
server.registerTool('object-wrong-root', { outputSchema }, async () => ({ content: [], structuredContent: 'wrong' }));
// @ts-expect-error structuredContent must include every required output field
server.registerTool('object-missing-field', { outputSchema }, () => ({ content: [], structuredContent: { data: 'missing' } }));
// prettier-ignore
// @ts-expect-error structuredContent field types must match outputSchema
server.registerTool('object-wrong-field', { outputSchema }, () => ({ content: [], structuredContent: { data: 'wrong', count: 'one' } }));
// @ts-expect-error successful callbacks with outputSchema must return structuredContent
server.registerTool('object-missing-output', { outputSchema }, () => ({ content: [] }));
// @ts-expect-error undefined is treated as absent by runtime output validation
server.registerTool('undefined-output', { outputSchema: z.undefined() }, () => ({ content: [], structuredContent: undefined }));
});

it('supports every non-object output root without widening invalid results', () => {
const server = new McpServer({ name: 's', version: '1' });

server.registerTool('array-valid', { outputSchema: z.array(z.number()) }, () => ({ content: [], structuredContent: [1, 2] }));
// @ts-expect-error array output schema rejects a string
server.registerTool('array-invalid', { outputSchema: z.array(z.number()) }, () => ({ content: [], structuredContent: 'wrong' }));

server.registerTool('primitive-valid', { outputSchema: z.string() }, async () => ({ content: [], structuredContent: 'ok' }));
// @ts-expect-error primitive output schema rejects a number
server.registerTool('primitive-invalid', { outputSchema: z.string() }, async () => ({ content: [], structuredContent: 1 }));

const unionOutput = z.union([z.string(), z.number()]);
server.registerTool('union-valid', { outputSchema: unionOutput }, () => ({ content: [], structuredContent: 1 }));
// @ts-expect-error union output schema rejects values outside the union
server.registerTool('union-invalid', { outputSchema: unionOutput }, () => ({ content: [], structuredContent: false }));

server.registerTool('null-valid', { outputSchema: z.null() }, () => ({ content: [], structuredContent: null }));
// @ts-expect-error null output schema rejects non-null values
server.registerTool('null-invalid', { outputSchema: z.null() }, () => ({ content: [], structuredContent: 'not-null' }));

server.registerTool('unknown-valid', { outputSchema: z.unknown() }, () => ({ content: [], structuredContent: null }));
// @ts-expect-error runtime treats undefined structuredContent as absent even when the schema output is unknown
server.registerTool('unknown-undefined', { outputSchema: z.unknown() }, () => ({ content: [], structuredContent: undefined }));
});

it('preserves input inference and exceptional result branches', () => {
const server = new McpServer({ name: 's', version: '1' });
const inputSchema = z.object({ succeed: z.boolean() });
const outputSchema = z.object({ data: z.string() });

server.registerTool('mixed-valid', { inputSchema, outputSchema }, async ({ succeed }) => {
expectTypeOf(succeed).toEqualTypeOf<boolean>();
return succeed ? { content: [], structuredContent: { data: 'ok' } } : { content: [], isError: true };
});
server.registerTool('input-required', { outputSchema }, () => inputRequired({ requestState: 'opaque' }));

// @ts-expect-error an invalid success branch cannot hide beside a valid error branch
server.registerTool('mixed-invalid', { inputSchema, outputSchema }, ({ succeed }) =>
succeed ? { content: [], structuredContent: { data: 1 } } : { content: [], isError: true }
);
});

it('retains broad callbacks when outputSchema is absent or optional', () => {
const server = new McpServer({ name: 's', version: '1' });
const inputSchema = z.object({ value: z.string() });
const callback: ToolCallback<typeof inputSchema> = ({ value }) => ({ content: [], structuredContent: value.length });

server.registerTool('no-output-schema', { inputSchema }, callback);

const maybeOutputSchema = (enabled: boolean): typeof inputSchema | undefined => (enabled ? inputSchema : undefined);
server.registerTool('optional-output-schema', { outputSchema: maybeOutputSchema(false) }, () => ({
content: [],
structuredContent: { value: 'ok' }
}));
});

it('uses schema output types for coercing schemas', () => {
const server = new McpServer({ name: 's', version: '1' });
const outputSchema = z.coerce.number();

server.registerTool('coerce-valid', { outputSchema }, () => ({ content: [], structuredContent: 1 }));
// @ts-expect-error callbacks return the schema output type, not its broader input type
server.registerTool('coerce-invalid', { outputSchema }, () => ({ content: [], structuredContent: '1' }));
});

it('checks deprecated raw output shapes and preserves raw input inference', () => {
const server = new McpServer({ name: 's', version: '1' });
const inputSchema = { n: z.number() };
const outputSchema = { result: z.string() };

server.registerTool('raw-valid', { inputSchema, outputSchema }, ({ n }) => {
expectTypeOf(n).toEqualTypeOf<number>();
return { content: [], structuredContent: { result: String(n) } };
});
// prettier-ignore
// @ts-expect-error raw output shape is inferred as its object output type
server.registerTool('raw-invalid', { inputSchema, outputSchema }, ({ n }) => ({ content: [], structuredContent: { result: n } }));
});

it('checks ArkType and Valibot output schemas in the server typecheck target', () => {
const server = new McpServer({ name: 's', version: '1' });

const arkOutput = type({ result: 'string' });
server.registerTool('ark-output-valid', { outputSchema: arkOutput }, () => ({
content: [],
structuredContent: [n, n + 1] satisfies number[]
structuredContent: { result: 'ok' }
}));
// 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 () => ({
// prettier-ignore
// @ts-expect-error ArkType output schema rejects the wrong field type
server.registerTool('ark-output-invalid', { outputSchema: arkOutput }, () => ({ content: [], structuredContent: { result: 1 } }));

const valibotOutput = toStandardJsonSchema(v.object({ result: v.string() }));
server.registerTool('valibot-output-valid', { outputSchema: valibotOutput }, () => ({
content: [],
structuredContent: 'not-an-array' // compiles: structuredContent is `unknown`
structuredContent: { result: 'ok' }
}));
expectTypeOf<number[]>().toMatchTypeOf<z.infer<ReturnType<typeof z.array<z.ZodNumber>>>>();
// prettier-ignore
// @ts-expect-error Valibot output schema rejects a missing required field
server.registerTool('valibot-output-invalid', { outputSchema: valibotOutput }, () => ({ content: [], structuredContent: {} }));
});
});
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading