feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() (#1901)

Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-04-29 16:34:01 +01:00 • committed by GitHub
commit e15a8ef3be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 387 additions and 12 deletions

View file

@ -0,0 +1,8 @@
---
'@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()`.
Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work.

View file

@ -15,6 +15,7 @@ export * from './types/index.js';
export * from './util/inMemory.js';
export * from './util/schema.js';
export * from './util/standardSchema.js';
export * from './util/zodCompat.js';
// experimental exports
export * from './experimental/index.js';

View file

@ -0,0 +1,80 @@
/**
* Zod-specific helpers for the v1-compat raw-shape shorthand on
* `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so
* that file stays library-agnostic per the Standard Schema spec.
*/
import * as z from 'zod/v4';
import type { StandardSchemaWithJSON } from './standardSchema.js';
import { isStandardSchema } from './standardSchema.js';
function isZodV4Schema(v: unknown): v is z.ZodType {
// `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def`
// and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require
// v4 because the wrap path below uses v4's `z.object()`, which cannot consume
// v3 field schemas.
return typeof v === 'object' && v !== null && '_zod' in v;
}
function looksLikeZodV3(v: unknown): boolean {
// v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`.
return (
typeof v === 'object' &&
v !== null &&
!('_zod' in v) &&
'_def' in v &&
typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string'
);
}
/**
* 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;
// Require a plain object literal: rejects arrays, Date, Map, RegExp, class instances, etc.
// Object.create(null) is also accepted.
const proto = Object.getPrototypeOf(obj);
if (proto !== Object.prototype && proto !== null) return false;
// [].every() is true, so an empty plain object is a valid raw shape (matches v1).
return Object.values(obj).every(v => isZodV4Schema(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;
}
if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) {
throw new TypeError(
'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.'
);
}
if (!isStandardSchema(schema)) {
throw new TypeError(
'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).'
);
}
// Any StandardSchema passes through; standardSchemaToJsonSchema owns the per-vendor
// handling for schemas without `~standard.jsonSchema` (zod 4.0-4.1 fallback, zod 3
// and non-zod errors). Gating on `~standard.jsonSchema` here would unreachably
// front-run that fallback.
return schema;
}

View file

@ -0,0 +1,89 @@
import { vi } from 'vitest';
import * as z from 'zod/v4';
import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.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);
});
test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => {
expect(isZodRawShape({ a: mockZodV3String() })).toBe(false);
});
test('rejects non-plain objects with no own-enumerable properties', () => {
expect(isZodRawShape([])).toBe(false);
expect(isZodRawShape([z.string()])).toBe(false);
expect(isZodRawShape(new Date())).toBe(false);
expect(isZodRawShape(new Map())).toBe(false);
expect(isZodRawShape(/regex/)).toBe(false);
});
test('accepts a null-prototype plain object', () => {
const o = Object.create(null);
o.a = z.string();
expect(isZodRawShape(o)).toBe(true);
});
});
// Minimal structural mock of a Zod v3 schema: has `_def.typeName` and
// `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`.
function mockZodV3String(): unknown {
return {
_def: { typeName: 'ZodString', checks: [], coerce: false },
'~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) },
parse: (v: unknown) => v
};
}
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();
});
test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => {
expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError);
});
test('passes through a Standard Schema without `~standard.jsonSchema` (per-vendor handling deferred to standardSchemaToJsonSchema)', () => {
const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } };
expect(normalizeRawShapeSchema(noJson as never)).toBe(noJson);
});
test('passes through a zod 4.0-4.1 schema so standardSchemaToJsonSchema can apply its z.toJSONSchema fallback', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const real = z.object({ a: z.string() });
// Simulate zod 4.0-4.1: shadow `~standard` with `jsonSchema` removed, keep `_zod` intact.
const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record<string, unknown>;
void _drop;
Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true });
const normalized = normalizeRawShapeSchema(real);
expect(normalized).toBe(real);
const json = standardSchemaToJsonSchema(normalized!, 'input');
expect(json.type).toBe('object');
expect((json.properties as Record<string, unknown>)?.a).toBeDefined();
warn.mockRestore();
});
test('throws actionable TypeError for a raw shape with Zod v3 fields', () => {
expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/);
});
test('throws the intended TypeError (not Object.values crash) for null input', () => {
expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/);
});
});

View file

@ -1,19 +1,19 @@
import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core';
import type { StandardSchemaV1 } from '@modelcontextprotocol/core';
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');
export type CompleteCallback<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = (
value: StandardSchemaWithJSON.InferInput<T>,
export type CompleteCallback<T extends StandardSchemaV1 = StandardSchemaV1> = (
value: StandardSchemaV1.InferInput<T>,
context?: {
arguments?: Record<string, string>;
}
) => StandardSchemaWithJSON.InferInput<T>[] | Promise<StandardSchemaWithJSON.InferInput<T>[]>;
) => StandardSchemaV1.InferInput<T>[] | Promise<StandardSchemaV1.InferInput<T>[]>;
export type CompletableMeta<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = {
export type CompletableMeta<T extends StandardSchemaV1 = StandardSchemaV1> = {
complete: CompleteCallback<T>;
};
export type CompletableSchema<T extends StandardSchemaWithJSON> = T & {
export type CompletableSchema<T extends StandardSchemaV1> = T & {
[COMPLETABLE_SYMBOL]: CompletableMeta<T>;
};
@ -48,7 +48,7 @@ export type CompletableSchema<T extends StandardSchemaWithJSON> = T & {
*
* @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions
*/
export function completable<T extends StandardSchemaWithJSON>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
export function completable<T extends StandardSchemaV1>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
value: { complete } as CompletableMeta<T>,
enumerable: false,
@ -61,14 +61,14 @@ export function completable<T extends StandardSchemaWithJSON>(schema: T, complet
/**
* Checks if a schema is completable (has completion metadata).
*/
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaWithJSON> {
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaV1> {
return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object);
}
/**
* Gets the completer callback from a completable schema, if it exists.
*/
export function getCompleter<T extends StandardSchemaWithJSON>(schema: T): CompleteCallback<T> | undefined {
export function getCompleter<T extends StandardSchemaV1>(schema: T): CompleteCallback<T> | undefined {
const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta<T> })[COMPLETABLE_SYMBOL];
return meta?.complete as CompleteCallback<T> | undefined;
}

View file

@ -30,6 +30,7 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
normalizeRawShapeSchema,
promptArgumentsFromStandardSchema,
ProtocolError,
ProtocolErrorCode,
@ -38,6 +39,7 @@ import {
validateAndWarnToolName,
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';
@ -873,6 +875,31 @@ export class McpServer {
_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;
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
): RegisteredTool;
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`);
@ -884,8 +911,8 @@ export class McpServer {
name,
title,
description,
inputSchema,
outputSchema,
normalizeRawShapeSchema(inputSchema),
normalizeRawShapeSchema(outputSchema),
annotations,
{ taskSupport: 'forbidden' },
_meta,
@ -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`);
@ -939,7 +987,7 @@ export class McpServer {
name,
title,
description,
argsSchema,
normalizeRawShapeSchema(argsSchema),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
_meta
);
@ -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> = 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 | 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>;
export type BaseToolCallback<
SendResultT extends Result,
Ctx extends ServerContext,

View file

@ -0,0 +1,129 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index.js';
import type { InferRawShape } from '../../src/server/mcp.js';
import { completable } from '../../src/server/completable.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('registerPrompt raw shape accepts completable() fields (v1 pattern)', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });
server.registerPrompt(
'p',
{
argsSchema: {
language: completable(z.string(), v => ['typescript', 'python'].filter(l => l.startsWith(v)))
}
},
async ({ language }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: language } }]
})
);
const prompts = (server as unknown as { _registeredPrompts: Record<string, { argsSchema?: unknown }> })._registeredPrompts;
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();
});
});
describe('InferRawShape', () => {
it('preserves optionality from .optional() as ?: keys', () => {
type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional<z.ZodString> }>;
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
});
});