diff --git a/.gitignore b/.gitignore index 6c4bf1a6..589c810b 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ out .DS_Store dist/ + +.vscode \ No newline at end of file diff --git a/README.md b/README.md index c9102260..a028b06a 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,25 @@ server.tool( }; } ); + +// Tool defined using JSON Schema +server.tool({ + name: "count-fingers", + paramsSchema: { + type: "object", + properties: { + hands: { type: "number" } + }, + additionalProperties: false + }, + cb: async (args) => { + return { + content: [ + { type: "text", text: String(args.hands * 5) } + ] + }; + } +}) ``` ### Prompts diff --git a/package-lock.json b/package-lock.json index 1165b751..8043a963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "license": "MIT", "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", @@ -1035,6 +1036,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", @@ -2198,15 +2223,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3216,6 +3241,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -3450,8 +3499,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -3500,6 +3548,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4880,10 +4944,10 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5558,6 +5622,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5662,6 +5727,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6445,6 +6519,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index f24053c5..b6cdb502 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "client": "tsx src/cli.ts client" }, "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c9be5c76..eaaee8d3 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -622,6 +622,140 @@ describe("tool()", () => { }); }); + describe('with options argument', () => { + test('should register tool with options', async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool({ + name: "test", + description: "A tool with everything", + paramsSchema: { + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false + }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + cb: async (args) => ({ + // @ts-expect-error - no type inference when using a JSON Schema object + content: [{ type: "text", text: `Hello, ${args.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"); + expect(result.tools[0].description).toBe("A tool with everything"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } } + }); + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", + readOnlyHint: true, + openWorldHint: false + }); + }); + + test("should validate tool args", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + mcpServer.tool({ + name: "test", + paramsSchema: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "number" } + }, + additionalProperties: false + }, + cb: async (args) => ({ + content: [ + { + type: "text", + // @ts-expect-error - no type inference when using a JSON Schema object + text: `${args.name}: ${args.value}`, + }, + ], + }), + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await expect( + client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + name: "test", + value: 123, + }, + }, + }, + CallToolResultSchema, + ), + ).resolves.toBeDefined(); + + await expect( + client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + name: "test", + value: "not a number", + }, + }, + }, + CallToolResultSchema, + ), + ).rejects.toThrow(/Invalid arguments/); + }); + }) + test("should validate tool args", async () => { const mcpServer = new McpServer({ name: "test server", diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 6204eb2a..39271b28 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1,5 +1,6 @@ import { Server, ServerOptions } from "./index.js"; -import { zodToJsonSchema } from "zod-to-json-schema"; +import { Ajv } from "ajv"; +import { JsonSchema7ObjectType, zodToJsonSchema } from "zod-to-json-schema"; import { z, ZodRawShape, @@ -46,6 +47,64 @@ import { UriTemplate, Variables } from "../shared/uriTemplate.js"; import { RequestHandlerExtra } from "../shared/protocol.js"; import { Transport } from "../shared/transport.js"; +type ParametersJsonSchema = { type: 'object' } & Partial; + +/** + * Helper to check if an object is a Zod schema (ZodRawShape) + */ +const isZodRawShape = (obj: unknown): obj is ZodRawShape => { + if (typeof obj !== "object" || obj === null) return false; + // Check that at least one property is a ZodType instance + return Object.values(obj as object).some((v) => v instanceof ZodType); +}; + +const isJsonSchemaObject = (obj: unknown): obj is ParametersJsonSchema => { + return Boolean( + obj && typeof obj === "object" && "type" in obj && typeof obj.type === "string" + ); +}; + +const asInputJsonSchema = (input: AnyZodObject | ParametersJsonSchema | undefined): Tool['inputSchema'] => { + if(input === undefined) { + return EMPTY_OBJECT_JSON_SCHEMA + } else if (isJsonSchemaObject(input)) { + return input; + } + + return zodToJsonSchema(input, { strictUnions: true }) as Tool['inputSchema']; +} + +const parseToolArguments = async (args: unknown, schema: AnyZodObject | ParametersJsonSchema): Promise => { + // We're dealing with a Zod schema, so we can use safeParseAsync + if('safeParseAsync' in schema) { + const parseResult = await schema.safeParseAsync(args); + + if (!parseResult.success) { + throw new Error(parseResult.error.message) + } + + return parseResult.data; + } + + const ajv = new Ajv({ removeAdditional: true }); + const validateFn = ajv.compile(schema); + const argsResult = structuredClone(args) as object; + + if (!validateFn(args)) { + throw new Error(ajv.errorsText(validateFn.errors)) + } + + return argsResult; +}; + +type ToolOptions = { + name: string, + description?: string, + paramsSchema?: Args, + annotations?: ToolAnnotations, + cb: ToolCallback, +} + /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. * For advanced usage (like sending notifications or setting custom request handlers), use the underlying @@ -90,7 +149,7 @@ export class McpServer { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -114,11 +173,7 @@ export class McpServer { return { name, description: tool.description, - inputSchema: tool.inputSchema - ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - }) as Tool["inputSchema"]) - : EMPTY_OBJECT_JSON_SCHEMA, + inputSchema: asInputJsonSchema(tool.inputSchema), annotations: tool.annotations, }; }, @@ -145,17 +200,17 @@ export class McpServer { } if (tool.inputSchema) { - const parseResult = await tool.inputSchema.safeParseAsync( - request.params.arguments, - ); - if (!parseResult.success) { + let args: object = {}; + try { + args = await parseToolArguments(request.params.arguments, tool.inputSchema); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); throw new McpError( ErrorCode.InvalidParams, - `Invalid arguments for tool ${request.params.name}: ${parseResult.error.message}`, + `Invalid arguments for tool ${request.params.name}: ${message}`, ); } - const args = parseResult.data; const cb = tool.callback as ToolCallback; try { return await Promise.resolve(cb(args, extra)); @@ -398,7 +453,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -481,7 +536,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -596,6 +651,11 @@ export class McpServer { } } + /** + * Registers a tool from the given options. + */ + tool(options: ToolOptions): RegisteredTool; + /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ @@ -633,7 +693,7 @@ export class McpServer { paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with both parameter schema and annotations. */ @@ -643,7 +703,7 @@ export class McpServer { annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with description, parameter schema, and annotations. */ @@ -655,54 +715,64 @@ export class McpServer { cb: ToolCallback, ): RegisteredTool; - tool(name: string, ...rest: unknown[]): RegisteredTool { - if (this._registeredTools[name]) { - throw new Error(`Tool ${name} is already registered`); - } - - // Helper to check if an object is a Zod schema (ZodRawShape) - const isZodRawShape = (obj: unknown): obj is ZodRawShape => { - if (typeof obj !== "object" || obj === null) return false; - // Check that at least one property is a ZodType instance - return Object.values(obj as object).some(v => v instanceof ZodType); - }; + tool(...args: unknown[]): RegisteredTool { + const firstArg = args.shift(); + const name = typeof firstArg === "string" ? firstArg : (firstArg as ToolOptions).name; let description: string | undefined; - if (typeof rest[0] === "string") { - description = rest.shift() as string; + let paramsSchema: AnyZodObject | ParametersJsonSchema | undefined; + let annotations: ToolAnnotations | undefined; + let cb: ToolCallback; + + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); } - let paramsSchema: 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 (typeof firstArg !== 'string') { + // We have a ToolOptions object + const options = firstArg as ToolOptions; + description = options.description; + if(options.paramsSchema) { + paramsSchema = isJsonSchemaObject(options.paramsSchema) + ? options.paramsSchema + : z.object(options.paramsSchema); + } + annotations = options.annotations; + cb = options.cb; + } else { + if (typeof args[0] === "string") { + description = args.shift() as string; + } - if (isZodRawShape(firstArg)) { - // We have a params schema as the first arg - paramsSchema = rest.shift() as ZodRawShape; + // Handle the different overload combinations + if (args.length > 1) { + // We have at least two more args before the callback + const firstArg = args[0]; - // 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; + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + paramsSchema = z.object(args.shift() as ZodRawShape); + + // Check if the next arg is potentially annotations + if (args.length > 1 && typeof args[0] === "object" && args[0] !== null && !(isZodRawShape(args[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) + annotations = args.shift() as ToolAnnotations; + } + } 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 = args.shift() as ToolAnnotations; } - } 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; } + + cb = args[0] as ToolCallback; } - const cb = rest[0] as ToolCallback; const registeredTool: RegisteredTool = { description, - inputSchema: - paramsSchema === undefined ? undefined : z.object(paramsSchema), + inputSchema: paramsSchema, annotations, callback: cb, enabled: true, @@ -904,19 +974,18 @@ export class ResourceTemplate { * * Parameters will include tool arguments, if applicable, as well as other request handler context. */ -export type ToolCallback = - Args extends ZodRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; +export type ToolCallback = + Args extends undefined + ? (extra: RequestHandlerExtra) => CallToolResult | Promise + : Args extends ZodRawShape + ? (args: z.objectOutputType, extra: RequestHandlerExtra) => CallToolResult | Promise + : (args: object, extra: RequestHandlerExtra) => CallToolResult | Promise export type RegisteredTool = { description?: string; - inputSchema?: AnyZodObject; + inputSchema?: AnyZodObject | ParametersJsonSchema; annotations?: ToolAnnotations; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void;