From 882e0b7d05c95af3ad3736612743dcdfe3e4745b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 6 May 2025 17:16:38 -0400 Subject: [PATCH] feat: update TypeScript SDK to implement draft spec changes for structured tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add outputSchema support to Tool interface with proper documentation - Split CallToolResult into structured and unstructured variants - Change structuredContent from string to object type - Add validation that tools without outputSchema cannot return structuredContent - Add validation that tools with outputSchema must return structuredContent - Update client to validate structured content as object (no JSON parsing) - Update tests to use object format for structuredContent - Add tests for new validation constraints - Update LATEST_PROTOCOL_VERSION to DRAFT-2025-v2 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/auth.test.ts | 2 +- src/client/index.test.ts | 153 ++++++++++++++++++++++++++++++++++++--- src/client/index.ts | 15 ++-- src/server/mcp.test.ts | 16 ++-- src/types.test.ts | 2 +- src/types.ts | 87 +++++++++++++++++++--- 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eba7074b..5aa8a5f3 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 72ea63a8..25e13217 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -802,7 +802,7 @@ describe('outputSchema validation', () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'test-tool') { return { - structuredContent: JSON.stringify({ result: 'success', count: 42 }), + structuredContent: { result: 'success', count: 42 }, }; } throw new Error('Unknown tool'); @@ -825,7 +825,7 @@ describe('outputSchema validation', () => { // Call the tool - should validate successfully const result = await client.callTool({ name: 'test-tool' }); - expect(result.structuredContent).toBe('{"result":"success","count":42}'); + expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); }); test('should throw error when structuredContent does not match schema', async () => { @@ -874,7 +874,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'test-tool') { // Return invalid structured content (count is string instead of number) return { - structuredContent: JSON.stringify({ result: 'success', count: 'not a number' }), + structuredContent: { result: 'success', count: 'not a number' }, }; } throw new Error('Unknown tool'); @@ -1094,7 +1094,7 @@ describe('outputSchema validation', () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'complex-tool') { return { - structuredContent: JSON.stringify({ + structuredContent: { name: 'John Doe', age: 30, active: true, @@ -1102,7 +1102,7 @@ describe('outputSchema validation', () => { metadata: { created: '2023-01-01T00:00:00Z', }, - }), + }, }; } throw new Error('Unknown tool'); @@ -1126,9 +1126,9 @@ describe('outputSchema validation', () => { // Call the tool - should validate successfully const result = await client.callTool({ name: 'complex-tool' }); expect(result.structuredContent).toBeDefined(); - const parsedContent = JSON.parse(result.structuredContent as string); - expect(parsedContent.name).toBe('John Doe'); - expect(parsedContent.age).toBe(30); + 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 () => { @@ -1176,10 +1176,10 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { - structuredContent: JSON.stringify({ + structuredContent: { name: 'John', extraField: 'not allowed', - }), + }, }; } throw new Error('Unknown tool'); @@ -1205,4 +1205,137 @@ describe('outputSchema validation', () => { /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/ + ); + }); + + test('should throw error when structuredContent is not an object', 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' }, + }, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Try to return a non-object value as structuredContent + return { + structuredContent: "This should be an object, not a string" as any, + }; + } + 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(); + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 6fbae852..945be0fa 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -438,11 +438,8 @@ export class Client< } try { - // Parse the structured content as JSON - const contentData = JSON.parse(result.structuredContent as string); - - // Validate the content against the schema - const validationResult = outputSchema.safeParse(contentData); + // Validate the structured content (which is already an object) against the schema + const validationResult = outputSchema.safeParse(result.structuredContent); if (!validationResult.success) { throw new McpError( @@ -459,6 +456,14 @@ export class Client< `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; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 433fc8f2..cacba7e2 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -795,11 +795,11 @@ describe("tool()", () => { }, async ({ input }) => ({ // When outputSchema is defined, return structuredContent instead of content - structuredContent: JSON.stringify({ + structuredContent: { processedInput: input, resultType: "structured", timestamp: "2023-01-01T00:00:00Z" - }), + }, }), ); @@ -860,10 +860,14 @@ describe("tool()", () => { expect(result.structuredContent).toBeDefined(); expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used - const parsed = JSON.parse(result.structuredContent || "{}"); - expect(parsed.processedInput).toBe("hello"); - expect(parsed.resultType).toBe("structured"); - expect(parsed.timestamp).toBe("2023-01-01T00:00:00Z"); + 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 () => { 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 2d5ae51d..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,12 +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(), /** - * A JSON Schema object defining the expected output for the tool. + * 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.object({type: z.any()}).passthrough().optional(), + outputSchema: z.optional(z.object({}).passthrough()), /** * Optional additional tool information. */ @@ -858,15 +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]), - ).optional(), - structuredContent: z.string().optional(), - 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. */ @@ -1317,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;