diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md new file mode 100644 index 000000000..bca0d9057 --- /dev/null +++ b/.changeset/register-rawshape-compat.md @@ -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()`. diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 9817dc39a..4228bb25a 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -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 { @@ -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 { + 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 | undefined +): StandardSchemaWithJSON | undefined { + if (schema === undefined) return undefined; + if (isZodRawShape(schema)) { + return z.object(schema) as StandardSchemaWithJSON; + } + return schema; +} + // JSON Schema conversion /** diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts index 6c3de99d7..de081b975 100644 --- a/packages/core/test/util/standardSchema.test.ts +++ b/packages/core/test/util/standardSchema.test.ts @@ -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', () => { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6c2699997..4caa125c4 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, @@ -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'; @@ -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 = { [K in keyof S]: z.output }; + +/** {@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..8cfd70b27 --- /dev/null +++ b/packages/server/test/server/mcp.compat.test.ts @@ -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 })._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('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(); + }); +});