-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: add multi-server chatbot client example #1691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| build/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # Multi-Server MCP Client Example | ||
|
|
||
| A CLI chatbot that connects to multiple MCP servers simultaneously, aggregates their tools, and routes tool calls to the correct server. This is the TypeScript equivalent of the [Python SDK's simple-chatbot example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-chatbot). | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Node.js 20+ | ||
| - An [Anthropic API key](https://console.anthropic.com/) | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```bash | ||
| # Install dependencies (from repo root) | ||
| pnpm install | ||
|
|
||
| # Set your API key | ||
| export ANTHROPIC_API_KEY=your-api-key-here | ||
|
|
||
| # Run with the default servers.json config | ||
| cd examples/client-multi-server | ||
| npx tsx src/index.ts | ||
| ``` | ||
|
|
||
| ## Configuration | ||
|
|
||
| Servers are configured via a JSON file (default: `servers.json` in the working directory). Pass a custom path as the first argument: | ||
|
|
||
| ```bash | ||
| npx tsx src/index.ts /path/to/my-servers.json | ||
| ``` | ||
|
|
||
| The config file uses the same format as Claude Desktop and other MCP clients: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "everything": { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-everything"] | ||
| }, | ||
| "memory": { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-memory"] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Each entry under `mcpServers` defines a server to connect to via stdio: | ||
|
|
||
| - `command`: the executable to run | ||
| - `args`: command-line arguments (optional) | ||
| - `env`: additional environment variables (optional, merged with the current environment) | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. Reads the server config and connects to each MCP server in sequence | ||
| 2. Discovers tools from every connected server and builds a unified tool list | ||
| 3. Maintains a mapping from each tool name to its originating server | ||
| 4. Sends the full tool list to Claude with each request | ||
| 5. When Claude calls a tool, routes the call to the correct server | ||
| 6. Supports multi-step tool use (agentic loop) where Claude can chain multiple tool calls | ||
|
|
||
| ## Usage | ||
|
|
||
| ``` | ||
| $ npx tsx src/index.ts | ||
| Connecting to server: everything... | ||
| Connected to everything with tools: echo, add, ... | ||
|
|
||
| Total tools available: 12 | ||
|
|
||
| Multi-Server MCP Client Started! | ||
| Type your queries or "quit" to exit. | ||
|
|
||
| Query: What tools do you have access to? | ||
|
|
||
| I have access to 12 tools from the "everything" server... | ||
|
|
||
| Query: quit | ||
| Disconnecting from everything... | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "name": "@modelcontextprotocol/examples-client-multi-server", | ||
| "private": true, | ||
| "version": "2.0.0-alpha.0", | ||
| "type": "module", | ||
| "bin": { | ||
| "mcp-multi-server-client": "./build/index.js" | ||
| }, | ||
| "scripts": { | ||
| "build": "tsc", | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@anthropic-ai/sdk": "^0.74.0", | ||
| "@modelcontextprotocol/client": "workspace:^" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^24.10.1", | ||
| "typescript": "catalog:devTools" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "mcpServers": { | ||
| "everything": { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-everything"] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
| import readline from 'readline/promises'; | ||
|
|
||
| import Anthropic from '@anthropic-ai/sdk'; | ||
| import { Client, StdioClientTransport } from '@modelcontextprotocol/client'; | ||
|
|
||
| const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; | ||
|
|
||
| interface ServerConfig { | ||
| command: string; | ||
| args?: string[]; | ||
| env?: Record<string, string>; | ||
| } | ||
|
|
||
| interface ServersConfig { | ||
| mcpServers: Record<string, ServerConfig>; | ||
| } | ||
|
|
||
| class MultiServerClient { | ||
| private servers: Map<string, Client> = new Map(); | ||
| private toolToServer: Map<string, { serverName: string; originalName: string }> = new Map(); | ||
| private _anthropic: Anthropic | null = null; | ||
| private tools: Anthropic.Tool[] = []; | ||
|
|
||
| private get anthropic(): Anthropic { | ||
| return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); | ||
| } | ||
|
|
||
| async connectToServers(configPath: string) { | ||
| const raw = readFileSync(resolve(configPath), 'utf-8'); | ||
| const config: ServersConfig = JSON.parse(raw); | ||
|
|
||
| for (const [name, serverConfig] of Object.entries(config.mcpServers)) { | ||
| console.log(`Connecting to server: ${name}...`); | ||
| try { | ||
| const transport = new StdioClientTransport({ | ||
| command: serverConfig.command, | ||
| args: serverConfig.args, | ||
| env: serverConfig.env | ||
| ? { ...process.env as Record<string, string>, ...serverConfig.env } | ||
| : undefined, | ||
| }); | ||
| const client = new Client({ name: `multi-server-client-${name}`, version: '1.0.0' }); | ||
| await client.connect(transport); | ||
| this.servers.set(name, client); | ||
|
|
||
| // Discover tools from this server | ||
| const toolsResult = await client.listTools(); | ||
| for (const tool of toolsResult.tools) { | ||
| const prefixedName = `${name}__${tool.name}`; | ||
| if (this.toolToServer.has(prefixedName)) { | ||
| console.warn( | ||
| ` Warning: tool "${tool.name}" from server "${name}" collides with an existing tool.` | ||
| ); | ||
| } | ||
| this.toolToServer.set(prefixedName, { serverName: name, originalName: tool.name }); | ||
| this.tools.push({ | ||
| name: prefixedName, | ||
| description: `[${name}] ${tool.description ?? ''}`, | ||
| input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, | ||
| }); | ||
| } | ||
| console.log( | ||
| ` Connected to ${name} with tools: ${toolsResult.tools.map((t) => t.name).join(', ')}` | ||
| ); | ||
| } catch (e) { | ||
| console.error(` Failed to connect to ${name}:`, e); | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| console.log(`\nTotal tools available: ${this.tools.length}`); | ||
| } | ||
|
|
||
| async processQuery(query: string) { | ||
| const messages: Anthropic.MessageParam[] = [{ role: 'user', content: query }]; | ||
|
|
||
| // Agentic loop: keep processing until the model stops issuing tool calls | ||
| let response = await this.anthropic.messages.create({ | ||
| model: ANTHROPIC_MODEL, | ||
| max_tokens: 1000, | ||
| messages, | ||
| tools: this.tools, | ||
| }); | ||
|
|
||
| const finalText: string[] = []; | ||
|
|
||
| while (response.stop_reason === 'tool_use') { | ||
| const assistantContent = response.content; | ||
| messages.push({ role: 'assistant', content: assistantContent }); | ||
|
|
||
| const toolResults: Anthropic.ToolResultBlockParam[] = []; | ||
|
|
||
| for (const block of assistantContent) { | ||
| if (block.type === 'text') { | ||
| finalText.push(block.text); | ||
| } else if (block.type === 'tool_use') { | ||
| const mapping = this.toolToServer.get(block.name); | ||
| if (!mapping) { | ||
| toolResults.push({ | ||
| type: 'tool_result', | ||
| tool_use_id: block.id, | ||
| content: `Error: unknown tool "${block.name}"`, | ||
| is_error: true, | ||
| }); | ||
| continue; | ||
| } | ||
|
Comment on lines
+96
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 When client.callTool() returns a result with isError: true (an in-band tool error), the code does not set is_error: true on the ToolResultBlockParam sent back to Claude — only thrown exceptions set this flag. As a result, Claude receives the error message as a successful tool result and cannot self-correct, which is the exact failure mode the MCP spec warns against. Fix by adding result.isError propagation to the toolResults.push() call. Extended reasoning...What the bug isIn processQuery(), the try block calls client.callTool() and constructs a ToolResultBlockParam from the result. The catch block correctly sets is_error: true when an exception is thrown (e.g., transport/protocol failure). However, the MCP protocol also allows servers to report tool-level failures in-band by returning { isError: true, content: [...] } without throwing. The code never checks result.isError and therefore never sets is_error: true for in-band errors. The specific code pathLines 120-129 of examples/client-multi-server/src/index.ts: "); The is_error field is only set inside the catch block, never in the success path. Why existing code does not prevent itThe outer try/catch catches exceptions (network errors, JSON parse failures, etc.) but does not observe the return value of callTool(). The result.isError field is a separate, in-band signal that sits in the return value and is simply never read. ImpactThe MCP spec (packages/core/src/types/spec.types.ts:1563) explicitly states: "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." By omitting is_error: true, Claude receives the error text as a normal successful result and may continue reasoning incorrectly (e.g., treating "File not found" as actual file content). The example also teaches incorrect patterns to developers who copy it. Step-by-step proof
How to fix itPropagate result.isError to the block param: |
||
|
|
||
| const { serverName, originalName } = mapping; | ||
| const client = this.servers.get(serverName)!; | ||
| console.log(` [Calling ${originalName} on server "${serverName}"]`); | ||
|
|
||
| try { | ||
| const result = await client.callTool({ | ||
| name: originalName, | ||
| arguments: block.input as Record<string, unknown> | undefined, | ||
| }); | ||
|
|
||
| const resultText = result.content | ||
| .filter((c) => c.type === 'text') | ||
| .map((c) => c.text) | ||
| .join('\n'); | ||
|
|
||
| toolResults.push({ | ||
| type: 'tool_result', | ||
| tool_use_id: block.id, | ||
| content: resultText, | ||
| }); | ||
| } catch (e) { | ||
| toolResults.push({ | ||
| type: 'tool_result', | ||
| tool_use_id: block.id, | ||
| content: `Error executing tool: ${e}`, | ||
| is_error: true, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| messages.push({ role: 'user', content: toolResults }); | ||
|
|
||
| response = await this.anthropic.messages.create({ | ||
| model: ANTHROPIC_MODEL, | ||
| max_tokens: 1000, | ||
| messages, | ||
| tools: this.tools, | ||
| }); | ||
| } | ||
|
|
||
| // Collect any remaining text from the final response | ||
| for (const block of response.content) { | ||
| if (block.type === 'text') { | ||
| finalText.push(block.text); | ||
| } | ||
| } | ||
|
|
||
| return finalText.join('\n'); | ||
| } | ||
|
|
||
| async chatLoop() { | ||
| const rl = readline.createInterface({ | ||
| input: process.stdin, | ||
| output: process.stdout, | ||
| }); | ||
|
|
||
| try { | ||
| console.log('\nMulti-Server MCP Client Started!'); | ||
| console.log('Type your queries or "quit" to exit.\n'); | ||
|
|
||
| while (true) { | ||
| const message = await rl.question('Query: '); | ||
| if (message.toLowerCase() === 'quit') { | ||
| break; | ||
| } | ||
| const response = await this.processQuery(message); | ||
| console.log('\n' + response + '\n'); | ||
| } | ||
| } finally { | ||
| rl.close(); | ||
| } | ||
| } | ||
|
|
||
| async cleanup() { | ||
| for (const [name, client] of this.servers) { | ||
| console.log(`Disconnecting from ${name}...`); | ||
| await client.close(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function main() { | ||
|
Comment on lines
+180
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...What the bug isIn The specific code pathasync function main() {
const configPath = process.argv[2] ?? servers.json;
const mcpClient = new MultiServerClient();
try {
await mcpClient.connectToServers(configPath); // spawns processes, downloads packages
const apiKey = process.env.ANTHROPIC_API_KEY; // checked only after
if (!apiKey) {
console.log(\nNo ANTHROPIC_API_KEY found...);
return; // wasted work
}
await mcpClient.chatLoop();
} ...
}Why existing code does not prevent itThere is no early validation of required prerequisites before entering the expensive setup phase. The API key check is deferred until after Addressing the refutationThe refutation argues this is intentional, allowing users to verify MCP server connectivity without Anthropic credentials. However, this argument does not hold up: the code immediately returns after printing the "no API key" message without offering any tool exploration functionality. If the intent were to support a "browse tools without chatting" mode, there would be a Step-by-step proof
How to fixMove the API key check to the top of async function main() {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error(Error: ANTHROPIC_API_KEY is not set.);
console.error( export ANTHROPIC_API_KEY=your-api-key-here);
process.exit(1);
}
// ... rest of setup
}This is a one-line change that provides immediate feedback without touching any functionality. |
||
| const configPath = process.argv[2] ?? 'servers.json'; | ||
|
|
||
| const mcpClient = new MultiServerClient(); | ||
| try { | ||
| await mcpClient.connectToServers(configPath); | ||
|
|
||
| const apiKey = process.env.ANTHROPIC_API_KEY; | ||
| if (!apiKey) { | ||
| console.log( | ||
| '\nNo ANTHROPIC_API_KEY found. To chat with these tools via Claude, set your API key:' | ||
| + '\n export ANTHROPIC_API_KEY=your-api-key-here' | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| await mcpClient.chatLoop(); | ||
| } catch (e) { | ||
| console.error('Error:', e); | ||
|
Comment on lines
+193
to
+210
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 In Extended reasoning...What the bug isIn The specific code pathtry {
await mcpClient.connectToServers(configPath);
await mcpClient.chatLoop();
} catch (e) {
console.error('Error:', e);
process.exit(1); // process terminates HERE — finally never runs
} finally {
await mcpClient.cleanup(); // NEVER REACHED on error
process.exit(0);
}When Why existing code does not prevent itThis is a fundamental Node.js behavior — Step-by-step proof
ImpactFor this specific CLI example, the practical impact is limited — the OS reclaims resources on process exit, and child processes typically exit once their stdin pipe closes. However, this is example code that developers will copy into production applications. Using How to fixReplace } catch (e) {
console.error('Error:', e);
process.exitCode = 1; // set exit code, do not terminate yet
} finally {
await mcpClient.cleanup(); // now runs on both success and error
process.exit(); // exits with the code set above
} |
||
| process.exit(1); | ||
| } finally { | ||
| await mcpClient.cleanup(); | ||
| process.exit(0); | ||
| } | ||
| } | ||
|
|
||
| main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2023", | ||
| "lib": ["ES2023"], | ||
| "module": "Node16", | ||
| "moduleResolution": "Node16", | ||
| "outDir": "./build", | ||
| "rootDir": "./src", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "forceConsistentCasingInFileNames": true, | ||
| "paths": { | ||
| "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], | ||
| "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], | ||
| "@modelcontextprotocol/core": [ | ||
| "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" | ||
| ] | ||
| } | ||
| }, | ||
| "include": ["src/**/*"], | ||
| "exclude": ["node_modules"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The collision check on line 47 is effectively dead code: since prefixedName is
${name}__${tool.name}andnameis the current server name (unique across the outer loop), a collision can only occur if the same server reports duplicate tool names in a single listTools() response, which is an MCP protocol violation. The warning message 'collides with an existing tool' is also misleading — users would interpret it as a cross-server collision, but prefixing already makes cross-server collisions impossible by design.Extended reasoning...
What the bug is
The collision detection block on lines 47-51 checks whether
toolToServeralready containsprefixedName, whereprefixedName = name + "__" + tool.name. The intent, based on the warning message, is to catch situations where two different servers expose a tool with the same name. However, because the server name is baked into the prefix, tools from different servers can never share the same prefixed key.The specific code path
In the tool registration loop, for each server
nameand eachtoolfrom that server, the code computesprefixedNameas the concatenation of the server name, a double-underscore separator, and the tool name. It then checks if this key already exists in the map. Since JSON object keys are guaranteed unique,nameis distinct for every iteration of the outer loop, so this prefixed key is unique per server+tool pair by construction.Why existing code does not prevent the issue
The prefixing scheme was added (correctly) to solve cross-server name collision, and it does solve it. But the collision-detection
ifblock was left in place without being updated to reflect the new invariant. Nobody removed or rewrote it to match the new key format, so it now tests for an impossible condition.Impact
There is no incorrect runtime behavior — the prefixing correctly routes all tool calls. The impact is purely one of code clarity and misleading diagnostics. A developer reading this example could incorrectly believe the
ifblock guards against cross-server collisions. If a broken MCP server ever did return duplicate tool names, the warning would confusingly say "collides with an existing tool" with no indication that it is a same-server duplicate.How to fix it
Two options: (1) Remove the check entirely — cross-server collisions are impossible with the prefix scheme, and same-server duplicates are an MCP protocol violation that does not need a warning in example code. (2) Update the warning to clarify the actual scenario, e.g. 'Warning: server X reported duplicate tool name Y — later entry overwrites earlier.'
Step-by-step proof
Suppose two servers 'weather' and 'news' both expose a tool named 'fetch':
The warning can never fire for this cross-server case. The only way it could fire is if the same server returned 'fetch' twice in a single listTools() response, which is an MCP protocol violation that should never happen in practice.