Skip to content

Commit 0a24dd3

Browse files
travisbreaksclaude
andcommitted
feat(examples): add multi-server chatbot client example
Adds a new standalone example 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, addressing issue #740. The example follows the client-quickstart pattern: minimal dependencies (Anthropic SDK + MCP client), standalone package, simple CLI interface. It uses a JSON config file (same format as Claude Desktop) to define servers and includes an agentic loop for multi-step tool use. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4fbcfcd commit 0a24dd3

9 files changed

Lines changed: 366 additions & 1 deletion

File tree

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"updateInternalDependencies": "patch",
1010
"ignore": [
1111
"@modelcontextprotocol/examples-client",
12+
"@modelcontextprotocol/examples-client-multi-server",
1213
"@modelcontextprotocol/examples-client-quickstart",
1314
"@modelcontextprotocol/examples-server",
1415
"@modelcontextprotocol/examples-server-quickstart",

.prettierignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pnpm-lock.yaml
1212
# Ignore generated files
1313
src/spec.types.ts
1414

15-
# Quickstart examples uses 2-space indent to match ecosystem conventions
15+
# Standalone examples use 2-space indent to match ecosystem conventions
16+
examples/client-multi-server/
1617
examples/client-quickstart/
1718
examples/server-quickstart/
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
build/
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Multi-Server MCP Client Example
2+
3+
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).
4+
5+
## Prerequisites
6+
7+
- Node.js 20+
8+
- An [Anthropic API key](https://console.anthropic.com/)
9+
10+
## Quick Start
11+
12+
```bash
13+
# Install dependencies (from repo root)
14+
pnpm install
15+
16+
# Set your API key
17+
export ANTHROPIC_API_KEY=your-api-key-here
18+
19+
# Run with the default servers.json config
20+
cd examples/client-multi-server
21+
npx tsx src/index.ts
22+
```
23+
24+
## Configuration
25+
26+
Servers are configured via a JSON file (default: `servers.json` in the working directory). Pass a custom path as the first argument:
27+
28+
```bash
29+
npx tsx src/index.ts /path/to/my-servers.json
30+
```
31+
32+
The config file uses the same format as Claude Desktop and other MCP clients:
33+
34+
```json
35+
{
36+
"mcpServers": {
37+
"everything": {
38+
"command": "npx",
39+
"args": ["-y", "@modelcontextprotocol/server-everything"]
40+
},
41+
"memory": {
42+
"command": "npx",
43+
"args": ["-y", "@modelcontextprotocol/server-memory"]
44+
}
45+
}
46+
}
47+
```
48+
49+
Each entry under `mcpServers` defines a server to connect to via stdio:
50+
51+
- `command`: the executable to run
52+
- `args`: command-line arguments (optional)
53+
- `env`: additional environment variables (optional, merged with the current environment)
54+
55+
## How It Works
56+
57+
1. Reads the server config and connects to each MCP server in sequence
58+
2. Discovers tools from every connected server and builds a unified tool list
59+
3. Maintains a mapping from each tool name to its originating server
60+
4. Sends the full tool list to Claude with each request
61+
5. When Claude calls a tool, routes the call to the correct server
62+
6. Supports multi-step tool use (agentic loop) where Claude can chain multiple tool calls
63+
64+
## Usage
65+
66+
```
67+
$ npx tsx src/index.ts
68+
Connecting to server: everything...
69+
Connected to everything with tools: echo, add, ...
70+
71+
Total tools available: 12
72+
73+
Multi-Server MCP Client Started!
74+
Type your queries or "quit" to exit.
75+
76+
Query: What tools do you have access to?
77+
78+
I have access to 12 tools from the "everything" server...
79+
80+
Query: quit
81+
Disconnecting from everything...
82+
```
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@modelcontextprotocol/examples-client-multi-server",
3+
"private": true,
4+
"version": "2.0.0-alpha.0",
5+
"type": "module",
6+
"bin": {
7+
"mcp-multi-server-client": "./build/index.js"
8+
},
9+
"scripts": {
10+
"build": "tsc",
11+
"typecheck": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@anthropic-ai/sdk": "^0.74.0",
15+
"@modelcontextprotocol/client": "workspace:^"
16+
},
17+
"devDependencies": {
18+
"@types/node": "^24.10.1",
19+
"typescript": "catalog:devTools"
20+
}
21+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"everything": {
4+
"command": "npx",
5+
"args": ["-y", "@modelcontextprotocol/server-everything"]
6+
}
7+
}
8+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import readline from 'readline/promises';
4+
5+
import Anthropic from '@anthropic-ai/sdk';
6+
import { Client, StdioClientTransport } from '@modelcontextprotocol/client';
7+
8+
const ANTHROPIC_MODEL = 'claude-sonnet-4-5';
9+
10+
interface ServerConfig {
11+
command: string;
12+
args?: string[];
13+
env?: Record<string, string>;
14+
}
15+
16+
interface ServersConfig {
17+
mcpServers: Record<string, ServerConfig>;
18+
}
19+
20+
class MultiServerClient {
21+
private servers: Map<string, Client> = new Map();
22+
private toolToServer: Map<string, string> = new Map();
23+
private _anthropic: Anthropic | null = null;
24+
private tools: Anthropic.Tool[] = [];
25+
26+
private get anthropic(): Anthropic {
27+
return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
28+
}
29+
30+
async connectToServers(configPath: string) {
31+
const raw = readFileSync(resolve(configPath), 'utf-8');
32+
const config: ServersConfig = JSON.parse(raw);
33+
34+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
35+
console.log(`Connecting to server: ${name}...`);
36+
try {
37+
const transport = new StdioClientTransport({
38+
command: serverConfig.command,
39+
args: serverConfig.args,
40+
env: serverConfig.env
41+
? { ...process.env as Record<string, string>, ...serverConfig.env }
42+
: undefined,
43+
});
44+
const client = new Client({ name: `multi-server-client-${name}`, version: '1.0.0' });
45+
await client.connect(transport);
46+
47+
// Discover tools from this server
48+
const toolsResult = await client.listTools();
49+
for (const tool of toolsResult.tools) {
50+
this.toolToServer.set(tool.name, name);
51+
this.tools.push({
52+
name: tool.name,
53+
description: tool.description ?? '',
54+
input_schema: tool.inputSchema as Anthropic.Tool.InputSchema,
55+
});
56+
}
57+
58+
this.servers.set(name, client);
59+
console.log(
60+
` Connected to ${name} with tools: ${toolsResult.tools.map((t) => t.name).join(', ')}`
61+
);
62+
} catch (e) {
63+
console.error(` Failed to connect to ${name}:`, e);
64+
throw e;
65+
}
66+
}
67+
68+
console.log(`\nTotal tools available: ${this.tools.length}`);
69+
}
70+
71+
async processQuery(query: string) {
72+
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: query }];
73+
74+
// Agentic loop: keep processing until the model stops issuing tool calls
75+
let response = await this.anthropic.messages.create({
76+
model: ANTHROPIC_MODEL,
77+
max_tokens: 1000,
78+
messages,
79+
tools: this.tools,
80+
});
81+
82+
const finalText: string[] = [];
83+
84+
while (response.stop_reason === 'tool_use') {
85+
const assistantContent = response.content;
86+
messages.push({ role: 'assistant', content: assistantContent });
87+
88+
const toolResults: Anthropic.ToolResultBlockParam[] = [];
89+
90+
for (const block of assistantContent) {
91+
if (block.type === 'text') {
92+
finalText.push(block.text);
93+
} else if (block.type === 'tool_use') {
94+
const serverName = this.toolToServer.get(block.name);
95+
if (!serverName) {
96+
toolResults.push({
97+
type: 'tool_result',
98+
tool_use_id: block.id,
99+
content: `Error: unknown tool "${block.name}"`,
100+
is_error: true,
101+
});
102+
continue;
103+
}
104+
105+
const client = this.servers.get(serverName)!;
106+
console.log(` [Calling ${block.name} on server "${serverName}"]`);
107+
108+
try {
109+
const result = await client.callTool({
110+
name: block.name,
111+
arguments: block.input as Record<string, unknown> | undefined,
112+
});
113+
114+
const resultText = result.content
115+
.filter((c): c is Anthropic.TextBlock => c.type === 'text')
116+
.map((c) => c.text)
117+
.join('\n');
118+
119+
toolResults.push({
120+
type: 'tool_result',
121+
tool_use_id: block.id,
122+
content: resultText,
123+
});
124+
} catch (e) {
125+
toolResults.push({
126+
type: 'tool_result',
127+
tool_use_id: block.id,
128+
content: `Error executing tool: ${e}`,
129+
is_error: true,
130+
});
131+
}
132+
}
133+
}
134+
135+
messages.push({ role: 'user', content: toolResults });
136+
137+
response = await this.anthropic.messages.create({
138+
model: ANTHROPIC_MODEL,
139+
max_tokens: 1000,
140+
messages,
141+
tools: this.tools,
142+
});
143+
}
144+
145+
// Collect any remaining text from the final response
146+
for (const block of response.content) {
147+
if (block.type === 'text') {
148+
finalText.push(block.text);
149+
}
150+
}
151+
152+
return finalText.join('\n');
153+
}
154+
155+
async chatLoop() {
156+
const rl = readline.createInterface({
157+
input: process.stdin,
158+
output: process.stdout,
159+
});
160+
161+
try {
162+
console.log('\nMulti-Server MCP Client Started!');
163+
console.log('Type your queries or "quit" to exit.\n');
164+
165+
while (true) {
166+
const message = await rl.question('Query: ');
167+
if (message.toLowerCase() === 'quit') {
168+
break;
169+
}
170+
const response = await this.processQuery(message);
171+
console.log('\n' + response + '\n');
172+
}
173+
} finally {
174+
rl.close();
175+
}
176+
}
177+
178+
async cleanup() {
179+
for (const [name, client] of this.servers) {
180+
console.log(`Disconnecting from ${name}...`);
181+
await client.close();
182+
}
183+
}
184+
}
185+
186+
async function main() {
187+
const configPath = process.argv[2] ?? 'servers.json';
188+
189+
const mcpClient = new MultiServerClient();
190+
try {
191+
await mcpClient.connectToServers(configPath);
192+
193+
const apiKey = process.env.ANTHROPIC_API_KEY;
194+
if (!apiKey) {
195+
console.log(
196+
'\nNo ANTHROPIC_API_KEY found. To chat with these tools via Claude, set your API key:'
197+
+ '\n export ANTHROPIC_API_KEY=your-api-key-here'
198+
);
199+
return;
200+
}
201+
202+
await mcpClient.chatLoop();
203+
} catch (e) {
204+
console.error('Error:', e);
205+
process.exit(1);
206+
} finally {
207+
await mcpClient.cleanup();
208+
process.exit(0);
209+
}
210+
}
211+
212+
main();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2023",
4+
"lib": ["ES2023"],
5+
"module": "Node16",
6+
"moduleResolution": "Node16",
7+
"outDir": "./build",
8+
"rootDir": "./src",
9+
"strict": true,
10+
"esModuleInterop": true,
11+
"skipLibCheck": true,
12+
"forceConsistentCasingInFileNames": true,
13+
"paths": {
14+
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
15+
"@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"],
16+
"@modelcontextprotocol/core": [
17+
"./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts"
18+
]
19+
}
20+
},
21+
"include": ["src/**/*"],
22+
"exclude": ["node_modules"]
23+
}

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)