|
| 1 | +/** |
| 2 | + * Multi-server MCP chatbot. |
| 3 | + * |
| 4 | + * Demonstrates how a single Anthropic-powered chatbot connects to multiple MCP |
| 5 | + * servers simultaneously and routes each tool call to the correct server. |
| 6 | + * |
| 7 | + * Architecture: |
| 8 | + * Client ──► Map<toolName, Client> ──► weather-server (:3001) or math-server (:3002) |
| 9 | + * |
| 10 | + * Two servers must be running before starting the chatbot: |
| 11 | + * Terminal 1: pnpm --filter @modelcontextprotocol/examples-server exec tsx src/weatherServer.ts |
| 12 | + * Terminal 2: pnpm --filter @modelcontextprotocol/examples-server exec tsx src/mathServer.ts |
| 13 | + * |
| 14 | + * Run the chatbot: |
| 15 | + * ANTHROPIC_API_KEY=sk-... \ |
| 16 | + * pnpm --filter @modelcontextprotocol/examples-client exec tsx src/multiServerChatbot.ts |
| 17 | + * |
| 18 | + * Example prompts: |
| 19 | + * "What's the weather in Tokyo?" |
| 20 | + * "What is 17 × 19?" |
| 21 | + * "Convert 100°C to Fahrenheit and give me a 3-day forecast for Paris." |
| 22 | + * |
| 23 | + * Closes #740 |
| 24 | + */ |
| 25 | + |
| 26 | +import { createInterface } from 'node:readline'; |
| 27 | + |
| 28 | +import Anthropic from '@anthropic-ai/sdk'; |
| 29 | +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; |
| 30 | + |
| 31 | +const MODEL = 'claude-opus-4-5'; |
| 32 | + |
| 33 | +const SERVER_CONFIGS = [ |
| 34 | + { url: 'http://localhost:3001/mcp', label: 'weather-server', file: 'weatherServer.ts' }, |
| 35 | + { url: 'http://localhost:3002/mcp', label: 'math-server', file: 'mathServer.ts' } |
| 36 | +] as const; |
| 37 | + |
| 38 | +async function main(): Promise<void> { |
| 39 | + // --- Validate API key --- |
| 40 | + const apiKey = process.env.ANTHROPIC_API_KEY; |
| 41 | + if (!apiKey) { |
| 42 | + console.error('Error: ANTHROPIC_API_KEY is not set.'); |
| 43 | + console.error(' export ANTHROPIC_API_KEY=sk-...'); |
| 44 | + // eslint-disable-next-line unicorn/no-process-exit |
| 45 | + process.exit(1); |
| 46 | + } |
| 47 | + |
| 48 | + const anthropic = new Anthropic({ apiKey }); |
| 49 | + |
| 50 | + // --- Connect to all servers --- |
| 51 | + const clients: Client[] = []; |
| 52 | + |
| 53 | + for (const { url, label, file } of SERVER_CONFIGS) { |
| 54 | + const client = new Client({ name: 'multi-server-chatbot', version: '1.0.0' }); |
| 55 | + try { |
| 56 | + await client.connect(new StreamableHTTPClientTransport(new URL(url))); |
| 57 | + clients.push(client); |
| 58 | + } catch { |
| 59 | + console.error(`\nFailed to connect to ${label} at ${url}.`); |
| 60 | + console.error('Is the server running? Start it with:'); |
| 61 | + console.error(` pnpm --filter @modelcontextprotocol/examples-server exec tsx src/${file}`); |
| 62 | + await Promise.all(clients.map(c => c.close())); |
| 63 | + // eslint-disable-next-line unicorn/no-process-exit |
| 64 | + process.exit(1); |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + // --- Build routing table and aggregate tool list --- |
| 69 | + // toolRouter maps each tool name to the client that owns it so tool calls |
| 70 | + // can be dispatched to the right server without any manual bookkeeping. |
| 71 | + const toolRouter = new Map<string, Client>(); |
| 72 | + const allTools: Anthropic.Tool[] = []; |
| 73 | + |
| 74 | + for (const [i, client] of clients.entries()) { |
| 75 | + const { tools } = await client.listTools(); |
| 76 | + const { label } = SERVER_CONFIGS[i]!; |
| 77 | + |
| 78 | + for (const tool of tools) { |
| 79 | + if (toolRouter.has(tool.name)) { |
| 80 | + console.warn(`[warning] tool "${tool.name}" is on multiple servers — ${label} will be used`); |
| 81 | + } |
| 82 | + toolRouter.set(tool.name, client); |
| 83 | + allTools.push({ |
| 84 | + name: tool.name, |
| 85 | + description: tool.description ?? '', |
| 86 | + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema |
| 87 | + }); |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + console.log(`\nConnected to ${clients.length} MCP servers.`); |
| 92 | + console.log(`Tools available: ${allTools.map(t => t.name).join(', ')}`); |
| 93 | + console.log('Type your question or "quit" to exit.\n'); |
| 94 | + |
| 95 | + // --- Clean shutdown --- |
| 96 | + const shutdown = async () => { |
| 97 | + console.log('\nShutting down...'); |
| 98 | + await Promise.all(clients.map(c => c.close())); |
| 99 | + // eslint-disable-next-line unicorn/no-process-exit |
| 100 | + process.exit(0); |
| 101 | + }; |
| 102 | + |
| 103 | + process.on('SIGINT', () => { |
| 104 | + shutdown().catch(console.error); |
| 105 | + }); |
| 106 | + |
| 107 | + // --- readline interface --- |
| 108 | + const rl = createInterface({ input: process.stdin, output: process.stdout }); |
| 109 | + const prompt = (): Promise<string> => new Promise(resolve => rl.question('You: ', resolve)); |
| 110 | + |
| 111 | + // --- Chat loop --- |
| 112 | + while (true) { |
| 113 | + const rawInput = await prompt(); |
| 114 | + const userInput = rawInput.trim(); |
| 115 | + |
| 116 | + if (!userInput) continue; |
| 117 | + if (userInput.toLowerCase() === 'quit' || userInput.toLowerCase() === 'exit') break; |
| 118 | + |
| 119 | + const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userInput }]; |
| 120 | + |
| 121 | + // Agentic loop: keep going until the model stops requesting tool calls. |
| 122 | + while (true) { |
| 123 | + const response = await anthropic.messages.create({ |
| 124 | + model: MODEL, |
| 125 | + max_tokens: 4096, |
| 126 | + tools: allTools, |
| 127 | + messages |
| 128 | + }); |
| 129 | + |
| 130 | + if (response.stop_reason !== 'tool_use') { |
| 131 | + // No tool calls — print the final text response. |
| 132 | + const text = response.content |
| 133 | + .filter((b): b is Anthropic.TextBlock => b.type === 'text') |
| 134 | + .map(b => b.text) |
| 135 | + .join(''); |
| 136 | + console.log(`\nAssistant: ${text}\n`); |
| 137 | + break; |
| 138 | + } |
| 139 | + |
| 140 | + // Execute all tool calls in parallel, each routed to the correct server. |
| 141 | + const toolUseBlocks = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'); |
| 142 | + |
| 143 | + const toolResultContent = await Promise.all( |
| 144 | + toolUseBlocks.map(async (block): Promise<Anthropic.ToolResultBlockParam> => { |
| 145 | + const client = toolRouter.get(block.name); |
| 146 | + if (!client) { |
| 147 | + return { |
| 148 | + type: 'tool_result', |
| 149 | + tool_use_id: block.id, |
| 150 | + content: `Unknown tool: ${block.name}`, |
| 151 | + is_error: true |
| 152 | + }; |
| 153 | + } |
| 154 | + |
| 155 | + console.log(` [tool] ${block.name}(${JSON.stringify(block.input)})`); |
| 156 | + |
| 157 | + const result = await client.callTool({ |
| 158 | + name: block.name, |
| 159 | + arguments: block.input as Record<string, unknown> |
| 160 | + }); |
| 161 | + |
| 162 | + const text = result.content |
| 163 | + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') |
| 164 | + .map(c => c.text) |
| 165 | + .join('\n'); |
| 166 | + |
| 167 | + console.log(` [result] ${text}`); |
| 168 | + return { type: 'tool_result', tool_use_id: block.id, content: text }; |
| 169 | + }) |
| 170 | + ); |
| 171 | + |
| 172 | + // Append this assistant turn and all tool results, then loop. |
| 173 | + messages.push({ role: 'assistant', content: response.content }, { role: 'user', content: toolResultContent }); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + rl.close(); |
| 178 | + await Promise.all(clients.map(c => c.close())); |
| 179 | +} |
| 180 | + |
| 181 | +try { |
| 182 | + await main(); |
| 183 | +} catch (error) { |
| 184 | + console.error('Error:', error); |
| 185 | + // eslint-disable-next-line unicorn/no-process-exit |
| 186 | + process.exit(1); |
| 187 | +} |
0 commit comments