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
16 changes: 16 additions & 0 deletions .changeset/register-tool-output-typing.md
Original file line number Diff line number Diff line change
@@ -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<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.
8 changes: 7 additions & 1 deletion docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 5 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand All @@ -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",
Expand All @@ -182,6 +184,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
172 changes: 144 additions & 28 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
mattzcarey marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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<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 +1071,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 +1087,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.
): RegisteredTool;
registerTool(
name: string,
Expand All @@ -1097,7 +1101,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 +1119,7 @@ export class McpServer {
undefined,
scopeChallenge,
_meta,
cb as ToolCallback<StandardSchemaWithJSON | undefined>
cb as AnyToolHandler<StandardSchemaWithJSON | undefined>
);
}

Expand Down Expand Up @@ -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()`.
Expand All @@ -1345,13 +1363,101 @@ 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}.
*
* `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<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'];
Comment thread
claude[bot] marked this conversation as resolved.
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>;

/**
* 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.
*/
Comment thread
mattzcarey marked this conversation as resolved.
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 +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<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>;

/** The `registerTool` implementation-signature callback: the union of every overload's callback shape. */
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
Loading
Loading