diff --git a/package-lock.json b/package-lock.json index ed986a69..425abd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 17a77872..83c101b7 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,19 +61,13 @@ 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} ===`); - 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`); - } + result.content?.forEach((item: { type: string; text?: string; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } // 3. Wait for all notifications (10 seconds) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 2debacf6..9a20f03a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -341,19 +341,13 @@ async function callTool(name: string, args: Record): Promise { - 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'); - } + result.content?.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 06f35004..aaefc680 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,19 +173,13 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - 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'); - } + result.content?.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } catch (error) { console.log(`Error calling notification tool: ${error}`); } diff --git a/src/examples/server/lowLevelOutputSchema.ts b/src/examples/server/lowLevelOutputSchema.ts new file mode 100644 index 00000000..dfb28415 --- /dev/null +++ b/src/examples/server/lowLevelOutputSchema.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Example MCP server demonstrating tool outputSchema support using the low-level Server API + * This server manually handles tool listing and invocation requests to return structured data + * For a simpler high-level API approach, see mcpServerOutputSchema.ts + */ + +import { Server } from "../../server/index.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; + +const server = new Server( + { + name: "output-schema-low-level-example", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Tool with structured output +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_weather", + description: "Get weather information for a city", + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + country: { type: "string", description: "Country code (e.g., US, UK)" } + }, + required: ["city", "country"] + }, + outputSchema: { + type: "object", + properties: { + temperature: { + type: "object", + properties: { + celsius: { type: "number" }, + fahrenheit: { type: "number" } + }, + required: ["celsius", "fahrenheit"] + }, + conditions: { + type: "string", + enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] + }, + humidity: { type: "number", minimum: 0, maximum: 100 }, + wind: { + type: "object", + properties: { + speed_kmh: { type: "number" }, + direction: { type: "string" } + }, + required: ["speed_kmh", "direction"] + } + }, + required: ["temperature", "conditions", "humidity", "wind"] + } + } + ] +})); + +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + switch (request.params.name) { + case "get_weather": { + const { city, country } = request.params.arguments as { city: string; country: string }; + + // Parameters are available but not used in this example + void city; + void country; + + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; + + // Return structured content matching the outputSchema + return { + structuredContent: { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + } + }; + } + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Low-level Output Schema Example Server running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 0695bfa1..07cb7f96 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -10,47 +10,31 @@ import { z } from "zod"; const server = new McpServer( { - name: "mcp-output-schema-example", + name: "mcp-output-schema-high-level-example", version: "1.0.0", } ); // Define a tool with structured output - Weather data -server.tool( - "get_weather", - "Get weather information for a city", - { +server.tool("get_weather", { + description: "Get weather information for a city", + inputSchema:{ city: z.string().describe("City name"), country: z.string().describe("Country code (e.g., US, UK)") }, - { - 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"] + outputSchema: { + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) }, - async ({ city, country }: { city: string; country: string }) => { + callback: async ({ city, country }) => { // Parameters are available but not used in this example void city; void country; @@ -73,161 +57,12 @@ server.tool( } }; } -); - -// 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"); + console.error("High-level Output Schema Example Server running on stdio"); } main().catch((error) => { diff --git a/src/examples/server/outputSchema.ts b/src/examples/server/outputSchema.ts deleted file mode 100644 index 8530a760..00000000 --- a/src/examples/server/outputSchema.ts +++ /dev/null @@ -1,209 +0,0 @@ -#!/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/examples/server/testOutputSchemaServers.ts b/src/examples/server/testOutputSchemaServers.ts new file mode 100755 index 00000000..f6966b5e --- /dev/null +++ b/src/examples/server/testOutputSchemaServers.ts @@ -0,0 +1,93 @@ +#!/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"; +import { ListToolsResultSchema, CallToolResultSchema } from "../../types.js"; + +async function main() { + const serverPath = process.argv[2]; + + if (!serverPath) { + console.error("Usage: npx tsx testOutputSchemaServers.ts "); + console.error("Example: npx tsx testOutputSchemaServers.ts ./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.request({ + method: "tools/list" + }, ListToolsResultSchema); + + 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..."); + const weatherResult = await client.request({ + method: "tools/call", + params: { + name: "get_weather", + arguments: { + city: "London", + country: "UK" + } + } + }, CallToolResultSchema); + + console.log("\nWeather tool result:"); + if (weatherResult.structuredContent) { + console.log("Structured content:"); + console.log(JSON.stringify(weatherResult.structuredContent, null, 2)); + } + + if (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