diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68b..e1f9bffc75 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -24,18 +24,45 @@ Most clients expect a server to be running. Start one from [`../server/README.md ## Example index -| Scenario | Description | File | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Scenario | Description | File | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | +| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | +| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | +| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | +| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | +| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | +| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | +| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Multi-server chatbot | Claude-powered chatbot that connects to two MCP servers and routes tool calls automatically. | [`src/multiServerChatbot.ts`](src/multiServerChatbot.ts) | + +## Multi-server chatbot example + +Shows how one chatbot client can connect to multiple MCP servers simultaneously and route tool calls to the correct server based on which server registered the tool. + +Start both servers first (each in its own terminal): + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/weatherServer.ts +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/mathServer.ts +``` + +Then run the chatbot: + +```bash +ANTHROPIC_API_KEY=sk-... \ + pnpm --filter @modelcontextprotocol/examples-client exec tsx src/multiServerChatbot.ts +``` + +Try these prompts to exercise both servers in one turn: + +``` +What's the weather in Tokyo? +What is 17 × 19? +Convert 100°C to Fahrenheit and give me a 3-day forecast for Paris. +``` ## URL elicitation example (server + client) diff --git a/examples/client/package.json b/examples/client/package.json index 57b329fd2d..c9ea6b55d3 100644 --- a/examples/client/package.json +++ b/examples/client/package.json @@ -32,6 +32,7 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { + "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/client": "workspace:^", "ajv": "catalog:runtimeShared", "open": "^11.0.0", diff --git a/examples/client/src/multiServerChatbot.ts b/examples/client/src/multiServerChatbot.ts new file mode 100644 index 0000000000..0a0273e009 --- /dev/null +++ b/examples/client/src/multiServerChatbot.ts @@ -0,0 +1,187 @@ +/** + * Multi-server MCP chatbot. + * + * Demonstrates how a single Anthropic-powered chatbot connects to multiple MCP + * servers simultaneously and routes each tool call to the correct server. + * + * Architecture: + * Client ──► Map ──► weather-server (:3001) or math-server (:3002) + * + * Two servers must be running before starting the chatbot: + * Terminal 1: pnpm --filter @modelcontextprotocol/examples-server exec tsx src/weatherServer.ts + * Terminal 2: pnpm --filter @modelcontextprotocol/examples-server exec tsx src/mathServer.ts + * + * Run the chatbot: + * ANTHROPIC_API_KEY=sk-... \ + * pnpm --filter @modelcontextprotocol/examples-client exec tsx src/multiServerChatbot.ts + * + * Example prompts: + * "What's the weather in Tokyo?" + * "What is 17 × 19?" + * "Convert 100°C to Fahrenheit and give me a 3-day forecast for Paris." + * + * Closes #740 + */ + +import { createInterface } from 'node:readline'; + +import Anthropic from '@anthropic-ai/sdk'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const MODEL = 'claude-opus-4-5'; + +const SERVER_CONFIGS = [ + { url: 'http://localhost:3001/mcp', label: 'weather-server', file: 'weatherServer.ts' }, + { url: 'http://localhost:3002/mcp', label: 'math-server', file: 'mathServer.ts' } +] as const; + +async function main(): Promise { + // --- Validate API key --- + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + console.error('Error: ANTHROPIC_API_KEY is not set.'); + console.error(' export ANTHROPIC_API_KEY=sk-...'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + + const anthropic = new Anthropic({ apiKey }); + + // --- Connect to all servers --- + const clients: Client[] = []; + + for (const { url, label, file } of SERVER_CONFIGS) { + const client = new Client({ name: 'multi-server-chatbot', version: '1.0.0' }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + clients.push(client); + } catch { + console.error(`\nFailed to connect to ${label} at ${url}.`); + console.error('Is the server running? Start it with:'); + console.error(` pnpm --filter @modelcontextprotocol/examples-server exec tsx src/${file}`); + await Promise.all(clients.map(c => c.close())); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + } + + // --- Build routing table and aggregate tool list --- + // toolRouter maps each tool name to the client that owns it so tool calls + // can be dispatched to the right server without any manual bookkeeping. + const toolRouter = new Map(); + const allTools: Anthropic.Tool[] = []; + + for (const [i, client] of clients.entries()) { + const { tools } = await client.listTools(); + const { label } = SERVER_CONFIGS[i]!; + + for (const tool of tools) { + if (toolRouter.has(tool.name)) { + console.warn(`[warning] tool "${tool.name}" is on multiple servers — ${label} will be used`); + } + toolRouter.set(tool.name, client); + allTools.push({ + name: tool.name, + description: tool.description ?? '', + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema + }); + } + } + + console.log(`\nConnected to ${clients.length} MCP servers.`); + console.log(`Tools available: ${allTools.map(t => t.name).join(', ')}`); + console.log('Type your question or "quit" to exit.\n'); + + // --- Clean shutdown --- + const shutdown = async () => { + console.log('\nShutting down...'); + await Promise.all(clients.map(c => c.close())); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + }; + + process.on('SIGINT', () => { + shutdown().catch(console.error); + }); + + // --- readline interface --- + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prompt = (): Promise => new Promise(resolve => rl.question('You: ', resolve)); + + // --- Chat loop --- + while (true) { + const rawInput = await prompt(); + const userInput = rawInput.trim(); + + if (!userInput) continue; + if (userInput.toLowerCase() === 'quit' || userInput.toLowerCase() === 'exit') break; + + const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userInput }]; + + // Agentic loop: keep going until the model stops requesting tool calls. + while (true) { + const response = await anthropic.messages.create({ + model: MODEL, + max_tokens: 4096, + tools: allTools, + messages + }); + + if (response.stop_reason !== 'tool_use') { + // No tool calls — print the final text response. + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map(b => b.text) + .join(''); + console.log(`\nAssistant: ${text}\n`); + break; + } + + // Execute all tool calls in parallel, each routed to the correct server. + const toolUseBlocks = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'); + + const toolResultContent = await Promise.all( + toolUseBlocks.map(async (block): Promise => { + const client = toolRouter.get(block.name); + if (!client) { + return { + type: 'tool_result', + tool_use_id: block.id, + content: `Unknown tool: ${block.name}`, + is_error: true + }; + } + + console.log(` [tool] ${block.name}(${JSON.stringify(block.input)})`); + + const result = await client.callTool({ + name: block.name, + arguments: block.input as Record + }); + + const text = result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map(c => c.text) + .join('\n'); + + console.log(` [result] ${text}`); + return { type: 'tool_result', tool_use_id: block.id, content: text }; + }) + ); + + // Append this assistant turn and all tool results, then loop. + messages.push({ role: 'assistant', content: response.content }, { role: 'user', content: toolResultContent }); + } + } + + rl.close(); + await Promise.all(clients.map(c => c.close())); +} + +try { + await main(); +} catch (error) { + console.error('Error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/README.md b/examples/server/README.md index 0f684bec7e..5ffc766240 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -39,6 +39,8 @@ pnpm tsx src/simpleStreamableHttp.ts | Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Multi-server chatbot — weather server | Stateless Streamable HTTP server on :3001 with `get_weather` and `get_forecast` tools. | [`src/weatherServer.ts`](src/weatherServer.ts) | +| Multi-server chatbot — math server | Stateless Streamable HTTP server on :3002 with `add`, `multiply`, and `convert_temperature`. | [`src/mathServer.ts`](src/mathServer.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/mathServer.ts b/examples/server/src/mathServer.ts new file mode 100644 index 0000000000..45c87041a6 --- /dev/null +++ b/examples/server/src/mathServer.ts @@ -0,0 +1,156 @@ +/** + * Math MCP server — part of the multi-server chatbot example. + * + * Provides three math tools over Streamable HTTP on port 3002: + * - add — add two numbers + * - multiply — multiply two numbers + * - convert_temperature — convert between Celsius, Fahrenheit, and Kelvin + * + * Start this server alongside weatherServer.ts, then run multiServerChatbot.ts. + * + * Usage: + * pnpm --filter @modelcontextprotocol/examples-server exec tsx src/mathServer.ts + */ + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const TemperatureUnit = z.enum(['C', 'F', 'K']); + +const getServer = (): McpServer => { + const server = new McpServer({ name: 'math-server', version: '1.0.0' }); + + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number') + }) + }, + async ({ a, b }): Promise => ({ + content: [{ type: 'text', text: `${a} + ${b} = ${a + b}` }] + }) + ); + + server.registerTool( + 'multiply', + { + description: 'Multiply two numbers', + inputSchema: z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number') + }) + }, + async ({ a, b }): Promise => ({ + content: [{ type: 'text', text: `${a} × ${b} = ${a * b}` }] + }) + ); + + server.registerTool( + 'convert_temperature', + { + description: 'Convert a temperature between Celsius (C), Fahrenheit (F), and Kelvin (K)', + inputSchema: z.object({ + value: z.number().describe('Temperature value to convert'), + from: TemperatureUnit.describe('Source unit: C, F, or K'), + to: TemperatureUnit.describe('Target unit: C, F, or K') + }) + }, + async ({ value, from, to }): Promise => { + let celsius: number; + + switch (from) { + case 'C': { + celsius = value; + break; + } + case 'F': { + celsius = (value - 32) * (5 / 9); + break; + } + case 'K': { + celsius = value - 273.15; + break; + } + } + + let result: number; + switch (to) { + case 'C': { + result = celsius; + break; + } + case 'F': { + result = celsius * (9 / 5) + 32; + break; + } + case 'K': { + result = celsius + 273.15; + break; + } + } + + return { + content: [{ type: 'text', text: `${value}°${from} = ${result.toFixed(2)}°${to}` }] + }; + } + ); + + return server; +}; + +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + const server = getServer(); + try { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + transport.close(); + server.close(); + }); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32_603, message: 'Internal server error' }, + id: null + }); + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null })); +}); + +const PORT = 3002; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start math server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`[math-server] listening on :${PORT} — tools: add, multiply, convert_temperature`); +}); + +process.on('SIGINT', () => { + console.log('[math-server] shutting down'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/examples/server/src/weatherServer.ts b/examples/server/src/weatherServer.ts new file mode 100644 index 0000000000..c61ead90f1 --- /dev/null +++ b/examples/server/src/weatherServer.ts @@ -0,0 +1,105 @@ +/** + * Weather MCP server — part of the multi-server chatbot example. + * + * Provides two weather tools over Streamable HTTP on port 3001: + * - get_weather — current conditions for a city + * - get_forecast — N-day forecast for a city + * + * Start this server alongside mathServer.ts, then run multiServerChatbot.ts. + * + * Usage: + * pnpm --filter @modelcontextprotocol/examples-server exec tsx src/weatherServer.ts + */ + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const getServer = (): McpServer => { + const server = new McpServer({ name: 'weather-server', version: '1.0.0' }); + + server.registerTool( + 'get_weather', + { + description: 'Get current weather conditions for a city', + inputSchema: z.object({ + city: z.string().describe('City name') + }) + }, + async ({ city }): Promise => ({ + content: [{ type: 'text', text: `Current weather in ${city}: sunny, 22°C, humidity 45%.` }] + }) + ); + + server.registerTool( + 'get_forecast', + { + description: 'Get a multi-day weather forecast for a city', + inputSchema: z.object({ + city: z.string().describe('City name'), + days: z.number().int().min(1).max(7).default(3).describe('Number of days (1–7)') + }) + }, + async ({ city, days }): Promise => { + const forecasts = Array.from({ length: days }, (_, i) => `Day ${i + 1}: sunny, ~${20 + i}°C`); + return { + content: [{ type: 'text', text: `${days}-day forecast for ${city}:\n${forecasts.join('\n')}` }] + }; + } + ); + + return server; +}; + +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + const server = getServer(); + try { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + transport.close(); + server.close(); + }); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32_603, message: 'Internal server error' }, + id: null + }); + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null })); +}); + +const PORT = 3001; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start weather server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`[weather-server] listening on :${PORT} — tools: get_weather, get_forecast`); +}); + +process.on('SIGINT', () => { + console.log('[weather-server] shutting down'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4fc799822..bc8c2667d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,9 @@ importers: examples/client: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.74.0 + version: 0.74.0(zod@4.3.6) '@modelcontextprotocol/client': specifier: workspace:^ version: link:../../packages/client