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
14 changes: 14 additions & 0 deletions .changeset/register-tool-output-typing.md
Original file line number Diff line number Diff line change
@@ -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<CallToolResult>` annotation, a helper returning `CallToolResult`, or `ToolCallback<typeof inputSchema>`); use `ToolCallback<typeof inputSchema, typeof outputSchema>` or drop the annotation;
- is registered with an `outputSchema` typed `S | undefined`, which is checked as if present.
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.
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:

Expand Down
7 changes: 5 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand All @@ -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",
Expand All @@ -186,6 +188,7 @@
"tsdown": "catalog:devTools",
"typescript": "catalog:devTools",
"typescript-eslint": "catalog:devTools",
"valibot": "catalog:devTools",
"vitest": "catalog:devTools"
}
}
6 changes: 5 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 114 additions & 27 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputArgs extends ZodRawShape = never, OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
} & RawShapeOverloadGate<InputArgs, OutputArgs>,
cb: LegacyToolCallback<[InputArgs] extends [never] ? undefined : InputArgs, OutputArgs>
): RegisteredTool;
/**
* Registers a tool with a config object and callback.
*
Expand All @@ -1055,7 +1070,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 @@ -1068,22 +1086,7 @@ export class McpServer {
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
): 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>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
cb: ToolCallback<InputArgs, OutputArgs>
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
): RegisteredTool;
registerTool(
name: string,
Expand All @@ -1097,7 +1100,7 @@ export class McpServer {
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
cb: ToolCallbackImplementation
): RegisteredTool {
if (this._registeredTools[name]) {
throw new Error(`Tool ${name} is already registered`);
Expand All @@ -1115,7 +1118,7 @@ export class McpServer {
undefined,
scopeChallenge,
_meta,
cb as ToolCallback<StandardSchemaWithJSON | undefined>
cb as AnyToolHandler<StandardSchemaWithJSON | undefined>
);
}

Expand Down Expand Up @@ -1345,13 +1348,88 @@ 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>>;

/**
* 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 | ZodRawShape> = Output extends StandardSchemaWithJSON
? unknown extends StandardSchemaWithJSON.InferOutput<Output>
? unknown
: ([StandardSchemaWithJSON.InferOutput<Output>] extends [StandardSchemaWithJSON.InferInput<Output>]
? StandardSchemaWithJSON.InferOutput<Output>
: StandardSchemaWithJSON.InferInput<Output> & StandardSchemaWithJSON.InferOutput<Output>) &
(NonNullable<unknown> | null)
: Output extends ZodRawShape
? [z.output<z.ZodObject<Output>>] extends [z.input<z.ZodObject<Output>>]
? z.output<z.ZodObject<Output>>
: z.input<z.ZodObject<Output>> & z.output<z.ZodObject<Output>>
: never;

/**
* The successful result of a tool that declares an `outputSchema`: `structuredContent` is
* required and typed by {@linkcode ToolStructuredContent}.
*/
export type ToolResultWithStructuredContent<T> = {
// `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 | undefined> = [Output] extends [
StandardSchemaWithJSON | ZodRawShape
]
? ToolResultWithStructuredContent<ToolStructuredContent<Output>> | ToolErrorResult
: CallToolResult;

/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
export type LegacyToolCallback<
Args extends ZodRawShape | undefined,
Output extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined
> = Args extends ZodRawShape
? (
args: InferRawShape<Args>,
ctx: ServerContext
) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>
: (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>;
) => ToolResultFor<Output> | InputRequiredResult | Promise<ToolResultFor<Output> | InputRequiredResult>
: (ctx: ServerContext) => ToolResultFor<Output> | InputRequiredResult | Promise<ToolResultFor<Output> | 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<InputArgs, OutputArgs> = 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 | undefined> = Args extends ZodRawShape
Expand All @@ -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<Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback<
CallToolResult | InputRequiredResult,
ServerContext,
Args
>;
export type ToolCallback<
Args extends StandardSchemaWithJSON | undefined = undefined,
Output extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined
> = BaseToolCallback<ToolResultFor<Output> | InputRequiredResult, ServerContext, Args>;

type ToolCallbackImplementation =
| ToolCallback<StandardSchemaWithJSON | undefined, StandardSchemaWithJSON | undefined>
| ToolCallback<StandardSchemaWithJSON | undefined, StandardSchemaWithJSON>
| LegacyToolCallback<ZodRawShape | undefined, StandardSchemaWithJSON | ZodRawShape | undefined>
| LegacyToolCallback<ZodRawShape | undefined, StandardSchemaWithJSON | ZodRawShape>;

/**
* Tool handler callback type.
Expand Down
17 changes: 13 additions & 4 deletions packages/server/test/server/lazyToolSchemaConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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;
Expand All @@ -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() }));
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 11 additions & 7 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>().toMatchTypeOf<z.infer<ReturnType<typeof z.array<z.ZodNumber>>>>();
});
Expand Down
Loading
Loading