diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 83c101b7..3783992d 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 9a20f03a..0328f0d2 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -264,12 +264,12 @@ async function terminateSession(): Promise { console.log('Terminating session with ID:', transport.sessionId); await transport.terminateSession(); console.log('Session terminated successfully'); - + // Check if sessionId was cleared after termination if (!transport.sessionId) { console.log('Session ID has been cleared'); sessionId = undefined; - + // Also close the transport and clear client objects await transport.close(); console.log('Transport closed after session termination'); @@ -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 { @@ -456,7 +456,7 @@ async function cleanup(): Promise { console.error('Error terminating session:', error); } } - + // Then close the transport await transport.close(); } catch (error) { diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index aaefc680..7646f0f7 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 deleted file mode 100755 index 5542ec36..00000000 --- a/src/examples/client/testOutputSchemaServers.ts +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 deleted file mode 100644 index dfb28415..00000000 --- a/src/examples/server/lowLevelOutputSchema.ts +++ /dev/null @@ -1,116 +0,0 @@ -#!/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 index 65230fc7..75bfe690 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -20,7 +20,7 @@ server.registerTool( "get_weather", { description: "Get weather information for a city", - inputSchema:{ + inputSchema: { city: z.string().describe("City name"), country: z.string().describe("Country code (e.g., US, UK)") }, @@ -45,20 +45,26 @@ server.registerTool( 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)] - } + const 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)] } }; + + return { + content: [{ + type: "text", + text: JSON.stringify(structuredContent, null, 2) + }], + structuredContent + }; } ); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 1b2f4d4b..37f0c228 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -398,7 +398,7 @@ describe("tool()", () => { name: z.string(), value: z.number(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ content: [ { type: "text", @@ -729,7 +729,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", { name: z.string() }, { title: "Test Tool", readOnlyHint: true }, async ({ name }) => ({ @@ -787,7 +787,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", "A tool with everything", { name: z.string() }, { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, @@ -828,8 +828,8 @@ describe("tool()", () => { type: "object", properties: { name: { type: "string" } } }); - expect(result.tools[0].annotations).toEqual({ - title: "Complete Test Tool", + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }); @@ -853,7 +853,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", "A tool with everything but empty params", {}, { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, @@ -894,8 +894,8 @@ describe("tool()", () => { type: "object", properties: {} }); - expect(result.tools[0].annotations).toEqual({ - title: "Complete Test Tool with empty params", + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }); @@ -1077,9 +1077,9 @@ describe("tool()", () => { input: z.string(), }, outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() }, }, async ({ input }) => ({ @@ -1088,6 +1088,16 @@ describe("tool()", () => { resultType: "structured", timestamp: "2023-01-01T00:00:00Z" }, + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }), + }, + ] }) ); @@ -1186,6 +1196,17 @@ describe("tool()", () => { }, }, async ({ input }) => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }), + }, + ], structuredContent: { processedInput: input, resultType: "structured", @@ -1203,10 +1224,7 @@ describe("tool()", () => { 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 + // Call the tool and expect it to throw a server-side validation error await expect( client.callTool({ name: "test", @@ -1214,7 +1232,7 @@ describe("tool()", () => { input: "hello", }, }), - ).rejects.toThrow(/Structured content does not match the tool's output schema/); + ).rejects.toThrow(/Invalid structured content for tool test/); }); /*** @@ -2664,7 +2682,7 @@ describe("prompt()", () => { name: z.string(), value: z.string(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ messages: [ { role: "assistant", diff --git a/src/server/mcp.ts b/src/server/mcp.ts index a5f2d0f1..e68691f4 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -198,40 +198,15 @@ 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) { + if (tool.outputSchema && result.structuredContent) { + // if the tool has an output schema, validate structured content + const parseResult = await tool.outputSchema.safeParseAsync( + result.structuredContent, + ); + if (!parseResult.success) { throw new McpError( - ErrorCode.InternalError, - `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ErrorCode.InvalidParams, + `Invalid structured content for tool ${request.params.name}: ${parseResult.error.message}`, ); } } diff --git a/src/types.ts b/src/types.ts index bd299c8f..ae25848e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -837,19 +837,17 @@ export const ToolSchema = z }) .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. + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. */ outputSchema: z.optional( - z.object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) - .passthrough() - ), + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), /** * Optional additional tool information. */ @@ -873,79 +871,46 @@ 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 ContentListSchema = z.array( - z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ]), -); - -export const CallToolUnstructuredResultSchema = ResultSchema.extend({ +export const CallToolResultSchema = 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. + * For backwards compatibility, this field is always present, but it may be empty. */ - 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()), -}); + content: z.array( + z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + EmbeddedResourceSchema, + ])).default([]), -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), + structuredContent: z.object({}).passthrough().optional(), /** * Whether the tool call ended in an error. * * If not set, this is assumed to be false (the call was successful). + * + * 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. */ isError: z.optional(z.boolean()), }); -export const CallToolResultSchema = z.union([ - CallToolUnstructuredResultSchema, - CallToolStructuredResultSchema, -]); - /** * CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07. */ @@ -1396,9 +1361,6 @@ 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;