From 6079cd830708431f9cd19e7780ad3e63ec31969c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 13 May 2025 08:44:03 -0400 Subject: [PATCH] - add new tool() api and tests - fix schema validation - comment headers on tests --- package-lock.json | 10 - src/client/index.test.ts | 64 ++- src/client/index.ts | 50 ++- src/examples/server/mcpServerOutputSchema.ts | 2 +- src/server/mcp-outputschema.test.ts | 222 ---------- src/server/mcp.test.ts | 433 +++++++++++++++++-- src/server/mcp.ts | 193 ++++----- src/types.ts | 9 +- 8 files changed, 580 insertions(+), 403 deletions(-) delete mode 100644 src/server/mcp-outputschema.test.ts diff --git a/package-lock.json b/package-lock.json index 425abd47..ed986a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -4880,15 +4879,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/json-schema-to-zod": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", - "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", - "license": "ISC", - "bin": { - "json-schema-to-zod": "dist/cjs/cli.js" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index cd43a7e0..d377af86 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -21,6 +21,9 @@ import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; +/*** + * Test: Initialize with Matching Protocol Version + */ test("should initialize with matching protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -76,6 +79,9 @@ test("should initialize with matching protocol version", async () => { expect(client.getInstructions()).toEqual("test instructions"); }); +/*** + * Test: Initialize with Supported Older Protocol Version + */ test("should initialize with supported older protocol version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const clientTransport: Transport = { @@ -124,6 +130,9 @@ test("should initialize with supported older protocol version", async () => { expect(client.getInstructions()).toBeUndefined(); }); +/*** + * Test: Reject Unsupported Protocol Version + */ test("should reject unsupported protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -166,6 +175,9 @@ test("should reject unsupported protocol version", async () => { expect(clientTransport.close).toHaveBeenCalled(); }); +/*** + * Test: Connect New Client to Old Supported Server Version + */ test("should connect new client to old, supported server version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -229,6 +241,9 @@ test("should connect new client to old, supported server version", async () => { }); }); +/*** + * Test: Version Negotiation with Old Client and Newer Server + */ test("should negotiate version when client is old, and newer server supports its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -292,6 +307,9 @@ test("should negotiate version when client is old, and newer server supports its }); }); +/*** + * Test: Throw when Old Client and Server Version Mismatch + */ test("should throw when client is old, and server doesn't support its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const FUTURE_VERSION = "FUTURE_VERSION"; @@ -354,6 +372,9 @@ test("should throw when client is old, and server doesn't support its version", }); +/*** + * Test: Respect Server Capabilities + */ test("should respect server capabilities", async () => { const server = new Server( { @@ -434,6 +455,9 @@ test("should respect server capabilities", async () => { ).rejects.toThrow("Server does not support completions"); }); +/*** + * Test: Respect Client Notification Capabilities + */ test("should respect client notification capabilities", async () => { const server = new Server( { @@ -490,6 +514,9 @@ test("should respect client notification capabilities", async () => { ); }); +/*** + * Test: Respect Server Notification Capabilities + */ test("should respect server notification capabilities", async () => { const server = new Server( { @@ -536,6 +563,9 @@ test("should respect server notification capabilities", async () => { ); }); +/*** + * Test: Only Allow setRequestHandler for Declared Capabilities + */ test("should only allow setRequestHandler for declared capabilities", () => { const client = new Client( { @@ -567,9 +597,10 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); -/* - Test that custom request/notification/result schemas can be used with the Client class. - */ +/*** + * Test: Type Checking + * Test that custom request/notification/result schemas can be used with the Client class. + */ test("should typecheck", () => { const GetWeatherRequestSchema = RequestSchema.extend({ method: z.literal("weather/get"), @@ -646,6 +677,9 @@ test("should typecheck", () => { }); }); +/*** + * Test: Handle Client Cancelling a Request + */ test("should handle client cancelling a request", async () => { const server = new Server( { @@ -701,6 +735,9 @@ test("should handle client cancelling a request", async () => { await expect(listResourcesPromise).rejects.toBe("Cancelled by test"); }); +/*** + * Test: Handle Request Timeout + */ test("should handle request timeout", async () => { const server = new Server( { @@ -757,6 +794,9 @@ test("should handle request timeout", async () => { }); describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ test('should validate structuredContent against outputSchema', async () => { const server = new Server({ name: 'test-server', @@ -828,6 +868,9 @@ describe('outputSchema validation', () => { expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); }); + /*** + * Test: Throw Error when structuredContent Does Not Match Schema + */ test('should throw error when structuredContent does not match schema', async () => { const server = new Server({ name: 'test-server', @@ -901,6 +944,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Throw Error when Tool with outputSchema Returns No structuredContent + */ test('should throw error when tool with outputSchema returns no structuredContent', async () => { const server = new Server({ name: 'test-server', @@ -972,6 +1018,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Handle Tools Without outputSchema Normally + */ test('should handle tools without outputSchema normally', async () => { const server = new Server({ name: 'test-server', @@ -1036,6 +1085,9 @@ describe('outputSchema validation', () => { expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); }); + /*** + * Test: Handle Complex JSON Schema Validation + */ test('should handle complex JSON schema validation', async () => { const server = new Server({ name: 'test-server', @@ -1131,6 +1183,9 @@ describe('outputSchema validation', () => { expect(structuredContent.age).toBe(30); }); + /*** + * Test: Fail Validation with Additional Properties When Not Allowed + */ test('should fail validation with additional properties when not allowed', async () => { const server = new Server({ name: 'test-server', @@ -1206,6 +1261,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Throw Error when Tool Without outputSchema Returns structuredContent + */ test('should throw error when tool without outputSchema returns structuredContent', async () => { const server = new Server({ name: 'test-server', diff --git a/src/client/index.ts b/src/client/index.ts index 98a0190c..c4aed3a4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,7 +44,7 @@ import { McpError, } from "../types.js"; import { z } from "zod"; -import { parseSchema } from "json-schema-to-zod"; +import { JsonSchema, parseSchema } from "json-schema-to-zod"; export type ClientOptions = ProtocolOptions & { /** @@ -91,7 +91,6 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; - private _cachedTools: Map = new Map(); private _cachedToolOutputSchemas: Map = new Map(); /** @@ -427,7 +426,7 @@ export class Client< ); // Check if the tool has an outputSchema - const outputSchema = this._cachedToolOutputSchemas.get(params.name); + const outputSchema = this.getToolOutputSchema(params.name); if (outputSchema) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -472,6 +471,30 @@ export class Client< return result; } + private cacheToolOutputSchemas(tools: Tool[]) { + this._cachedToolOutputSchemas.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the Zod schema + if (tool.outputSchema) { + try { + const zodSchemaCode = parseSchema(tool.outputSchema as JsonSchema); + // The library returns a string of Zod code, we need to evaluate it + // Using Function constructor to safely evaluate the Zod schema + const createSchema = new Function('z', `return ${zodSchemaCode}`); + const zodSchema = createSchema(z); + this._cachedToolOutputSchemas.set(tool.name, zodSchema); + } catch (error) { + console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); + } + } + } + } + + private getToolOutputSchema(toolName: string): z.ZodTypeAny | undefined { + return this._cachedToolOutputSchemas.get(toolName); + } + async listTools( params?: ListToolsRequest["params"], options?: RequestOptions, @@ -483,26 +506,7 @@ export class Client< ); // Cache the tools and their output schemas for future validation - this._cachedTools.clear(); - this._cachedToolOutputSchemas.clear(); - - for (const tool of result.tools) { - this._cachedTools.set(tool.name, tool); - - // If the tool has an outputSchema, create and cache the Zod schema - if (tool.outputSchema) { - try { - const zodSchemaCode = parseSchema(tool.outputSchema); - // The library returns a string of Zod code, we need to evaluate it - // Using Function constructor to safely evaluate the Zod schema - const createSchema = new Function('z', `return ${zodSchemaCode}`); - const zodSchema = createSchema(z); - this._cachedToolOutputSchemas.set(tool.name, zodSchema); - } catch (error) { - console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); - } - } - } + this.cacheToolOutputSchemas(result.tools); return result; } diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index cb845851..0695bfa1 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -192,7 +192,7 @@ server.tool( } }, required: ["stats"] - }, + }, async ({ data }) => { const mean = data.reduce((a, b) => a + b, 0) / data.length; const sorted = [...data].sort((a, b) => a - b); diff --git a/src/server/mcp-outputschema.test.ts b/src/server/mcp-outputschema.test.ts deleted file mode 100644 index e59bd563..00000000 --- a/src/server/mcp-outputschema.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { McpServer } from './mcp.js'; -import { Client } from '../client/index.js'; -import { InMemoryTransport } from '../inMemory.js'; -import { z } from 'zod'; - -describe('McpServer outputSchema support', () => { - let server: McpServer; - let client: Client; - let serverTransport: InMemoryTransport; - let clientTransport: InMemoryTransport; - - beforeEach(async () => { - server = new McpServer({ name: 'test', version: '1.0' }); - client = new Client({ name: 'test-client', version: '1.0' }); - - [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - }); - - afterEach(async () => { - await client.close(); - await server.close(); - }); - - describe('tool registration with outputSchema', () => { - it('should register a tool with outputSchema', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - }, - required: ['result', 'count'] - }; - - const tool = server.tool( - 'test-tool', - 'A test tool', - { input: z.string() }, - outputSchema, - () => ({ structuredContent: { result: 'test', count: 42 } }) - ); - - expect(tool.outputSchema).toEqual(outputSchema); - - // Connect after registering the tool - await server.connect(serverTransport); - await client.connect(clientTransport); - }); - - it('should include outputSchema in ListToolsResult', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ structuredContent: { result: 'test' } }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.listTools(); - expect(result.tools[0].outputSchema).toEqual(outputSchema); - }); - }); - - describe('tool execution with outputSchema', () => { - it('should return structuredContent and auto-generate content for backward compatibility', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ - structuredContent: { result: 'test', count: 42 } - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); - - expect(result.structuredContent).toEqual({ result: 'test', count: 42 }); - expect(result.content).toEqual([{ - type: 'text', - text: JSON.stringify({ result: 'test', count: 42 }, null, 2) - }]); - }); - - it('should preserve both content and structuredContent if tool provides both', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ - structuredContent: { result: 'test' }, - content: [{ type: 'text', text: 'Custom text' }] - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); - - expect(result.structuredContent).toEqual({ result: 'test' }); - expect(result.content).toEqual([{ type: 'text', text: 'Custom text' }]); - }); - - it('should throw error if tool with outputSchema returns no structuredContent', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'broken-tool', - { input: z.string() }, - outputSchema, - () => ({ - content: [{ type: 'text', text: 'No structured content' }] - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) - .rejects.toThrow('has outputSchema but returned no structuredContent'); - }); - - it('should throw error if tool without outputSchema returns structuredContent', async () => { - server.tool( - 'broken-tool', - { input: z.string() }, - () => ({ - structuredContent: { result: 'test' } - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Call listTools first (but in this case the tool has no outputSchema) - await client.listTools(); - - await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) - .rejects.toThrow('has no outputSchema but returned structuredContent'); - }); - - it('should handle error results properly for tools with outputSchema', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'error-tool', - { input: z.string() }, - outputSchema, - () => { - throw new Error('Tool error'); - } - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'error-tool', arguments: { input: 'test' } }); - - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ - type: 'text', - text: 'Tool error' - }]); - expect(result.structuredContent).toBeUndefined(); - }); - }); -}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index b9561739..a9a56c61 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -13,12 +13,16 @@ import { CompleteResultSchema, LoggingMessageNotificationSchema, Notification, + TextContent, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; describe("McpServer", () => { + /*** + * Test: Basic Server Instance + */ test("should expose underlying Server instance", () => { const mcpServer = new McpServer({ name: "test server", @@ -28,6 +32,9 @@ describe("McpServer", () => { expect(mcpServer.server).toBeDefined(); }); + /*** + * Test: Notification Sending via Server + */ test("should allow sending notifications via Server", async () => { const mcpServer = new McpServer( { @@ -75,6 +82,9 @@ describe("McpServer", () => { }); describe("ResourceTemplate", () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ test("should create ResourceTemplate with string pattern", () => { const template = new ResourceTemplate("test://{category}/{id}", { list: undefined, @@ -83,6 +93,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ test("should create ResourceTemplate with UriTemplate", () => { const uriTemplate = new UriTemplate("test://{category}/{id}"); const template = new ResourceTemplate(uriTemplate, { list: undefined }); @@ -90,6 +103,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate with List Callback + */ test("should create ResourceTemplate with list callback", async () => { const list = jest.fn().mockResolvedValue({ resources: [{ name: "Test", uri: "test://example" }], @@ -111,6 +127,9 @@ describe("ResourceTemplate", () => { }); describe("tool()", () => { + /*** + * Test: Zero-Argument Tool Registration + */ test("should register zero-argument tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -178,6 +197,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Updating Existing Tool + */ test("should update existing tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -244,6 +266,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Updating Tool with Schema + */ test("should update tool with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -339,6 +364,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Tool List Changed Notifications + */ test("should send tool list changed notifications when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -404,6 +432,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Tool Registration with Parameters + */ test("should register tool with params", async () => { const mcpServer = new McpServer({ name: "test server", @@ -414,6 +445,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool( "test", { @@ -430,6 +462,17 @@ describe("tool()", () => { }), ); + // new api + mcpServer.tool( + "test (new api)", + { + inputSchema: { name: z.string(), value: z.number() }, + callback: async ({ name, value }) => ({ + content: [{ type: "text", text: `${name}: ${value}` }], + }), + } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -445,7 +488,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", @@ -454,8 +497,13 @@ describe("tool()", () => { value: { type: "number" }, }, }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); }); + /*** + * Test: Tool Registration with Description + */ test("should register tool with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -466,6 +514,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool("test", "Test description", async () => ({ content: [ { @@ -475,6 +524,20 @@ describe("tool()", () => { ], })); + // new api + mcpServer.tool("test (new api)", { + description: "Test description", + callback: async ({}) => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -490,11 +553,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("Test description"); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("Test description"); }); - + + /*** + * Test: Tool Registration with Annotations + */ test("should register tool with annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -514,6 +582,18 @@ describe("tool()", () => { ], })); + mcpServer.tool("test (new api)", { + annotations: { title: "Test Tool", readOnlyHint: true }, + callback: async ({}) => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -529,11 +609,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); }); - + + /*** + * Test: Tool Registration with Parameters and Annotations + */ test("should register tool with params and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -553,6 +638,14 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + inputSchema: { name: z.string() }, + annotations: { title: "Test Tool", readOnlyHint: true }, + callback: async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -566,15 +659,21 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", properties: { name: { type: "string" } } }); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); - + + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ test("should register tool with description, params, and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -595,6 +694,15 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + description: "A tool with everything", + inputSchema: { name: z.string() }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + callback: async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -608,7 +716,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("A tool with everything"); expect(result.tools[0].inputSchema).toMatchObject({ @@ -620,8 +728,15 @@ describe("tool()", () => { readOnlyHint: true, openWorldHint: false }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ test("should register tool with description, empty params, and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -642,6 +757,15 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + description: "A tool with everything but empty params", + inputSchema: {}, + annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + callback: async ({}) => ({ + content: [{ type: "text", text: "Test response" }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -655,7 +779,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("A tool with everything but empty params"); expect(result.tools[0].inputSchema).toMatchObject({ @@ -667,8 +791,15 @@ describe("tool()", () => { readOnlyHint: true, openWorldHint: false }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything but empty params"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Argument Validation + */ test("should validate tool args", async () => { const mcpServer = new McpServer({ name: "test server", @@ -703,6 +834,21 @@ describe("tool()", () => { }), ); + mcpServer.tool("test (new api)", { + inputSchema: { + name: z.string(), + value: z.number(), + }, + callback: async ({ name, value }) => ({ + content: [ + { + type: "text", + text: `${name}: ${value}`, + }, + ], + }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -726,8 +872,27 @@ describe("tool()", () => { CallToolResultSchema, ), ).rejects.toThrow(/Invalid arguments/); + + await expect( + client.request( + { + method: "tools/call", + params: { + name: "test (new api)", + arguments: { + name: "test", + value: "not a number", + }, + }, + }, + CallToolResultSchema, + ), + ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Tool Registration + */ test("should prevent duplicate tool registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -755,6 +920,9 @@ describe("tool()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Tool Registration + */ test("should allow registering multiple tools", () => { const mcpServer = new McpServer({ name: "test server", @@ -768,6 +936,9 @@ describe("tool()", () => { mcpServer.tool("tool2", () => ({ content: [] })); }); + /*** + * Test: Tool with Output Schema and Structured Content + */ test("should support tool with outputSchema and structuredContent", async () => { const mcpServer = new McpServer({ name: "test server", @@ -787,35 +958,28 @@ describe("tool()", () => { ); // Register a tool with outputSchema - const registeredTool = mcpServer.tool( + mcpServer.tool( "test", - "Test tool with structured output", { - input: z.string(), + description: "Test tool with structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + callback: async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + }), }, - async ({ input }) => ({ - // When outputSchema is defined, return structuredContent instead of content - structuredContent: { - processedInput: input, - resultType: "structured", - timestamp: "2023-01-01T00:00:00Z" - }, - }), ); - // Update the tool to add outputSchema - registeredTool.update({ - outputSchema: { - type: "object", - properties: { - processedInput: { type: "string" }, - resultType: { type: "string" }, - timestamp: { type: "string", format: "date-time" } - }, - required: ["processedInput", "resultType", "timestamp"] - } - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -833,17 +997,17 @@ describe("tool()", () => { ); expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toEqual({ + expect(listResult.tools[0].outputSchema).toMatchObject({ type: "object", properties: { processedInput: { type: "string" }, resultType: { type: "string" }, - timestamp: { type: "string", format: "date-time" } + timestamp: { type: "string" } }, required: ["processedInput", "resultType", "timestamp"] }); - // Call the tool and verify it returns structuredContent + // Call the tool and verify it returns valid structuredContent const result = await client.request( { method: "tools/call", @@ -858,9 +1022,6 @@ describe("tool()", () => { ); expect(result.structuredContent).toBeDefined(); - // For backward compatibility, content is auto-generated from structuredContent - expect(result.content).toBeDefined(); - const structuredContent = result.structuredContent as { processedInput: string; resultType: string; @@ -869,8 +1030,85 @@ describe("tool()", () => { expect(structuredContent.processedInput).toBe("hello"); expect(structuredContent.resultType).toBe("structured"); expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z"); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: "text" }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test("should fail schema validation when tool returns invalid structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Register a tool with outputSchema that returns invalid data + mcpServer.tool( + "test", + { + description: "Test tool with invalid structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + callback: async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }, + }), + }, + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // First call listTools to cache the outputSchema in the client + await client.listTools(); + + // Call the tool and expect it to throw a validation error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).rejects.toThrow(/Structured content does not match the tool's output schema/); + }); + + /*** + * Test: Pass Session ID to Tool Callback + */ test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -924,6 +1162,9 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + /*** + * Test: Pass Request ID to Tool Callback + */ test("should pass requestId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -977,6 +1218,9 @@ describe("tool()", () => { expect(result.content && result.content[0].text).toContain("Received request ID:"); }); + /*** + * Test: Send Notification within Tool Call + */ test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { @@ -1034,6 +1278,9 @@ describe("tool()", () => { expect(receivedLogMessage).toBe(loggingMessage); }); + /*** + * Test: Client to Server Tool Call + */ test("should allow client to call server tools", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1097,6 +1344,9 @@ describe("tool()", () => { ]); }); + /*** + * Test: Graceful Tool Error Handling + */ test("should handle server tool errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1146,6 +1396,9 @@ describe("tool()", () => { ]); }); + /*** + * Test: McpError for Invalid Tool Name + */ test("should throw McpError for invalid tool name", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1196,6 +1449,9 @@ describe("tool()", () => { }); describe("resource()", () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ test("should register resource with uri and readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1235,6 +1491,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource"); }); + /*** + * Test: Update Resource with URI + */ test("should update resource with uri", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1297,6 +1556,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Update Resource Template + */ test("should update resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1363,6 +1625,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Resource List Changed Notification + */ test("should send resource list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1416,6 +1681,9 @@ describe("resource()", () => { ]); }); + /*** + * Test: Remove Resource and Send Notification + */ test("should remove resource and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1477,6 +1745,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource2"); }); + /*** + * Test: Remove Resource Template and Send Notification + */ test("should remove resource template and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1541,6 +1812,9 @@ describe("resource()", () => { expect(result2.resourceTemplates).toHaveLength(0); }); + /*** + * Test: Resource Registration with Metadata + */ test("should register resource with metadata", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1588,6 +1862,9 @@ describe("resource()", () => { expect(result.resources[0].mimeType).toBe("text/plain"); }); + /*** + * Test: Resource Template Registration + */ test("should register resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1633,6 +1910,9 @@ describe("resource()", () => { ); }); + /*** + * Test: Resource Template with List Callback + */ test("should register resource template with listCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1691,6 +1971,9 @@ describe("resource()", () => { expect(result.resources[1].uri).toBe("test://resource/2"); }); + /*** + * Test: Template Variables to Read Callback + */ test("should pass template variables to readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1737,6 +2020,9 @@ describe("resource()", () => { expect(result.contents[0].text).toBe("Category: books, ID: 123"); }); + /*** + * Test: Preventing Duplicate Resource Registration + */ test("should prevent duplicate resource registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1764,6 +2050,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Resource Registration + */ test("should allow registering multiple resources", () => { const mcpServer = new McpServer({ name: "test server", @@ -1791,6 +2080,9 @@ describe("resource()", () => { })); }); + /*** + * Test: Preventing Duplicate Resource Template Registration + */ test("should prevent duplicate resource template registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1826,6 +2118,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Graceful Resource Read Error Handling + */ test("should handle resource read errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1861,6 +2156,9 @@ describe("resource()", () => { ).rejects.toThrow(/Resource read failed/); }); + /*** + * Test: McpError for Invalid Resource URI + */ test("should throw McpError for invalid resource URI", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1901,6 +2199,9 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Resource Template Parameter Completion + */ test("should support completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1966,6 +2267,9 @@ describe("resource()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Resource Template Parameter Completion + */ test("should support filtered completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2034,6 +2338,9 @@ describe("resource()", () => { expect(result.completion.total).toBe(2); }); + /*** + * Test: Pass Request ID to Resource Callback + */ test("should pass requestId to resource callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2089,6 +2396,9 @@ describe("resource()", () => { }); describe("prompt()", () => { + /*** + * Test: Zero-Argument Prompt Registration + */ test("should register zero-argument prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2130,6 +2440,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("test"); expect(result.prompts[0].arguments).toBeUndefined(); }); + /*** + * Test: Updating Existing Prompt + */ test("should update existing prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2198,6 +2511,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Updating Prompt with Schema + */ test("should update prompt with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2291,6 +2607,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Prompt List Changed Notification + */ test("should send prompt list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2350,6 +2669,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Remove Prompt and Send Notification + */ test("should remove prompt and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2428,6 +2750,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("prompt2"); }); + /*** + * Test: Prompt Registration with Arguments Schema + */ test("should register prompt with args schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2480,6 +2805,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Prompt Registration with Description + */ test("should register prompt with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2522,6 +2850,9 @@ describe("prompt()", () => { expect(result.prompts[0].description).toBe("Test description"); }); + /*** + * Test: Prompt Argument Validation + */ test("should validate prompt args", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2584,6 +2915,9 @@ describe("prompt()", () => { ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Prompt Registration + */ test("should prevent duplicate prompt registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -2617,6 +2951,9 @@ describe("prompt()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Prompt Registration + */ test("should allow registering multiple prompts", () => { const mcpServer = new McpServer({ name: "test server", @@ -2650,6 +2987,9 @@ describe("prompt()", () => { })); }); + /*** + * Test: Prompt Registration with Arguments + */ test("should allow registering prompts with arguments", () => { const mcpServer = new McpServer({ name: "test server", @@ -2672,6 +3012,9 @@ describe("prompt()", () => { ); }); + /*** + * Test: Resources and Prompts with Completion Handlers + */ test("should allow registering both resources and prompts with completion handlers", () => { const mcpServer = new McpServer({ name: "test server", @@ -2713,6 +3056,9 @@ describe("prompt()", () => { ); }); + /*** + * Test: McpError for Invalid Prompt Name + */ test("should throw McpError for invalid prompt name", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2764,6 +3110,9 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + /*** + * Test: Prompt Argument Completion + */ test("should support completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2829,6 +3178,9 @@ describe("prompt()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Prompt Argument Completion + */ test("should support filtered completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2896,6 +3248,9 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); + /*** + * Test: Pass Request ID to Prompt Callback + */ test("should pass requestId to prompt callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", diff --git a/src/server/mcp.ts b/src/server/mcp.ts index faf8faaf..abb75279 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -90,7 +90,7 @@ export class McpServer { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -116,15 +116,17 @@ export class McpServer { description: tool.description, inputSchema: tool.inputSchema ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - }) as Tool["inputSchema"]) + strictUnions: true, + }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, }; - // Only include outputSchema if it's defined if (tool.outputSchema) { - toolDefinition.outputSchema = tool.outputSchema; + toolDefinition.outputSchema = zodToJsonSchema( + tool.outputSchema, + { strictUnions: true } + ) as Tool["outputSchema"]; } return toolDefinition; @@ -447,7 +449,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -530,7 +532,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -682,7 +684,7 @@ export class McpServer { paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with both parameter schema and annotations. */ @@ -692,7 +694,7 @@ export class McpServer { annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with description, parameter schema, and annotations. */ @@ -701,117 +703,92 @@ export class McpServer { description: string, paramsSchema: Args, annotations: ToolAnnotations, - cb: ToolCallback, + callback: ToolCallback, ): RegisteredTool; /** - * Registers a tool with output schema. + * Registers a tool from a config object. */ - tool( + tool( name: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - cb: ToolCallback, + config: { + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + callback: ToolCallback + } ): RegisteredTool; /** - * Registers a tool with description and output schema. + * tool() implementation. Parses arguments passed to overrides defined above. */ - tool( - name: string, - description: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - cb: ToolCallback, - ): RegisteredTool; - - /** - * Registers a tool with parameter schema, output schema, and annotations. - */ - tool( - name: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - annotations: ToolAnnotations, - cb: ToolCallback, - ): RegisteredTool; - - /** - * Registers a tool with description, parameter schema, output schema, and annotations. - */ - tool( - name: string, - description: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - annotations: ToolAnnotations, - cb: ToolCallback, - ): RegisteredTool; - tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } let description: string | undefined; - if (typeof rest[0] === "string") { - description = rest.shift() as string; - } - - let paramsSchema: ZodRawShape | undefined; - let outputSchema: Tool["outputSchema"] | undefined; + let inputSchema: ZodRawShape | undefined; + let outputSchema: ZodRawShape | undefined; let annotations: ToolAnnotations | undefined; - - // Handle the different overload combinations - if (rest.length > 1) { - // We have at least two more args before the callback - const firstArg = rest[0]; - - if (isZodRawShape(firstArg)) { - // We have a params schema as the first arg - paramsSchema = rest.shift() as ZodRawShape; - - // Check if the next arg is potentially annotations or outputSchema - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null) { - const nextArg = rest[0]; + let callback: ToolCallback; - // Check if it's a JSON Schema (outputSchema) - if (typeof nextArg === "object" && "type" in nextArg) { - outputSchema = rest.shift() as Tool["outputSchema"]; + if (rest.length == 1 && typeof rest[0] === "object" && rest[0] !== null && 'callback' in rest[0]) { + // New style: tool(name, { description, inputSchema, outputSchema, annotations, callback }) + // Future additions to tool definition should be added to config object. - // Check if there's still an annotations object - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { - annotations = rest.shift() as ToolAnnotations; - } - } else if (!(isZodRawShape(nextArg))) { - // It's annotations - annotations = rest.shift() as ToolAnnotations; - } - } - } else if (typeof firstArg === "object" && firstArg !== null) { - // Check if it's a JSON Schema (outputSchema) - if ("type" in firstArg) { - outputSchema = rest.shift() as Tool["outputSchema"]; - - // Check if there's still an annotations object + const config = rest[0] as { + description?: string; + inputSchema?: ZodRawShape; + outputSchema?: ZodRawShape; + annotations?: ToolAnnotations; + callback: ToolCallback; + }; + ({ description, inputSchema, outputSchema, annotations, callback } = config) ; + + } else { + // Old style: tool properties are passed as separate arguments, with omissions allowed. + // Support for this style is frozen as of protocol version 2025-03-26. Future additions + // to tool definition should *NOT* be added. + + if (typeof rest[0] === "string") { + description = rest.shift() as string; + } + + // Handle the different overload combinations + if (rest.length > 1) { + // We have at least one more arg before the callback + const firstArg = rest[0]; + + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + inputSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) annotations = rest.shift() as ToolAnnotations; } - } else { - // It's annotations + } else if (typeof firstArg === "object" && firstArg !== null) { + // Not a ZodRawShape, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) annotations = rest.shift() as ToolAnnotations; } } + callback = rest[0] as ToolCallback; } - const cb = rest[0] as ToolCallback; const registeredTool: RegisteredTool = { description, inputSchema: - paramsSchema === undefined ? undefined : z.object(paramsSchema), - outputSchema, + inputSchema === undefined ? undefined : z.object(inputSchema), + outputSchema: + outputSchema === undefined ? undefined : z.object(outputSchema), annotations, - callback: cb, + callback: callback, enabled: true, disable: () => registeredTool.update({ enabled: false }), enable: () => registeredTool.update({ enabled: true }), @@ -823,7 +800,6 @@ export class McpServer { } if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) - if (typeof updates.outputSchema !== "undefined") registeredTool.outputSchema = updates.outputSchema if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled @@ -1019,22 +995,31 @@ export class ResourceTemplate { */ export type ToolCallback = Args extends ZodRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; + ? ( + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => CallToolResult | Promise + : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; - outputSchema?: Tool["outputSchema"]; + outputSchema?: AnyZodObject; annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, outputSchema?: Tool["outputSchema"], callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void + update( + updates: { + name?: string | null, + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1107,23 +1092,23 @@ export type RegisteredResourceTemplate = { enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; type PromptArgsRawShape = { [k: string]: - | ZodType - | ZodOptional>; + | ZodType + | ZodOptional>; }; export type PromptCallback< Args extends undefined | PromptArgsRawShape = undefined, > = Args extends PromptArgsRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { diff --git a/src/types.ts b/src/types.ts index dc9ab5df..6b5a696a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -838,7 +838,14 @@ export const ToolSchema = z * If set, a CallToolResult for this Tool MUST contain a structuredContent field whose contents validate against this schema. * If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field. */ - outputSchema: z.optional(z.object({}).passthrough()), + outputSchema: z.optional( + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), /** * Optional additional tool information. */