-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() #1901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
27e4ddf
5266131
f2fdbe7
9576f20
0152b26
1af9ed2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()`. |
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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; | |||||||||||
|
Comment on lines
+879
to
+891
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: the overloads aren't fully orthogonal — Extended reasoning...What the gap is
The four input×output combinations resolve as:
The last cell falls through both: overload 1 rejects because Why the runtime would handle it
Step-by-step proof (verified with
|
|||||||||||
| 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> = { [K in keyof S]: z.output<S[K]> }; | |||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: Extended reasoning...What the bug is
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };A mapped type over But the runtime path wraps the shape with So Step-by-step proofTake
Why nothing else catches itThe only thing between the raw shape and the callback's ImpactLow — hence nit. For the dominant pattern
This is a FixOne-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 |
|||||||||||
|
|
|||||||||||
| /** {@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>; | |||||||||||
|
claude[bot] marked this conversation as resolved.
|
|||||||||||
|
|
|||||||||||
| export type BaseToolCallback< | |||||||||||
| SendResultT extends Result, | |||||||||||
| Ctx extends ServerContext, | |||||||||||
|
|
|||||||||||
| 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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.