diff --git a/.changeset/fix-tool-json-schema-detection.md b/.changeset/fix-tool-json-schema-detection.md new file mode 100644 index 000000000..2801e3b00 --- /dev/null +++ b/.changeset/fix-tool-json-schema-detection.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server": patch +--- + +fix: detect plain JSON Schema objects in tool() overload resolution diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 95566bbb4..d162d6e20 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -12,6 +12,7 @@ export type { AnyToolHandler, BaseToolCallback, CompleteResourceTemplateCallback, + DeprecatedVariadicToolCallback, ListResourcesCallback, PromptCallback, ReadResourceCallback, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index fb45fd5db..e5fe69f71 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -9,6 +9,7 @@ import type { CreateTaskServerContext, GetPromptResult, Implementation, + JsonSchemaType, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -30,6 +31,8 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + isStandardSchema, + isZodRawShape, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -43,6 +46,7 @@ import type * as z from 'zod/v4'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; +import { fromJsonSchema } from '../fromJsonSchema.js'; import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; @@ -920,6 +924,105 @@ export class McpServer { ); } + /** + * Registers a tool using the legacy variadic overloads (v1-style). + * + * **Note:** Use {@linkcode registerTool} for new code. + * + * @deprecated Prefer {@linkcode registerTool} with an explicit config object. + */ + tool( + name: string, + paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record, + cb: DeprecatedVariadicToolCallback + ): RegisteredTool; + + /** + * @deprecated Prefer {@linkcode registerTool}. + */ + tool( + name: string, + description: string, + paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record, + cb: DeprecatedVariadicToolCallback + ): RegisteredTool; + + /** + * Legacy `tool()` implementation. Parses arguments for the overloads declared above. + */ + tool(name: string, ...rest: unknown[]): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } + + let description: string | undefined; + let inputSchema: StandardSchemaWithJSON | undefined; + let annotations: ToolAnnotations | undefined; + + if (typeof rest[0] === 'string') { + description = rest.shift() as string; + } + + if (rest.length > 1) { + const firstArg = rest[0]; + + if (typeof firstArg === 'object' && firstArg !== null && !Array.isArray(firstArg)) { + const record = firstArg as Record; + + if (isZodRawShape(record) || isStandardSchema(record)) { + inputSchema = normalizeRawShapeSchema(record as StandardSchemaWithJSON | ZodRawShape); + rest.shift(); + + if ( + rest.length > 1 && + typeof rest[0] === 'object' && + rest[0] !== null && + !Array.isArray(rest[0]) && + isToolAnnotationsOnlyObject(rest[0] as Record) + ) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (isLikelyPlainJsonSchemaObject(record)) { + inputSchema = fromJsonSchema(record as JsonSchemaType); + rest.shift(); + + if ( + rest.length > 1 && + typeof rest[0] === 'object' && + rest[0] !== null && + !Array.isArray(rest[0]) && + isToolAnnotationsOnlyObject(rest[0] as Record) + ) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (isToolAnnotationsOnlyObject(record)) { + annotations = rest.shift() as ToolAnnotations; + } else { + throw new TypeError( + `Tool "${name}": unrecognized third argument. Expected a Standard Schema, Zod raw shape ({ field: z.string() }), plain JSON Schema (e.g. { type: "object", properties: {...} }), or ToolAnnotations (${[...TOOL_ANNOTATION_KEYS].join(', ')}).` + ); + } + } + } + + const callback = rest[0]; + if (typeof callback !== 'function') { + throw new TypeError(`Tool "${name}": last argument must be the handler callback`); + } + + return this._createRegisteredTool( + name, + undefined, + description, + inputSchema, + undefined, + annotations, + { taskSupport: 'forbidden' }, + undefined, + callback as ToolCallback + ); + } + /** * Registers a prompt with a config object and callback. * @@ -1147,6 +1250,14 @@ export type ToolCallback; +/** + * Callback type for deprecated {@linkcode McpServer.tool} positional overloads. + * Intentionally loose: plain JSON Schema (wrapped via {@linkcode fromJsonSchema}) does not participate in overload inference. + */ +export type DeprecatedVariadicToolCallback = + | ((args: unknown, ctx: ServerContext) => CallToolResult | Promise) + | ((ctx: ServerContext) => CallToolResult | Promise); + /** * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). */ @@ -1185,6 +1296,69 @@ export type RegisteredTool = { remove(): void; }; +const TOOL_ANNOTATION_KEYS = new Set(['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']); + +/** Structural hints that a plain object is JSON Schema (wire-shape) rather than {@linkcode ToolAnnotations}. */ +const JSON_SCHEMA_SHAPE_KEYS = [ + 'type', + 'properties', + 'items', + 'required', + 'additionalProperties', + '$schema', + '$ref', + '$defs', + 'definitions', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + 'enum', + 'const', + 'minimum', + 'maximum', + 'minLength', + 'maxLength', + 'pattern', + 'format', + 'minItems', + 'maxItems', + 'prefixItems', + 'description' +] as const; + +function isToolAnnotationsOnlyObject(obj: Record): boolean { + const keys = Object.keys(obj); + if (keys.length === 0) { + return false; + } + for (const key of keys) { + if (!TOOL_ANNOTATION_KEYS.has(key)) { + return false; + } + } + for (const [k, v] of Object.entries(obj)) { + if (k === 'title') { + if (typeof v !== 'string') { + return false; + } + } else if (typeof v !== 'boolean') { + return false; + } + } + return true; +} + +function isLikelyPlainJsonSchemaObject(obj: Record): boolean { + if (Object.keys(obj).length === 0) { + return false; + } + if (isToolAnnotationsOnlyObject(obj)) { + return false; + } + return JSON_SCHEMA_SHAPE_KEYS.some(k => k in obj); +} + /** * Creates an executor that invokes the handler with the appropriate arguments. * When `inputSchema` is defined, the handler is called with `(args, ctx)`. diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b61535..69c2c6b79 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,5 +1,5 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/core'; -import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION, standardSchemaToJsonSchema } from '@modelcontextprotocol/core'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { McpServer } from '../../src/index.js'; @@ -127,3 +127,156 @@ describe('InferRawShape', () => { expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); }); }); + +describe('McpServer.tool() legacy overload resolution', () => { + it('treats a plain JSON Schema object as inputSchema (not ToolAnnotations)', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.tool( + 'my.tool', + 'A tool that requires a directory_id', + { + type: 'object', + properties: { + directory_id: { + type: 'string', + format: 'uuid', + description: 'The UUID of the directory' + } + }, + required: ['directory_id'] + }, + async (args: unknown) => ({ + content: [{ type: 'text' as const, text: JSON.stringify(args) }] + }) + ); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(isStandardSchema(tools['my.tool']?.inputSchema)).toBe(true); + const json = standardSchemaToJsonSchema(tools['my.tool']!.inputSchema as StandardSchemaWithJSON, 'input') as { + properties?: Record; + required?: string[]; + }; + expect(json.properties).toHaveProperty('directory_id'); + expect(json.required).toContain('directory_id'); + }); + + it('still treats ToolAnnotations-only objects as annotations (empty wire input schema)', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.tool('annotated', 'desc', { title: 'Display title', readOnlyHint: true }, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })); + + const registered = (server as unknown as { _registeredTools: Record }) + ._registeredTools; + expect(registered['annotated']?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true }); + expect(registered['annotated']?.inputSchema).toBeUndefined(); + + 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/list', + params: {} + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const listed = responses.find(r => 'id' in r && r.id === 2) as { + result?: { tools: Array<{ annotations?: unknown; inputSchema?: unknown }> }; + }; + expect(listed.result?.tools).toHaveLength(1); + expect(listed.result?.tools[0]?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true }); + expect(listed.result?.tools[0]?.inputSchema).toEqual({ + type: 'object', + properties: {} + }); + + await server.close(); + }); + + it('throws when the positional object matches neither schema nor ToolAnnotations', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + expect(() => + server.tool('bad', 'desc', { notASchemaOrAnnotation: true }, async () => ({ + content: [{ type: 'text' as const, text: 'x' }] + })) + ).toThrow(TypeError); + + expect(() => + server.tool('bad2', 'desc', { title: 'x', extraKey: true }, async () => ({ + content: [{ type: 'text' as const, text: 'x' }] + })) + ).toThrow(TypeError); + }); + + it('passes validated arguments for plain JSON Schema tools end-to-end', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + let received: unknown; + server.tool( + 'js', + 'uses json schema', + { + type: 'object', + properties: { n: { type: 'number' } }, + required: ['n'] + }, + async (args: unknown) => { + received = args; + const { n } = args as { n: number }; + return { content: [{ type: 'text' as const, text: String(n) }] }; + } + ); + + 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: 'js', arguments: { n: 42 } } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + expect(received).toEqual({ n: 42 }); + const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text?: string }> } }; + expect(result.result?.content[0]?.text).toBe('42'); + + await server.close(); + }); +});