Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"updateInternalDependencies": "patch",
"ignore": [
"@modelcontextprotocol/examples-client",
"@modelcontextprotocol/examples-client-multi-server",
"@modelcontextprotocol/examples-client-quickstart",
"@modelcontextprotocol/examples-server",
"@modelcontextprotocol/examples-server-quickstart",
Expand Down
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pnpm-lock.yaml
# Ignore generated files
src/spec.types.ts

# Quickstart examples uses 2-space indent to match ecosystem conventions
# Standalone examples use 2-space indent to match ecosystem conventions
examples/client-multi-server/
examples/client-quickstart/
examples/server-quickstart/
1 change: 1 addition & 0 deletions examples/client-multi-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
82 changes: 82 additions & 0 deletions examples/client-multi-server/README.md
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...
```
21 changes: 21 additions & 0 deletions examples/client-multi-server/package.json
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"
}
}
8 changes: 8 additions & 0 deletions examples/client-multi-server/servers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"everything": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}
218 changes: 218 additions & 0 deletions examples/client-multi-server/src/index.ts
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.`
);
Comment on lines +47 to +55
Copy link
Copy Markdown

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} and name is 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 toolToServer already contains prefixedName, where prefixedName = 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 name and each tool from that server, the code computes prefixedName as 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, name is 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 if block 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 if block 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':

  • Iteration 1 (name = 'weather'): prefixedName = 'weather__fetch'. Map is empty, no collision. Map now contains 'weather__fetch'.
  • Iteration 2 (name = 'news'): prefixedName = 'news__fetch'. Map contains 'weather__fetch' only. has('news__fetch') is false — no collision fires.

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.

}
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 is

In 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 path

Lines 120-129 of examples/client-multi-server/src/index.ts:

const resultText = result.content
  .filter((c) => c.type === "text")
  .map((c) => c.text)
  .join("

");

toolResults.push({
  type: "tool_result",
  tool_use_id: block.id,
  content: resultText,   // no is_error: true even when result.isError === true
});

The is_error field is only set inside the catch block, never in the success path.

Why existing code does not prevent it

The 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.

Impact

The 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

  1. An MCP server returns { isError: true, content: [{ type: "text", text: "File not found" }] } from a callTool request.
  2. The code enters the try block (no exception is thrown).
  3. result.content.filter/map/join produces "File not found".
  4. toolResults.push({ type: "tool_result", tool_use_id: block.id, content: "File not found" }) -- no is_error: true.
  5. Claude receives a tool_result block with content: "File not found" and no error flag; it interprets this as successful output and continues reasoning incorrectly.

How to fix it

Propagate result.isError to the block param:

toolResults.push({
  type: "tool_result",
  tool_use_id: block.id,
  content: resultText,
  ...(result.isError && { is_error: true }),
});


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The ANTHROPIC_API_KEY check occurs after connectToServers() in main(), meaning a user who forgot to set the key will first wait for npm package downloads, server spawning, and full tool discovery before being told to set their key. Moving the API key check to before connectToServers() would fail fast with a clear message.

Extended reasoning...

What the bug is

In main(), connectToServers(configPath) is called unconditionally before the ANTHROPIC_API_KEY environment variable is read (lines 185 and 188 respectively in the final file). Only after all server connections and tool discovery complete does the code check for the key and print a friendly error message.

The specific code path

async 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 it

There is no early validation of required prerequisites before entering the expensive setup phase. The API key check is deferred until after connectToServers() completes, which for the default servers.json triggers npx -y @modelcontextprotocol/server-everything — downloading and installing the npm package on first run before establishing the MCP connection and discovering all tools.

Addressing the refutation

The 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 listTools() display or similar. The message itself says "To chat with these tools via Claude" — signalling that chatting is the only intended purpose. The README also lists the API key as a prerequisite before running.

Step-by-step proof

  1. User clones the repo and runs npx tsx src/index.ts without setting ANTHROPIC_API_KEY.
  2. connectToServers() starts. It reads servers.json and finds the everything entry using npx -y @modelcontextprotocol/server-everything.
  3. On first run, npx downloads and installs @modelcontextprotocol/server-everything (~seconds to a minute depending on network).
  4. The server starts via stdio, MCP handshake completes, listTools() is called and all tools are discovered.
  5. connectToServers() returns. Only now is process.env.ANTHROPIC_API_KEY read.
  6. The key is missing. A friendly message is printed and the function returns.
  7. cleanup() in finally iterates all connected servers and calls client.close() on each — closing connections that were opened purely to be immediately thrown away.
  8. The user waited significant time (npm download + install + server boot) just to learn they need to set an env var.

How to fix

Move the API key check to the top of main(), before connectToServers():

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In main(), process.exit(1) in the catch block terminates the Node.js process synchronously before the finally block runs, so mcpClient.cleanup() is never called on error. The fix is to replace process.exit(1) with process.exitCode = 1, allowing control to fall through to the finally block which then calls cleanup() before process.exit(0).

Extended reasoning...

What the bug is

In main() (lines 193-210 of examples/client-multi-server/src/index.ts), the error handling uses process.exit(1) inside the catch block. In Node.js, process.exit() terminates the process synchronously and immediately — it halts the event loop without unwinding the JavaScript call stack. This means finally blocks do not execute after process.exit() is called.

The specific code path

try {
  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 connectToServers() or chatLoop() throws, the catch block calls process.exit(1), terminating the process before the finally block can execute mcpClient.cleanup().

Why existing code does not prevent it

This is a fundamental Node.js behavior — try/finally only ensures the finally block runs when control leaves the try block via normal flow, exceptions, or return/break. It does not protect against process.exit(), which terminates the process unconditionally without stack unwinding.

Step-by-step proof

  1. connectToServers() successfully connects to servers A and B, then server C fails and throws.
  2. The catch block runs: console.error('Error:', e) logs the error.
  3. process.exit(1) is called — the Node.js event loop halts immediately.
  4. The finally block never executes. mcpClient.cleanup() is never called.
  5. The stdio transports for servers A and B are not gracefully closed via the MCP protocol. While the OS will eventually clean up file descriptors and child processes will receive EOF/SIGPIPE on stdin, there is no graceful MCP shutdown.

Impact

For 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 process.exit() inside a catch alongside a finally is a pedagogically incorrect pattern that defeats the purpose of finally and could cause real resource leaks in production services derived from this example.

How to fix

Replace process.exit(1) in the catch block with process.exitCode = 1. This sets the exit code without terminating immediately, allowing control to fall through to the finally block:

} 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();
23 changes: 23 additions & 0 deletions examples/client-multi-server/tsconfig.json
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"]
}
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading