diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7e61b4364..f1b9e7449 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -146,6 +146,10 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: (() => { + if (isLikelyJsonSchema(tool.inputSchema)) { + return tool.inputSchema as Tool['inputSchema']; + } + const obj = normalizeObjectSchema(tool.inputSchema); return obj ? (toJsonSchemaCompat(obj, { @@ -996,7 +1000,7 @@ export class McpServer { } let description: string | undefined; - let inputSchema: ZodRawShapeCompat | undefined; + let inputSchema: ZodRawShapeCompat | AnySchema | undefined; let outputSchema: ZodRawShapeCompat | undefined; let annotations: ToolAnnotations | undefined; @@ -1013,20 +1017,25 @@ export class McpServer { // We have at least one more arg before the callback const firstArg = rest[0]; - if (isZodRawShapeCompat(firstArg)) { - // We have a params schema as the first arg + if (isZodRawShapeCompat(firstArg) || isLikelyJsonSchema(firstArg)) { + // We have an input schema (Zod raw shape shorthand or plain JSON Schema) as the first arg inputSchema = rest.shift() as ZodRawShapeCompat; // Check if the next arg is potentially annotations - if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { + if (rest.length > 1 && isToolAnnotations(rest[0])) { // Case: tool(name, paramsSchema, annotations, cb) // Or: tool(name, description, paramsSchema, annotations, cb) annotations = rest.shift() as ToolAnnotations; } } else if (typeof firstArg === 'object' && firstArg !== null) { - // Not a ZodRawShapeCompat, so must be annotations in this position + // Non-schema object in this position must be ToolAnnotations. // Case: tool(name, annotations, cb) // Or: tool(name, description, annotations, cb) + if (!isToolAnnotations(firstArg)) { + throw new Error( + `Invalid third argument for tool '${name}': expected input schema (Zod raw shape or JSON Schema) or ToolAnnotations` + ); + } annotations = rest.shift() as ToolAnnotations; } } @@ -1384,6 +1393,53 @@ function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { return Object.values(obj).some(isZodTypeLike); } +function isToolAnnotations(obj: unknown): obj is ToolAnnotations { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const allowedKeys = ['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']; + const keys = Object.keys(obj); + + // Empty object is valid for backwards compatibility in old tool() API. + if (keys.length === 0) { + return true; + } + + // ToolAnnotations only supports these optional hint keys. + if (!keys.every(key => allowedKeys.includes(key))) { + return false; + } + + const record = obj as Record; + + if (record.title !== undefined && typeof record.title !== 'string') { + return false; + } + + for (const key of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const) { + if (record[key] !== undefined && typeof record[key] !== 'boolean') { + return false; + } + } + + return true; +} + +function isLikelyJsonSchema(obj: unknown): boolean { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + // Exclude Zod schemas (including transformed/object schemas) first. + if (isZodSchemaInstance(obj)) { + return false; + } + + const schema = obj as Record; + return schema.type === 'object' || schema.properties !== undefined; +} + /** * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, * otherwise returns the schema as is. diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index f6c2124e1..03747f77b 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -993,6 +993,71 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Registration with JSON Schema + */ + test('should treat plain JSON Schema as params schema instead of annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + (mcpServer.tool as any)( + 'test-json-schema', + 'JSON schema tool', + { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }, + async ({ name }: { name: string }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-json-schema'); + expect(result.tools[0].description).toBe('JSON schema tool'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }); + expect(result.tools[0].annotations).toBeUndefined(); + }); + + /*** + * Test: Tool Registration with Invalid Object Argument + */ + test('should throw when non-schema object is not valid ToolAnnotations', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + expect(() => + (mcpServer.tool as any)( + 'invalid-arg', + 'Invalid object arg', + { foo: 'bar' }, + async () => ({ content: [{ type: 'text', text: 'ok' }] }) + ) + ).toThrow("Invalid third argument for tool 'invalid-arg': expected input schema (Zod raw shape or JSON Schema) or ToolAnnotations"); + }); + /*** * Test: Tool Argument Validation */