diff --git a/OUTPUTSCHEMA_CHANGES.md b/OUTPUTSCHEMA_CHANGES.md new file mode 100644 index 00000000..22b7faee --- /dev/null +++ b/OUTPUTSCHEMA_CHANGES.md @@ -0,0 +1,171 @@ +# OutputSchema Support Implementation + +This document summarizes the changes made to support tools with `outputSchema` in the MCP TypeScript SDK. + +## Changes Made + +### Server-Side Changes + +#### 1. Tool Registration (mcp.ts) + +- Added support for parsing and storing `outputSchema` when registering tools +- Updated the `tool()` method to handle outputSchema parameter in various overload combinations +- Added new overloads to support tools with outputSchema: + ```typescript + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + ``` + +#### 2. Tool Listing + +- Modified `ListToolsResult` handler to include outputSchema in tool definitions +- Only includes outputSchema in the response if it's defined for the tool + +#### 3. Tool Execution + +- Updated `CallToolRequest` handler to validate structured content based on outputSchema +- Added automatic backward compatibility: + - If a tool has outputSchema and returns `structuredContent` but no `content`, the server automatically generates a text representation + - This ensures compatibility with clients that don't support structured content +- Added validation to ensure: + - Tools with outputSchema must return structuredContent (unless error) + - Tools without outputSchema must not return structuredContent + - Tools without outputSchema must return content + +#### 4. Backward Compatibility + +The implementation maintains full backward compatibility: +- Tools without outputSchema continue to work as before +- Tools with outputSchema can optionally provide both `structuredContent` and `content` +- If only `structuredContent` is provided, `content` is auto-generated as JSON + +### Client-Side Changes + +#### 1. Schema Caching and Validation (index.ts) + +- Added `_cachedTools` and `_cachedToolOutputSchemas` maps to cache tool definitions and their parsed Zod schemas +- The client converts JSON Schema to Zod schema using the `json-schema-to-zod` library for runtime validation +- Added dependency: `json-schema-to-zod` for converting JSON schemas to Zod schemas + +#### 2. Tool Listing + +- Modified `listTools` to parse and cache output schemas: + - When a tool has an outputSchema, the client converts it to a Zod schema + - Schemas are cached for validation during tool calls + - Handles errors gracefully with warning logs if schema parsing fails + +#### 3. Tool Execution + +- Enhanced `callTool` method with comprehensive validation: + - Tools with outputSchema must return `structuredContent` (validates this requirement) + - Tools without outputSchema must not return `structuredContent` + - Validates structured content against the cached Zod schema + - Provides detailed error messages when validation fails + +#### 4. Error Handling + +The client throws `McpError` with appropriate error codes: +- `ErrorCode.InvalidRequest` when required structured content is missing or unexpected +- `ErrorCode.InvalidParams` when structured content doesn't match the schema + +### Testing + +#### Server Tests + +Added comprehensive test suite (`mcp-outputschema.test.ts`) covering: +- Tool registration with outputSchema +- ListToolsResult including outputSchema +- Tool execution with structured content +- Automatic backward compatibility behavior +- Error cases and validation + +#### Client Tests + +Added tests in `index.test.ts` covering: +- Validation of structured content against output schemas +- Error handling when structured content doesn't match schema +- Error handling when tools with outputSchema don't return structured content +- Error handling when tools without outputSchema return structured content +- Complex JSON schema validation including nested objects, arrays, and strict mode +- Validation of additional properties when `additionalProperties: false` + +### Examples + +Created two example servers: +1. `outputSchema.ts` - Using the low-level Server API +2. `mcpServerOutputSchema.ts` - Using the high-level McpServer API + +These examples demonstrate: +- Tools with structured output (weather data, CSV processing, BMI calculation) +- Tools that return both structured and readable content +- Traditional tools without outputSchema for comparison + +## API Usage + +### Registering a tool with outputSchema: + +```typescript +server.tool( + "calculate_bmi", + "Calculate BMI given height and weight", + { + height_cm: z.number(), + weight_kg: z.number() + }, + { + type: "object", + properties: { + bmi: { type: "number" }, + category: { type: "string" } + }, + required: ["bmi", "category"] + }, + async ({ height_cm, weight_kg }) => { + // Calculate BMI... + return { + structuredContent: { + bmi: calculatedBmi, + category: bmiCategory + } + }; + } +); +``` + +### Tool callback return values: + +- For tools with outputSchema: Return `{ structuredContent: {...} }` +- For backward compatibility: Optionally include `{ structuredContent: {...}, content: [...] }` +- For tools without outputSchema: Return `{ content: [...] }` as before + +## Implementation Summary + +### Key Design Decisions + +1. **Backward Compatibility**: The server automatically generates `content` from `structuredContent` for clients that don't support structured output +2. **Schema Validation**: The client validates all structured content against the tool's output schema using Zod +3. **Caching**: The client caches parsed schemas to avoid re-parsing on every tool call +4. **Error Handling**: Both client and server validate the correct usage of `structuredContent` vs `content` based on whether a tool has an outputSchema + +### Implementation Notes + +1. **Server Side**: + - Automatically handles backward compatibility by serializing structuredContent to JSON + - Validates that tools properly use structuredContent vs content based on their outputSchema + - All existing tools continue to work without changes + +2. **Client Side**: + - Converts JSON Schema to Zod schemas for runtime validation + - Caches schemas for performance + - Provides detailed validation errors when structured content doesn't match schemas + - Enforces proper usage of structuredContent based on outputSchema presence + +3. **Compatibility**: + - The implementation follows the spec requirements + - Maintains full backward compatibility + - Provides a good developer experience with clear error messages + - Ensures both old and new clients can work with servers that support outputSchema \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1165b751..ff089d81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -15,6 +15,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -4879,6 +4880,15 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-to-zod": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", + "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index c6a65847..f006b1ce 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 629feab7..09d4c22d 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -39,7 +39,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-03-26" + "MCP-Protocol-Version": "DRAFT-2025-v2" }); }); diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 5b4f332f..cd43a7e0 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, @@ -754,3 +755,520 @@ test("should handle request timeout", async () => { code: ErrorCode.RequestTimeout, }); }); + +describe('outputSchema validation', () => { + 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('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('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('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('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('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/ + ); + }); + + test('should throw error when tool without outputSchema returns 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: {}, + }, + // No outputSchema defined + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Incorrectly return structuredContent for a tool without outputSchema + return { + structuredContent: { result: 'This should not be 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 + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool without outputSchema cannot return structuredContent/ + ); + }); + +}); diff --git a/src/client/index.ts b/src/client/index.ts index a3edd0be..08640d45 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -39,7 +39,12 @@ import { SubscribeRequest, SUPPORTED_PROTOCOL_VERSIONS, UnsubscribeRequest, + Tool, + ErrorCode, + McpError, } from "../types.js"; +import { z } from "zod"; +import { parseSchema } from "json-schema-to-zod"; export type ClientOptions = ProtocolOptions & { /** @@ -86,6 +91,8 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; + private _cachedTools: Map = new Map(); + private _cachedToolOutputSchemas: Map = new Map(); /** * Initializes this client with the given name and version information. @@ -413,22 +420,91 @@ 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 outputSchema = this._cachedToolOutputSchemas.get(params.name); + if (outputSchema) { + // 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 validationResult = outputSchema.safeParse(result.structuredContent); + + if (!validationResult.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.error.message}` + ); + } + } 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)}` + ); + } + } + } else { + // If tool doesn't have outputSchema, it MUST NOT return structuredContent + if (result.structuredContent) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool without outputSchema cannot return structuredContent` + ); + } + } + + return result; } 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._cachedTools.clear(); + this._cachedToolOutputSchemas.clear(); + + for (const tool of result.tools) { + this._cachedTools.set(tool.name, tool); + + // If the tool has an outputSchema, create and cache the Zod schema + if (tool.outputSchema) { + try { + const zodSchemaCode = parseSchema(tool.outputSchema); + // The library returns a string of Zod code, we need to evaluate it + // Using Function constructor to safely evaluate the Zod schema + const createSchema = new Function('z', `return ${zodSchemaCode}`); + const zodSchema = createSchema(z); + this._cachedToolOutputSchemas.set(tool.name, zodSchema); + } catch (error) { + console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); + } + } + } + + return result; } async sendRootsListChanged() { diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 3783992d..17a77872 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,13 +61,19 @@ 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; }) => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach((item: { type: string; text?: string; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(` No content returned`); + } } // 3. Wait for all notifications (10 seconds) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c1501a57..2debacf6 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -341,13 +341,19 @@ async function callTool(name: string, args: Record): Promise { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(' No content returned'); + } } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 7646f0f7..06f35004 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,13 +173,19 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(' No content returned'); + } } catch (error) { console.log(`Error calling notification tool: ${error}`); } diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts new file mode 100644 index 00000000..87820b6a --- /dev/null +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -0,0 +1,236 @@ +#!/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-example", + version: "1.0.0", + } +); + +// Define a tool with structured output - Weather data +server.tool( + "get_weather", + "Get weather information for a city", + { + city: z.string().describe("City name"), + country: z.string().describe("Country code (e.g., US, UK)") + }, + { + 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"] + }, + async ({ city, country }: { 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 { + 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)] + } + } + }; + } +); + +// Define a tool for data processing with structured output +server.tool( + "process_csv", + "Process CSV data and return statistics", + { + csv_data: z.string().describe("CSV data as a string"), + delimiter: z.string().default(",").describe("CSV delimiter") + }, + { + type: "object", + properties: { + row_count: { type: "integer" }, + column_count: { type: "integer" }, + headers: { + type: "array", + items: { type: "string" } + }, + data_types: { + type: "object", + additionalProperties: { + type: "string", + enum: ["number", "string", "date", "boolean"] + } + }, + summary: { + type: "object", + additionalProperties: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + mean: { type: "number" }, + count: { type: "integer" } + } + } + } + }, + required: ["row_count", "column_count", "headers", "data_types"] + }, + async ({ csv_data, delimiter }) => { + const lines = csv_data.trim().split('\n'); + const headers = lines[0].split(delimiter).map(h => h.trim()); + const data = lines.slice(1).map(line => line.split(delimiter).map(cell => cell.trim())); + + // Infer data types + const dataTypes: { [key: string]: string } = {}; + const summary: { [key: string]: unknown } = {}; + + headers.forEach((header, idx) => { + const values = data.map(row => row[idx]); + const numericValues = values.filter(v => !isNaN(Number(v)) && v !== ''); + + if (numericValues.length === values.length) { + dataTypes[header] = "number"; + const numbers = numericValues.map(Number); + summary[header] = { + min: Math.min(...numbers), + max: Math.max(...numbers), + mean: numbers.reduce((a, b) => a + b, 0) / numbers.length, + count: numbers.length + }; + } else { + dataTypes[header] = "string"; + } + }); + + return { + structuredContent: { + row_count: data.length, + column_count: headers.length, + headers, + data_types: dataTypes, + summary + } + }; + } +); + +// Traditional tool without outputSchema for comparison +server.tool( + "echo", + "Echo back the input message", + { + message: z.string() + }, + async ({ message }) => { + return { + content: [ + { + type: "text", + text: `Echo: ${message}` + } + ] + }; + } +); + +// Tool that can return both structured and unstructured content +server.tool( + "hybrid_tool", + "Tool that returns both structured and readable content", + { + data: z.array(z.number()).describe("Array of numbers to analyze") + }, + { + type: "object", + properties: { + stats: { + type: "object", + properties: { + mean: { type: "number" }, + median: { type: "number" }, + std_dev: { type: "number" } + } + } + }, + required: ["stats"] + }, + async ({ data }) => { + const mean = data.reduce((a, b) => a + b, 0) / data.length; + const sorted = [...data].sort((a, b) => a - b); + const median = sorted.length % 2 === 0 + ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 + : sorted[Math.floor(sorted.length / 2)]; + const variance = data.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / data.length; + const std_dev = Math.sqrt(variance); + + return { + structuredContent: { + stats: { + mean: Math.round(mean * 100) / 100, + median: Math.round(median * 100) / 100, + std_dev: Math.round(std_dev * 100) / 100 + } + }, + // Also provide human-readable content for backward compatibility + content: [ + { + type: "text", + text: `Analysis of ${data.length} numbers: +- Mean: ${Math.round(mean * 100) / 100} +- Median: ${Math.round(median * 100) / 100} +- Standard Deviation: ${Math.round(std_dev * 100) / 100}` + } + ] + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("McpServer Output Schema Example 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/outputSchema.ts b/src/examples/server/outputSchema.ts new file mode 100644 index 00000000..cb17b4b6 --- /dev/null +++ b/src/examples/server/outputSchema.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Example MCP server demonstrating tool outputSchema support + * This server exposes tools that return structured data + */ + +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-example", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Tool with structured output +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "calculate_bmi", + description: "Calculate BMI given height and weight", + inputSchema: { + type: "object", + properties: { + height_cm: { type: "number", description: "Height in centimeters" }, + weight_kg: { type: "number", description: "Weight in kilograms" } + }, + required: ["height_cm", "weight_kg"] + }, + outputSchema: { + type: "object", + properties: { + bmi: { type: "number", description: "Body Mass Index" }, + category: { + type: "string", + enum: ["underweight", "normal", "overweight", "obese"], + description: "BMI category" + }, + healthy_weight_range: { + type: "object", + properties: { + min_kg: { type: "number" }, + max_kg: { type: "number" } + }, + required: ["min_kg", "max_kg"] + } + }, + required: ["bmi", "category", "healthy_weight_range"] + } + }, + { + name: "analyze_text", + description: "Analyze text and return structured insights", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "Text to analyze" } + }, + required: ["text"] + }, + outputSchema: { + type: "object", + properties: { + word_count: { type: "integer" }, + sentence_count: { type: "integer" }, + character_count: { type: "integer" }, + reading_time_minutes: { type: "number" }, + sentiment: { + type: "string", + enum: ["positive", "negative", "neutral"] + }, + key_phrases: { + type: "array", + items: { type: "string" } + } + }, + required: ["word_count", "sentence_count", "character_count", "reading_time_minutes"] + } + }, + { + name: "traditional_tool", + description: "A traditional tool without outputSchema", + inputSchema: { + type: "object", + properties: { + message: { type: "string" } + }, + required: ["message"] + } + } + ] +})); + +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + switch (request.params.name) { + case "calculate_bmi": { + const { height_cm, weight_kg } = request.params.arguments as { height_cm: number; weight_kg: number }; + + const height_m = height_cm / 100; + const bmi = weight_kg / (height_m * height_m); + + let category: string; + if (bmi < 18.5) category = "underweight"; + else if (bmi < 25) category = "normal"; + else if (bmi < 30) category = "overweight"; + else category = "obese"; + + // Calculate healthy weight range for normal BMI (18.5-24.9) + const min_healthy_bmi = 18.5; + const max_healthy_bmi = 24.9; + const min_healthy_weight = min_healthy_bmi * height_m * height_m; + const max_healthy_weight = max_healthy_bmi * height_m * height_m; + + // Return structured content matching the outputSchema + return { + structuredContent: { + bmi: Math.round(bmi * 10) / 10, + category, + healthy_weight_range: { + min_kg: Math.round(min_healthy_weight * 10) / 10, + max_kg: Math.round(max_healthy_weight * 10) / 10 + } + } + }; + } + + case "analyze_text": { + const { text } = request.params.arguments as { text: string }; + + // Simple text analysis + const words = text.trim().split(/\s+/); + const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + const wordsPerMinute = 200; // Average reading speed + + // Very simple sentiment analysis (for demo purposes) + const positiveWords = ["good", "great", "excellent", "happy", "positive", "amazing"]; + const negativeWords = ["bad", "poor", "terrible", "sad", "negative", "awful"]; + + let positiveCount = 0; + let negativeCount = 0; + words.forEach(word => { + if (positiveWords.includes(word.toLowerCase())) positiveCount++; + if (negativeWords.includes(word.toLowerCase())) negativeCount++; + }); + + let sentiment: string; + if (positiveCount > negativeCount) sentiment = "positive"; + else if (negativeCount > positiveCount) sentiment = "negative"; + else sentiment = "neutral"; + + // Extract key phrases (simple approach - just common bigrams) + const keyPhrases: string[] = []; + for (let i = 0; i < words.length - 1; i++) { + if (words[i].length > 3 && words[i + 1].length > 3) { + keyPhrases.push(`${words[i]} ${words[i + 1]}`); + } + } + + return { + structuredContent: { + word_count: words.length, + sentence_count: sentences.length, + character_count: text.length, + reading_time_minutes: Math.round((words.length / wordsPerMinute) * 10) / 10, + sentiment, + key_phrases: keyPhrases.slice(0, 5) // Top 5 phrases + } + }; + } + + case "traditional_tool": { + const { message } = request.params.arguments as { message: string }; + + // Traditional tool returns content array + return { + content: [ + { + type: "text", + text: `Processed message: ${message.toUpperCase()}` + } + ] + }; + } + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("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-outputschema.test.ts b/src/server/mcp-outputschema.test.ts new file mode 100644 index 00000000..62ea74d3 --- /dev/null +++ b/src/server/mcp-outputschema.test.ts @@ -0,0 +1,222 @@ +import { McpServer } from './mcp.js'; +import { Client } from '../client/index.js'; +import { InMemoryTransport } from '../inMemory.js'; +import { z } from 'zod'; + +describe('McpServer outputSchema support', () => { + let server: McpServer; + let client: Client; + let serverTransport: InMemoryTransport; + let clientTransport: InMemoryTransport; + + beforeEach(async () => { + server = new McpServer({ name: 'test', version: '1.0' }); + client = new Client({ name: 'test-client', version: '1.0' }); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + describe('tool registration with outputSchema', () => { + it('should register a tool with outputSchema', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'] + }; + + const tool = server.tool( + 'test-tool', + 'A test tool', + { input: z.string() }, + outputSchema, + () => ({ structuredContent: { result: 'test', count: 42 } }) + ); + + expect(tool.outputSchema).toEqual(outputSchema); + + // Connect after registering the tool + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + it('should include outputSchema in ListToolsResult', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ structuredContent: { result: 'test' } }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.listTools(); + expect(result.tools[0].outputSchema).toEqual(outputSchema); + }); + }); + + describe('tool execution with outputSchema', () => { + it('should return structuredContent and auto-generate content for backward compatibility', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ + structuredContent: { result: 'test', count: 42 } + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); + + expect(result.structuredContent).toEqual({ result: 'test', count: 42 }); + expect(result.content).toEqual([{ + type: 'text', + text: JSON.stringify({ result: 'test', count: 42 }, null, 2) + }]); + }); + + it('should preserve both content and structuredContent if tool provides both', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ + structuredContent: { result: 'test' }, + content: [{ type: 'text', text: 'Custom text' }] + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); + + expect(result.structuredContent).toEqual({ result: 'test' }); + expect(result.content).toEqual([{ type: 'text', text: 'Custom text' }]); + }); + + it('should throw error if tool with outputSchema returns no structuredContent', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'broken-tool', + { input: z.string() }, + outputSchema, + () => ({ + content: [{ type: 'text', text: 'No structured content' }] + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) + .rejects.toThrow('has outputSchema but returned no structuredContent'); + }); + + it('should throw error if tool without outputSchema returns structuredContent', async () => { + server.tool( + 'broken-tool', + { input: z.string() }, + () => ({ + structuredContent: { result: 'test' } + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Call listTools first (but in this case the tool has no outputSchema) + await client.listTools(); + + await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) + .rejects.toThrow('has no outputSchema but returned structuredContent'); + }); + + it('should handle error results properly for tools with outputSchema', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'error-tool', + { input: z.string() }, + outputSchema, + () => { + throw new Error('Tool error'); + } + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'error-tool', arguments: { input: 'test' } }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ + type: 'text', + text: 'Tool error' + }]); + expect(result.structuredContent).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c9be5c76..a61515c4 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -721,6 +721,109 @@ describe("tool()", () => { mcpServer.tool("tool2", () => ({ 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 + const registeredTool = mcpServer.tool( + "test", + "Test tool with structured output", + { + input: z.string(), + }, + async ({ input }) => ({ + // When outputSchema is defined, return structuredContent instead of content + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + }), + ); + + // Update the tool to add outputSchema + registeredTool.update({ + outputSchema: { + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["processedInput", "resultType", "timestamp"] + } + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + 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).toEqual({ + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["processedInput", "resultType", "timestamp"] + }); + + // Call the tool and verify it returns structuredContent + const result = await client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + input: "hello", + }, + }, + }, + CallToolResultSchema, + ); + + expect(result.structuredContent).toBeDefined(); + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe("hello"); + expect(structuredContent.resultType).toBe("structured"); + expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z"); + }); + test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -824,7 +927,7 @@ 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("should provide sendNotification within tool call", async () => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 6204eb2a..0d001287 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -111,7 +111,7 @@ export class McpServer { ([, tool]) => tool.enabled, ).map( ([name, tool]): Tool => { - return { + const toolDefinition: Tool = { name, description: tool.description, inputSchema: tool.inputSchema @@ -121,6 +121,13 @@ export class McpServer { : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, }; + + // Only include outputSchema if it's defined + if (tool.outputSchema) { + toolDefinition.outputSchema = tool.outputSchema; + } + + return toolDefinition; }, ), }), @@ -144,6 +151,8 @@ export class McpServer { ); } + let result: CallToolResult; + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -158,9 +167,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 +182,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 +195,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 has no outputSchema + if (result.structuredContent) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ); + } + + // 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`, + ); + } + } + + return result; }, ); @@ -655,6 +704,50 @@ export class McpServer { cb: ToolCallback, ): RegisteredTool; + /** + * Registers a tool with output schema. + */ + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description and output schema. + */ + tool( + name: string, + description: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with parameter schema, output schema, and annotations. + */ + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description, parameter schema, output schema, and annotations. + */ + tool( + name: string, + description: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -673,6 +766,7 @@ export class McpServer { } let paramsSchema: ZodRawShape | undefined; + let outputSchema: Tool["outputSchema"] | undefined; let annotations: ToolAnnotations | undefined; // Handle the different overload combinations @@ -684,17 +778,36 @@ export class McpServer { // We have a params schema as the first arg paramsSchema = rest.shift() as ZodRawShape; - // Check if the next arg is potentially annotations - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { - // Case: tool(name, paramsSchema, annotations, cb) - // Or: tool(name, description, paramsSchema, annotations, cb) - annotations = rest.shift() as ToolAnnotations; + // Check if the next arg is potentially annotations or outputSchema + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null) { + const nextArg = rest[0]; + + // Check if it's a JSON Schema (outputSchema) + if (typeof nextArg === "object" && "type" in nextArg) { + outputSchema = rest.shift() as Tool["outputSchema"]; + + // Check if there's still an annotations object + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (!(isZodRawShape(nextArg))) { + // It's annotations + annotations = rest.shift() as ToolAnnotations; + } } } else if (typeof firstArg === "object" && firstArg !== null) { - // 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; + // Check if it's a JSON Schema (outputSchema) + if ("type" in firstArg) { + outputSchema = rest.shift() as Tool["outputSchema"]; + + // Check if there's still an annotations object + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + annotations = rest.shift() as ToolAnnotations; + } + } else { + // It's annotations + annotations = rest.shift() as ToolAnnotations; + } } } @@ -703,6 +816,7 @@ export class McpServer { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), + outputSchema, annotations, callback: cb, enabled: true, @@ -716,6 +830,7 @@ export class McpServer { } if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + if (typeof updates.outputSchema !== "undefined") registeredTool.outputSchema = updates.outputSchema if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled @@ -903,6 +1018,11 @@ 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 @@ -915,12 +1035,13 @@ export type ToolCallback = export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; + outputSchema?: Tool["outputSchema"]; 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?: Args, outputSchema?: Tool["outputSchema"], callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void remove(): void }; diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..9347acf0 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + expect(LATEST_PROTOCOL_VERSION).toBe("DRAFT-2025-v2"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); diff --git a/src/types.ts b/src/types.ts index 2ee0f752..710212c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const LATEST_PROTOCOL_VERSION = "DRAFT-2025-v2"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2025-03-26", "2024-11-05", "2024-10-07", ]; @@ -829,8 +830,16 @@ 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({}).passthrough()), /** * Optional additional tool information. */ @@ -854,14 +863,76 @@ 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 +1383,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;