Skip to content
Draft
6 changes: 6 additions & 0 deletions .changeset/register-rawshape-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---

`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`.
41 changes: 41 additions & 0 deletions packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

/* eslint-disable @typescript-eslint/no-namespace */

import * as z from 'zod/v4';

// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)

export interface StandardTypedV1<Input = unknown, Output = Input> {
Expand Down Expand Up @@ -136,6 +138,45 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
return isStandardJSONSchema(schema) && isStandardSchema(schema);
}

function isZodSchema(v: unknown): v is z.ZodType {
if (typeof v !== 'object' || v === null) return false;
if ('_def' in v) return true;
return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod';
}

/**
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
* Zod values are supported.
*
* @internal
*/
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
if (typeof obj !== 'object' || obj === null) return false;
if (isStandardSchema(obj)) return false;
// [].every() is true, so an empty object is a valid raw shape (matches v1).
return Object.values(obj).every(v => isZodSchema(v));
}

/**
* Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape
* `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}.
* Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a
* uniform schema type; already-wrapped schemas pass through unchanged.
*
* @internal
*/
export function normalizeRawShapeSchema(
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
): StandardSchemaWithJSON | undefined {
if (schema === undefined) return undefined;
if (isZodRawShape(schema)) {
return z.object(schema) as StandardSchemaWithJSON;
}
return schema;
}

// JSON Schema conversion

/**
Expand Down
33 changes: 32 additions & 1 deletion packages/core/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
import * as z from 'zod/v4';

import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
import { isZodRawShape, normalizeRawShapeSchema, standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';

describe('isZodRawShape', () => {
test('treats empty object as a raw shape (matches v1)', () => {
expect(isZodRawShape({})).toBe(true);
});
test('detects raw shape with zod fields', () => {
expect(isZodRawShape({ a: z.string() })).toBe(true);
});
test('rejects a Standard Schema instance', () => {
expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false);
});
test('rejects a shape with non-Zod Standard Schema fields', () => {
const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } };
expect(isZodRawShape({ a: nonZod })).toBe(false);
});
});

describe('normalizeRawShapeSchema', () => {
test('wraps empty raw shape into z.object({})', () => {
const wrapped = normalizeRawShapeSchema({});
expect(wrapped).toBeDefined();
expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object');
});
test('passes through an already-wrapped Standard Schema unchanged', () => {
const schema = z.object({ a: z.string() });
expect(normalizeRawShapeSchema(schema)).toBe(schema);
});
test('returns undefined for undefined input', () => {
expect(normalizeRawShapeSchema(undefined)).toBeUndefined();
});
});

describe('standardSchemaToJsonSchema', () => {
test('emits type:object for plain z.object schemas', () => {
Expand Down
74 changes: 71 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
normalizeRawShapeSchema,
promptArgumentsFromStandardSchema,
ProtocolError,
ProtocolErrorCode,
Expand All @@ -39,6 +40,7 @@ import {
validateStandardSchema
} from '@modelcontextprotocol/core';

import type * as z from 'zod/v4';
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
import { getCompleter, isCompletable } from './completable.js';
Expand Down Expand Up @@ -873,6 +875,31 @@ export class McpServer {
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
): RegisteredTool;
Comment thread
claude[bot] marked this conversation as resolved.
/** @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;
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
): RegisteredTool;
Comment on lines +879 to +891
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: the overloads aren't fully orthogonal — inputSchema: z.object({...}) paired with outputSchema: { result: z.string() } (wrapped input + raw output) matches neither overload and yields TS2769, even though the reverse combo (raw input + wrapped output) type-checks via overload 2 and the runtime handles both via normalizeRawShapeSchema. Low-impact since the @deprecated guidance is to wrap anyway, but if you want the changeset's "outputSchema accepts a raw shape" claim to hold unconditionally, widen overload 1's OutputArgs to StandardSchemaWithJSON | ZodRawShape.

Extended reasoning...

What the gap is

registerTool now has two public overload signatures:

inputSchema constraint outputSchema constraint
Overload 1 (native) InputArgs extends StandardSchemaWithJSON | undefined OutputArgs extends StandardSchemaWithJSON
Overload 2 (@deprecated raw-shape) InputArgs extends ZodRawShape OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined

The four input×output combinations resolve as:

  • wrapped + wrapped → overload 1 ✓
  • raw + raw → overload 2 ✓
  • raw + wrapped → overload 2 ✓ (its OutputArgs includes StandardSchemaWithJSON)
  • wrapped + raw → no match

The last cell falls through both: overload 1 rejects because { result: z.string() } has no '~standard' property and so is not a StandardSchemaWithJSON; overload 2 rejects because a ZodObject instance is not assignable to ZodRawShape = Record<string, z.ZodType> — its own members (_def, shape, parse, ~standard, …) are not z.ZodType, so it fails the index-signature constraint.

Why the runtime would handle it

registerTool's implementation signature accepts StandardSchemaWithJSON | ZodRawShape for both fields and calls normalizeRawShapeSchema on each independently (mcp.ts:914-915). normalizeRawShapeSchema checks isZodRawShape per-argument, so a wrapped inputSchema passes through unchanged while a raw outputSchema gets wrapped — there is no runtime coupling between the two. Only the public overload surface couples them.

Step-by-step proof (verified with tsc)

server.registerTool(
  'mixed',
  { inputSchema: z.object({ x: z.number() }), outputSchema: { result: z.string() } },
  async ({ x }) => ({ content: [{ type: 'text', text: String(x) }], structuredContent: { result: String(x) } })
);
  • Overload 1: TS infers OutputArgs = { result: ZodString }, checks { result: ZodString } extends StandardSchemaWithJSON → fails (no '~standard'). Error: Property '"~standard"' is missing in type '{ result: ZodString }'.
  • Overload 2: TS infers InputArgs = ZodObject<{x: ZodNumber}, $strip>, checks against Record<string, z.ZodType> → fails (e.g. _zod: $ZodObjectInternals is not assignable to z.ZodType). Error: Index signature for type 'string' is missing / …is not assignable to type 'Record<string, ZodType>'.
  • Net result: TS2769: No overload matches this call.

Swapping the two — inputSchema: { x: z.number() }, outputSchema: z.object({ result: z.string() }) — compiles cleanly via overload 2, confirming the asymmetry.

Impact

Low. Pure v1 code (both raw) and pure v2 code (both wrapped) both type-check; this only bites someone who has wrapped inputSchema but not outputSchema — an unusual half-migrated state — and the compile error itself points at outputSchema lacking '~standard', which directly suggests wrapping it. The @deprecated JSDoc on the raw-shape overload already steers users that way. The only reason to mention it is that the changeset says raw shapes are accepted "for inputSchema/outputSchema/argsSchema" without noting that a raw outputSchema is only accepted alongside a raw inputSchema.

Fix (optional)

Widen overload 1 to OutputArgs extends StandardSchemaWithJSON | ZodRawShape (mirroring overload 2's output constraint). The callback type ToolCallback<InputArgs> doesn't reference OutputArgs, so no inference change is needed. Alternatively, leave as-is and treat the asymmetry as an intentional nudge toward wrapping.

registerTool(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: StandardSchemaWithJSON | ZodRawShape;
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
): RegisteredTool {
if (this._registeredTools[name]) {
throw new Error(`Tool ${name} is already registered`);
Expand All @@ -884,8 +911,8 @@ export class McpServer {
name,
title,
description,
inputSchema,
outputSchema,
normalizeRawShapeSchema(inputSchema),
normalizeRawShapeSchema(outputSchema),
annotations,
{ taskSupport: 'forbidden' },
_meta,
Expand Down Expand Up @@ -928,6 +955,27 @@ export class McpServer {
_meta?: Record<string, unknown>;
},
cb: PromptCallback<Args>
): RegisteredPrompt;
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `argsSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
registerPrompt<Args extends ZodRawShape>(
name: string,
config: {
title?: string;
description?: string;
argsSchema?: Args;
_meta?: Record<string, unknown>;
},
cb: LegacyPromptCallback<Args>
): RegisteredPrompt;
registerPrompt(
name: string,
config: {
title?: string;
description?: string;
argsSchema?: StandardSchemaWithJSON | ZodRawShape;
_meta?: Record<string, unknown>;
},
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
): RegisteredPrompt {
if (this._registeredPrompts[name]) {
throw new Error(`Prompt ${name} is already registered`);
Expand All @@ -939,7 +987,7 @@ export class McpServer {
name,
title,
description,
argsSchema,
normalizeRawShapeSchema(argsSchema),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
_meta
);
Expand Down Expand Up @@ -1062,6 +1110,26 @@ export class ResourceTemplate {
}
}

/**
* A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
* Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries.
*/
export type ZodRawShape = Record<string, z.ZodType>;

/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: InferRawShape<S> = { [K in keyof S]: z.output<S[K]> } makes every key required, so { a: z.string().optional() } infers as { a: string | undefined } instead of { a?: string | undefined } — diverging from both v1 (which used z.objectOutputType) and from what the runtime z.object(shape).parse() actually returns. Consider type InferRawShape<S extends ZodRawShape> = z.output<z.ZodObject<S>> so optional/default fields get the ? modifier the same way they did in v1.

Extended reasoning...

What the bug is

InferRawShape is defined as a homogeneous mapped type:

export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };

A mapped type over keyof S keeps every key required — it just maps the value type. So for S = { a: z.ZodOptional<z.ZodString> }, z.output<S['a']> is string | undefined, and the result is { a: string | undefined }: the key is present, the value may be undefined.

But the runtime path wraps the shape with z.object(shape) (normalizeRawShapeSchema, standardSchema.ts:173), and z.object({ a: z.string().optional() }).parse({}) returns {} — the key is absent. Zod's own output type for that object is { a?: string | undefined } (Zod applies the ? modifier via its internal addQuestionMarks helper for ZodOptional/ZodDefault fields). v1's callback args type used z.objectOutputType<Args, z.ZodTypeAny>, which produced exactly that optional-key form.

So LegacyToolCallback<{a: ZodOptional<ZodString>}> now types args as { a: string | undefined } where v1 typed it as { a?: string | undefined }, and where the actual value passed at runtime is {} or { a: 'x' }.

Step-by-step proof

Take const shape = { a: z.string().optional() }:

  1. ZodRawShape is satisfied — z.ZodOptional<z.ZodString> extends z.ZodType.
  2. InferRawShape<typeof shape> expands to { a: z.output<ZodOptional<ZodString>> } = { a: string | undefined }. Key a is required.
  3. At runtime, normalizeRawShapeSchema(shape) returns z.object({ a: z.string().optional() }).
  4. On tools/call with arguments: {}, validateStandardSchema calls .parse({}) → returns {} (no a key). This value is handed to the callback as args.
  5. So the callback's static type says 'a' in args is always true, but the runtime value has 'a' in args === false.
  6. v1's type for the same shape was z.objectOutputType<{a: ZodOptional<ZodString>}, ZodTypeAny> = { a?: string | undefined } — key optional, matching runtime.

Why nothing else catches it

The only thing between the raw shape and the callback's args parameter type is InferRawShape. The runtime side (z.object(shape)) is correct; only the homegrown mapped type drops the optionality marker. z.output<S[K]> per-field cannot recover it because optionality is a property of the key in the object type, not of the field's standalone output type.

Impact

Low — hence nit. For the dominant pattern async ({ a }) => ..., destructuring yields a: string | undefined either way, and under default TS settings (exactOptionalPropertyTypes: false) { a?: string | undefined } and { a: string | undefined } are mutually assignable, so a v1-typed callback still passes by contravariance. The divergence is only observable for:

  • 'a' in args / Object.keys(args) checks (the type says the key is always there; runtime says it may not be),
  • projects with exactOptionalPropertyTypes: true, where the two types are not mutually assignable,
  • .default() fields, where v1/z.object infer { a: T } (required, non-undefined) but this mapped type infers { a: T } too — actually .default() is fine here since z.output<ZodDefault<X>> is non-undefined and the key being required matches; the issue is specifically .optional().

This is a @deprecated v1-compat shim, so the bar is "match v1's types", and it doesn't quite.

Fix

One-liner — let Zod do the inference it already knows how to do:

export type InferRawShape<S extends ZodRawShape> = z.output<z.ZodObject<S>>;

(or equivalently z.infer<z.ZodObject<S>>). This applies Zod's own optional-key logic, so { a: z.string().optional() }{ a?: string | undefined }, exactly matching v1's z.objectOutputType and the runtime wrapped-schema output.


/** {@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 | Promise<CallToolResult>
: (ctx: ServerContext) => CallToolResult | Promise<CallToolResult>;

/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (args: InferRawShape<Args>, ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>
: (ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>;
Comment thread
claude[bot] marked this conversation as resolved.

export type BaseToolCallback<
SendResultT extends Result,
Ctx extends ServerContext,
Expand Down
101 changes: 101 additions & 0 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index.js';

describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({
content: [{ type: 'text' as const, text: String(x) }]
}));
server.registerTool('b', { inputSchema: { y: z.number() } }, async ({ y }) => ({
content: [{ type: 'text' as const, text: String(y) }]
}));

const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
expect(Object.keys(tools)).toEqual(['a', 'b']);
// raw shape was wrapped into a Standard Schema (z.object)
expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true);
});

it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({
content: [{ type: 'text' as const, text: String(n) }],
structuredContent: { result: String(n) }
}));

const tools = (server as unknown as { _registeredTools: Record<string, { outputSchema?: unknown }> })._registeredTools;
expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true);
});

it('registerTool with z.object() inputSchema also works (passthrough, no auto-wrap)', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({
content: [{ type: 'text' as const, text: String(x) }]
}));

const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
expect(isStandardSchema(tools['c']?.inputSchema)).toBe(true);
});

it('registerPrompt accepts a raw shape for argsSchema', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }]
}));

const prompts = (server as unknown as { _registeredPrompts: Record<string, { argsSchema?: unknown }> })._registeredPrompts;
expect(Object.keys(prompts)).toContain('p');
expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true);
});

it('callback receives validated, typed args end-to-end via tools/call', async () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

let received: { x: number } | undefined;
server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => {
received = args;
return { content: [{ type: 'text' as const, text: String(args.x) }] };
});

const [client, srv] = InMemoryTransport.createLinkedPair();
await server.connect(srv);
await client.start();

const responses: JSONRPCMessage[] = [];
client.onmessage = m => responses.push(m);

await client.send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: 'c', version: '1.0.0' }
}
} as JSONRPCMessage);
await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage);
await client.send({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'echo', arguments: { x: 7 } }
} as JSONRPCMessage);

await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));

expect(received).toEqual({ x: 7 });
const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text: string }> } };
expect(result.result?.content[0]?.text).toBe('7');

await server.close();
});
});
Loading