diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md new file mode 100644 index 000000000..5f1f16784 --- /dev/null +++ b/.changeset/register-rawshape-compat.md @@ -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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e70b62e20..8bcc9c959 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts new file mode 100644 index 000000000..3bb208809 --- /dev/null +++ b/packages/core/src/util/zodCompat.ts @@ -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 { + 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 | 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; +} diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts new file mode 100644 index 000000000..cf48be3d3 --- /dev/null +++ b/packages/core/test/util/zodCompat.test.ts @@ -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; + 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)?.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/); + }); +}); diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 2e651e932..82300f7df 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -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 = ( - value: StandardSchemaWithJSON.InferInput, +export type CompleteCallback = ( + value: StandardSchemaV1.InferInput, context?: { arguments?: Record; } -) => StandardSchemaWithJSON.InferInput[] | Promise[]>; +) => StandardSchemaV1.InferInput[] | Promise[]>; -export type CompletableMeta = { +export type CompletableMeta = { complete: CompleteCallback; }; -export type CompletableSchema = T & { +export type CompletableSchema = T & { [COMPLETABLE_SYMBOL]: CompletableMeta; }; @@ -48,7 +48,7 @@ export type CompletableSchema = T & { * * @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions */ -export function completable(schema: T, complete: CompleteCallback): CompletableSchema { +export function completable(schema: T, complete: CompleteCallback): CompletableSchema { Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { value: { complete } as CompletableMeta, enumerable: false, @@ -61,14 +61,14 @@ export function completable(schema: T, complet /** * Checks if a schema is completable (has completion metadata). */ -export function isCompletable(schema: unknown): schema is CompletableSchema { +export function isCompletable(schema: unknown): schema is CompletableSchema { 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(schema: T): CompleteCallback | undefined { +export function getCompleter(schema: T): CompleteCallback | undefined { const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta })[COMPLETABLE_SYMBOL]; return meta?.complete as CompleteCallback | undefined; } diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6c2699997..fb45fd5db 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -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; }, cb: ToolCallback + ): 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( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: LegacyToolCallback + ): RegisteredTool; + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON | ZodRawShape; + outputSchema?: StandardSchemaWithJSON | ZodRawShape; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback | LegacyToolCallback ): 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; }, cb: PromptCallback + ): 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( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, + cb: LegacyPromptCallback + ): RegisteredPrompt; + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON | ZodRawShape; + _meta?: Record; + }, + cb: PromptCallback | LegacyPromptCallback ): 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, _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; + +/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ +export type InferRawShape = z.infer>; + +/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyToolCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise + : (ctx: ServerContext) => CallToolResult | Promise; + +/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyPromptCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + export type BaseToolCallback< SendResultT extends Result, Ctx extends ServerContext, diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts new file mode 100644 index 000000000..322b61535 --- /dev/null +++ b/packages/server/test/server/mcp.compat.test.ts @@ -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 })._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 })._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 })._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 })._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 })._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 }>; + expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); + }); +});