diff --git a/package-lock.json b/package-lock.json index ed986a69..59b21bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.11.3", "license": "MIT", "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -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 233fd9e1..c23610b8 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.5", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 5b4f332f..bbfa80fa 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -12,6 +12,7 @@ import { InitializeRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, + CallToolRequestSchema, CreateMessageRequestSchema, ListRootsRequestSchema, ErrorCode, @@ -20,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), @@ -75,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 = { @@ -123,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), @@ -165,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( @@ -228,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( @@ -291,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"; @@ -353,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( { @@ -433,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( { @@ -489,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( { @@ -535,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( { @@ -566,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"), @@ -645,6 +677,9 @@ test("should typecheck", () => { }); }); +/*** + * Test: Handle Client Cancelling a Request + */ test("should handle client cancelling a request", async () => { const server = new Server( { @@ -700,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( { @@ -754,3 +792,474 @@ test("should handle request timeout", async () => { code: ErrorCode.RequestTimeout, }); }); + +describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ + test('should validate structuredContent against outputSchema', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + return { + structuredContent: { result: 'success', count: 42 }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'test-tool' }); + 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', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: { result: 'success', count: 'not a number' }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + /*** + * 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', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return content instead of structuredContent + return { + content: [{ type: 'text', text: 'This should be structured content' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool test-tool has an output schema but did not return structured content/ + ); + }); + + /*** + * Test: Handle Tools Without outputSchema Normally + */ + test('should handle tools without outputSchema normally', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + // No outputSchema + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return regular content + return { + content: [{ type: 'text', text: 'Normal response' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should work normally without validation + const result = await client.callTool({ name: 'test-tool' }); + 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', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' }, + }, + required: ['created'], + }, + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'complex-tool') { + return { + structuredContent: { + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z', + }, + }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'complex-tool' }); + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + 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', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'strict-tool') { + // Return structured content with extra property + return { + structuredContent: { + name: 'John', + extraField: 'not allowed', + }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error due to additional property + await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + +}); diff --git a/src/client/index.ts b/src/client/index.ts index a3edd0be..70870633 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -39,7 +39,11 @@ import { SubscribeRequest, SUPPORTED_PROTOCOL_VERSIONS, UnsubscribeRequest, + Tool, + ErrorCode, + McpError, } from "../types.js"; +import { Ajv, type ValidateFunction } from "ajv"; export type ClientOptions = ProtocolOptions & { /** @@ -86,6 +90,8 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; + private _cachedToolOutputValidators: Map = new Map(); + private _ajv: InstanceType; /** * Initializes this client with the given name and version information. @@ -96,6 +102,7 @@ export class Client< ) { super(options); this._capabilities = options?.capabilities ?? {}; + this._ajv = new Ajv({ strict: false, validateFormats: true }); } /** @@ -413,22 +420,84 @@ export class Client< | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/call", params }, resultSchema, options, ); + + // Check if the tool has an outputSchema + const validator = this.getToolOutputValidator(params.name); + if (validator) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content (which is already an object) against the schema + const isValid = validator(result.structuredContent); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + return result; + } + + private cacheToolOutputSchemas(tools: Tool[]) { + this._cachedToolOutputValidators.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the Ajv validator + if (tool.outputSchema) { + try { + const validator = this._ajv.compile(tool.outputSchema); + this._cachedToolOutputValidators.set(tool.name, validator); + } catch (error) { + console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`); + } + } + } + } + + private getToolOutputValidator(toolName: string): ValidateFunction | undefined { + return this._cachedToolOutputValidators.get(toolName); } async listTools( params?: ListToolsRequest["params"], options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/list", params }, ListToolsResultSchema, options, ); + + // Cache the tools and their output schemas for future validation + this.cacheToolOutputSchemas(result.tools); + + return result; } async sendRootsListChanged() { diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 3783992d..83c101b7 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,7 +61,7 @@ async function main(): Promise { // Log the results from each tool call for (const [caller, result] of Object.entries(toolResults)) { console.log(`\n=== Tool result for ${caller} ===`); - result.content.forEach((item: { type: string; text?: string; }) => { + result.content?.forEach((item: { type: string; text?: string; }) => { if (item.type === 'text') { console.log(` ${item.text}`); } else { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c1501a57..9a20f03a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -341,7 +341,7 @@ async function callTool(name: string, args: Record): Promise { + result.content?.forEach(item => { if (item.type === 'text') { console.log(` ${item.text}`); } else { diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 7646f0f7..aaefc680 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,7 +173,7 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - result.content.forEach(item => { + result.content?.forEach(item => { if (item.type === 'text') { console.log(` ${item.text}`); } else { diff --git a/src/examples/client/testOutputSchemaServers.ts b/src/examples/client/testOutputSchemaServers.ts new file mode 100755 index 00000000..5542ec36 --- /dev/null +++ b/src/examples/client/testOutputSchemaServers.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Client to test outputSchema servers + * This client connects to either the high-level or low-level outputSchema server + * and calls the get_weather tool to demonstrate structured output + */ + +import { Client } from "../../client/index.js"; +import { StdioClientTransport } from "../../client/stdio.js"; + +async function main() { + const serverPath = process.argv[2]; + + if (!serverPath) { + console.error("Usage: npx tsx src/examples/client/testOutputSchemaServers.ts "); + console.error("Example: npx tsx src/examples/client/testOutputSchemaServers.ts ../server/mcpServerOutputSchema.ts"); + process.exit(1); + } + + console.log(`Connecting to ${serverPath}...`); + + // Create transport that spawns the server process + const transport = new StdioClientTransport({ + command: "npx", + args: ["tsx", serverPath] + }); + + const client = new Client({ + name: "output-schema-test-client", + version: "1.0.0" + }, { + capabilities: {} + }); + + try { + await client.connect(transport); + console.log("Connected to server\n"); + + // List available tools + console.log("Listing available tools..."); + const toolsResult = await client.listTools(); + + console.log("Available tools:"); + for (const tool of toolsResult.tools) { + console.log(`- ${tool.name}: ${tool.description}`); + if (tool.outputSchema) { + console.log(" Has outputSchema: Yes"); + console.log(" Output schema:", JSON.stringify(tool.outputSchema, null, 2)); + } else { + console.log(" Has outputSchema: No"); + } + } + + // Call the weather tool + console.log("\nCalling get_weather tool..."); + // Note: Output schema validation only works when using the high-level Client API + // methods like callTool(). Low-level protocol requests do not validate the response. + const weatherResult = await client.callTool({ + name: "get_weather", + arguments: { + city: "London", + country: "UK" + } + }); + + console.log("\nWeather tool result:"); + if (weatherResult.structuredContent) { + console.log("Structured content:"); + console.log(JSON.stringify(weatherResult.structuredContent, null, 2)); + } + + if (weatherResult.content && Array.isArray(weatherResult.content)) { + console.log("Unstructured content:"); + weatherResult.content.forEach(content => { + if (content.type === "text") { + console.log(content.text); + } + }); + } + + await client.close(); + console.log("\nDisconnected from server"); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/src/examples/server/lowLevelOutputSchema.ts b/src/examples/server/lowLevelOutputSchema.ts new file mode 100644 index 00000000..dfb28415 --- /dev/null +++ b/src/examples/server/lowLevelOutputSchema.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Example MCP server demonstrating tool outputSchema support using the low-level Server API + * This server manually handles tool listing and invocation requests to return structured data + * For a simpler high-level API approach, see mcpServerOutputSchema.ts + */ + +import { Server } from "../../server/index.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; + +const server = new Server( + { + name: "output-schema-low-level-example", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Tool with structured output +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_weather", + description: "Get weather information for a city", + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + country: { type: "string", description: "Country code (e.g., US, UK)" } + }, + required: ["city", "country"] + }, + outputSchema: { + type: "object", + properties: { + temperature: { + type: "object", + properties: { + celsius: { type: "number" }, + fahrenheit: { type: "number" } + }, + required: ["celsius", "fahrenheit"] + }, + conditions: { + type: "string", + enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] + }, + humidity: { type: "number", minimum: 0, maximum: 100 }, + wind: { + type: "object", + properties: { + speed_kmh: { type: "number" }, + direction: { type: "string" } + }, + required: ["speed_kmh", "direction"] + } + }, + required: ["temperature", "conditions", "humidity", "wind"] + } + } + ] +})); + +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + switch (request.params.name) { + case "get_weather": { + const { city, country } = request.params.arguments as { city: string; country: string }; + + // Parameters are available but not used in this example + void city; + void country; + + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; + + // Return structured content matching the outputSchema + return { + structuredContent: { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + } + }; + } + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Low-level Output Schema Example Server running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts new file mode 100644 index 00000000..65230fc7 --- /dev/null +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * Example MCP server using the high-level McpServer API with outputSchema + * This demonstrates how to easily create tools with structured output + */ + +import { McpServer } from "../../server/mcp.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer( + { + name: "mcp-output-schema-high-level-example", + version: "1.0.0", + } +); + +// Define a tool with structured output - Weather data +server.registerTool( + "get_weather", + { + description: "Get weather information for a city", + inputSchema:{ + city: z.string().describe("City name"), + country: z.string().describe("Country code (e.g., US, UK)") + }, + outputSchema: { + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) + }, + }, + async ({ city, country }) => { + // Parameters are available but not used in this example + void city; + void country; + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; + + return { + structuredContent: { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + } + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("High-level Output Schema Example Server running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 5f50df68..73103208 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.registerTool( + "test (new api)", + { + inputSchema: { name: z.string(), value: z.number() }, + }, + 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,23 @@ describe("tool()", () => { ], })); + // new api + mcpServer.registerTool( + "test (new api)", + { + description: "Test description", + }, + async () => ({ + content: [ + { + type: "text" as const, + text: "Test response", + }, + ], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -490,11 +556,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 +585,21 @@ describe("tool()", () => { ], })); + mcpServer.registerTool( + "test (new api)", + { + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async () => ({ + content: [ + { + type: "text" as const, + text: "Test response", + }, + ], + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -529,11 +615,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 +644,17 @@ describe("tool()", () => { }) ); + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { name: z.string() }, + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -566,15 +668,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 +703,18 @@ describe("tool()", () => { }) ); + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything", + inputSchema: { name: z.string() }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -608,7 +728,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 +740,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 +769,18 @@ describe("tool()", () => { }) ); + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything but empty params", + inputSchema: {}, + annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + }, + async () => ({ + content: [{ type: "text" as const, text: "Test response" }] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -655,7 +794,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 +806,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 +849,24 @@ describe("tool()", () => { }), ); + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { + name: z.string(), + value: z.number(), + }, + }, + async ({ name, value }) => ({ + content: [ + { + type: "text", + text: `${name}: ${value}`, + }, + ], + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -726,8 +890,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 +938,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 +954,179 @@ 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", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Register a tool with outputSchema + mcpServer.registerTool( + "test", + { + description: "Test tool with structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].outputSchema).toMatchObject({ + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string" } + }, + required: ["processedInput", "resultType", "timestamp"] + }); + + // Call the tool and verify it returns valid structuredContent + const result = await client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + input: "hello", + }, + }, + }, + CallToolResultSchema, + ); + + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + 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.registerTool( + "test", + { + description: "Test tool with invalid structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + }, + 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", @@ -821,6 +1180,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", @@ -871,9 +1233,12 @@ describe("tool()", () => { expect(receivedRequestId).toBeDefined(); expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content[0].text).toContain("Received request ID:"); + 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( { @@ -931,6 +1296,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", @@ -994,6 +1362,9 @@ describe("tool()", () => { ]); }); + /*** + * Test: Graceful Tool Error Handling + */ test("should handle server tool errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1043,6 +1414,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", @@ -1093,6 +1467,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", @@ -1132,6 +1509,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", @@ -1194,6 +1574,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Update Resource Template + */ test("should update resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1260,6 +1643,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", @@ -1313,6 +1699,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", @@ -1374,6 +1763,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", @@ -1438,6 +1830,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", @@ -1485,6 +1880,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", @@ -1530,6 +1928,9 @@ describe("resource()", () => { ); }); + /*** + * Test: Resource Template with List Callback + */ test("should register resource template with listCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1588,6 +1989,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", @@ -1634,6 +2038,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", @@ -1661,6 +2068,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Resource Registration + */ test("should allow registering multiple resources", () => { const mcpServer = new McpServer({ name: "test server", @@ -1688,6 +2098,9 @@ describe("resource()", () => { })); }); + /*** + * Test: Preventing Duplicate Resource Template Registration + */ test("should prevent duplicate resource template registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1723,6 +2136,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", @@ -1758,6 +2174,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", @@ -1798,6 +2217,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", @@ -1863,6 +2285,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", @@ -1931,6 +2356,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", @@ -1986,6 +2414,9 @@ describe("resource()", () => { }); describe("prompt()", () => { + /*** + * Test: Zero-Argument Prompt Registration + */ test("should register zero-argument prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2027,6 +2458,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", @@ -2095,6 +2529,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", @@ -2188,6 +2625,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", @@ -2247,6 +2687,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", @@ -2325,6 +2768,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", @@ -2377,6 +2823,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Prompt Registration with Description + */ test("should register prompt with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2419,6 +2868,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", @@ -2481,6 +2933,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", @@ -2514,6 +2969,9 @@ describe("prompt()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Prompt Registration + */ test("should allow registering multiple prompts", () => { const mcpServer = new McpServer({ name: "test server", @@ -2547,6 +3005,9 @@ describe("prompt()", () => { })); }); + /*** + * Test: Prompt Registration with Arguments + */ test("should allow registering prompts with arguments", () => { const mcpServer = new McpServer({ name: "test server", @@ -2569,6 +3030,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", @@ -2610,6 +3074,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", @@ -2661,6 +3128,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", @@ -2726,6 +3196,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", @@ -2793,6 +3266,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 97459fb1..a5f2d0f1 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, ); @@ -111,16 +111,25 @@ export class McpServer { ([, tool]) => tool.enabled, ).map( ([name, tool]): Tool => { - return { + const toolDefinition: Tool = { name, 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, }; + + if (tool.outputSchema) { + toolDefinition.outputSchema = zodToJsonSchema( + tool.outputSchema, + { strictUnions: true } + ) as Tool["outputSchema"]; + } + + return toolDefinition; }, ), }), @@ -144,6 +153,8 @@ export class McpServer { ); } + let result: CallToolResult; + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -158,9 +169,9 @@ export class McpServer { const args = parseResult.data; const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)); + result = await Promise.resolve(cb(args, extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -173,9 +184,9 @@ export class McpServer { } else { const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(extra)); + result = await Promise.resolve(cb(extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -186,6 +197,46 @@ export class McpServer { }; } } + + // Handle structured output and backward compatibility + if (tool.outputSchema) { + // Tool has outputSchema, so result must have structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has outputSchema but returned no structuredContent`, + ); + } + + // For backward compatibility, if structuredContent is provided but no content, + // automatically serialize the structured content to text + if (result.structuredContent && !result.content) { + result.content = [ + { + type: "text", + text: JSON.stringify(result.structuredContent, null, 2), + }, + ]; + } + } else { + // Tool must have content if no outputSchema + if (!result.content && !result.isError) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema and must return content`, + ); + } + + // If structuredContent is provided, it's an error + if (result.structuredContent) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ); + } + } + + return result; }, ); @@ -398,7 +449,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -481,7 +532,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -596,6 +647,47 @@ export class McpServer { } } + private _createRegisteredTool( + name: string, + description: string | undefined, + inputSchema: ZodRawShape | undefined, + outputSchema: ZodRawShape | undefined, + annotations: ToolAnnotations | undefined, + callback: ToolCallback + ): RegisteredTool { + const registeredTool: RegisteredTool = { + description, + inputSchema: + inputSchema === undefined ? undefined : z.object(inputSchema), + outputSchema: + outputSchema === undefined ? undefined : z.object(outputSchema), + annotations, + callback, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool + } + if (typeof updates.description !== "undefined") registeredTool.description = updates.description + if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + 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 + this.sendToolListChanged() + }, + }; + this._registeredTools[name] = registeredTool; + + this.setToolRequestHandlers(); + this.sendToolListChanged() + + return registeredTool + } + /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ @@ -633,7 +725,7 @@ export class McpServer { paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with both parameter schema and annotations. */ @@ -643,7 +735,7 @@ export class McpServer { annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with description, parameter schema, and annotations. */ @@ -655,29 +747,38 @@ export class McpServer { cb: ToolCallback, ): RegisteredTool; + + /** + * tool() implementation. Parses arguments passed to overrides defined 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: ZodRawShape | undefined; + let outputSchema: ZodRawShape | undefined; + let annotations: ToolAnnotations | undefined; + + // 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; } - 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 + // 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 - paramsSchema = rest.shift() as ZodRawShape; - - // Check if the next arg is potentially annotations + 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) @@ -690,37 +791,38 @@ export class McpServer { annotations = rest.shift() as ToolAnnotations; } } + const callback = rest[0] as ToolCallback; - const cb = rest[0] as ToolCallback; - const registeredTool: RegisteredTool = { - description, - inputSchema: - paramsSchema === undefined ? undefined : z.object(paramsSchema), - annotations, - callback: cb, - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredTools[name] - if (updates.name) this._registeredTools[updates.name] = registeredTool - } - if (typeof updates.description !== "undefined") registeredTool.description = updates.description - if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) - 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 - this.sendToolListChanged() - }, - }; - this._registeredTools[name] = registeredTool; + return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback) + } - this.setToolRequestHandlers(); - this.sendToolListChanged() + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + }, + cb: ToolCallback + ): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } - return registeredTool + const { description, inputSchema, outputSchema, annotations } = config; + + return this._createRegisteredTool( + name, + description, + inputSchema, + outputSchema, + annotations, + cb as ToolCallback + ) } /** @@ -896,24 +998,39 @@ export class ResourceTemplate { * Callback for a tool handler registered with Server.tool(). * * Parameters will include tool arguments, if applicable, as well as other request handler context. + * + * The callback should return: + * - `structuredContent` if the tool has an outputSchema defined + * - `content` if the tool does not have an outputSchema + * - Both fields are optional but typically one should be provided */ 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?: AnyZodObject; annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, 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 }; @@ -986,23 +1103,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 2ee0f752..2e31a6b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -829,8 +829,23 @@ export const ToolSchema = z .object({ type: z.literal("object"), properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), }) .passthrough(), + /** + * An optional JSON Schema object defining the structure of the tool's output. + * + * 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({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), /** * Optional additional tool information. */ @@ -854,14 +869,79 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({ /** * The server's response to a tool call. + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. */ -export const CallToolResultSchema = ResultSchema.extend({ - content: z.array( - z.union([TextContentSchema, ImageContentSchema, AudioContentSchema, EmbeddedResourceSchema]), - ), - isError: z.boolean().default(false).optional(), +export const ContentListSchema = z.array( + z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + EmbeddedResourceSchema, + ]), +); + +export const CallToolUnstructuredResultSchema = ResultSchema.extend({ + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool does not define an outputSchema, this field MUST be present in the result. + */ + content: ContentListSchema, + + /** + * Structured output must not be provided in an unstructured tool result. + */ + structuredContent: z.never().optional(), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError: z.optional(z.boolean()), +}); + +export const CallToolStructuredResultSchema = ResultSchema.extend({ + /** + * An object containing structured tool output. + * + * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: z.object({}).passthrough(), + + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool defines an outputSchema, this field MAY be present in the result. + * + * Tools may use this field to provide compatibility with older clients that + * do not support structured content. + * + * Clients that support structured content should ignore this field. + */ + content: z.optional(ContentListSchema), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError: z.optional(z.boolean()), }); +export const CallToolResultSchema = z.union([ + CallToolUnstructuredResultSchema, + CallToolStructuredResultSchema, +]); + /** * CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07. */ @@ -1312,6 +1392,9 @@ export type ToolAnnotations = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; +export type ContentList = Infer; +export type CallToolUnstructuredResult = Infer; +export type CallToolStructuredResult = Infer; export type CallToolResult = Infer; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer;