Skip to content

feat: add multi-server chatbot client example#1691

Open
travisbreaks wants to merge 4 commits intomodelcontextprotocol:mainfrom
travisbreaks:feat/multi-server-chatbot
Open

feat: add multi-server chatbot client example#1691
travisbreaks wants to merge 4 commits intomodelcontextprotocol:mainfrom
travisbreaks:feat/multi-server-chatbot

Conversation

@travisbreaks
Copy link
Copy Markdown

Summary

Adds a client-multi-server example that connects to multiple MCP servers simultaneously and routes tool calls to the correct server. TypeScript equivalent of the Python SDK's simple-chatbot example.

Fixes #740

What it does

  • Reads server config from servers.json (same format as Claude Desktop's mcpServers block)
  • Connects to each MCP server via stdio, discovers tools from all servers
  • Builds a tool-to-server routing map for dispatching tool calls
  • Sends the unified tool list to Claude with each request
  • Supports multi-step tool use via an agentic loop
  • Simple readline CLI interface

Implementation

  • Follows the client-quickstart example pattern (package structure, tsconfig, dependencies)
  • ~170 lines of focused, well-commented code
  • Ships with a default servers.json pointing to server-everything for easy testing

Test plan

  • All 386 existing tests pass
  • Full typecheck passes across the repo
  • Lint passes
  • Example added to changeset ignore list (matches quickstart convention)

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 modelcontextprotocol#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>
@travisbreaks travisbreaks requested a review from a team as a code owner March 17, 2026 03:39
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 17, 2026

⚠️ No Changeset found

Latest commit: a1bde15

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 17, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1691

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1691

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1691

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1691

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1691

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1691

commit: a1bde15

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — straightforward new example following the existing client-quickstart pattern with only minor nits.

Extended reasoning...

Overview

This PR adds a new examples/client-multi-server/ directory containing a ~170-line CLI chatbot that connects to multiple MCP servers, aggregates tools, and routes tool calls. It also adds the expected config entries (.changeset/config.json ignore list, .prettierignore). No library code is modified — only new example files and the lockfile.

Security risks

None. This is a standalone CLI example that reads a local JSON config and calls the Anthropic API. No auth, crypto, or permissions code is involved. The env passthrough in transport config mirrors the existing quickstart pattern.

Level of scrutiny

Low scrutiny is appropriate. This is self-contained example code that closely mirrors the existing client-quickstart example in structure, dependencies, and patterns. It does not affect any published packages (added to changeset ignore list). The examples/ directory is not covered by CODEOWNERS.

Other factors

All three bug reports are nits on example code with no runtime impact: (1) a type guard narrowing to the wrong SDK type (works due to structural compatibility), (2) no warning on tool name collisions across servers, and (3) a theoretical client leak if listTools fails after connect. These are worth noting as inline comments for the author to consider but do not block merging. The PR fixes an open issue (#740) and the author confirms all tests, typecheck, and lint pass.

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Thanks for this — the mcpServers config parsing and agentic loop are solid. A few changes before merge:

1. Prefix tool names with server name

The flat Map<toolName, serverName> means if two servers both expose a tool with the same name, the last one silently overwrites the first. Prefix tool names to avoid this:

const prefixedName = `${serverName}__${tool.name}`;
this.toolToServer.set(prefixedName, { serverName, originalName: tool.name });
this.tools.push({ name: prefixedName, ... });

Then strip the prefix before calling the server.

2. Default servers.json should have 2+ servers

A multi-server example with one server in the default config doesn't demonstrate the routing. Add @modelcontextprotocol/server-time as a second entry — it works via npx with no extra setup.

3. Fix the content type predicate

filter((c): c is Anthropic.TextBlock => c.type === 'text') — MCP CallToolResult content blocks aren't Anthropic.TextBlock. Use TextContent from @modelcontextprotocol/core.

- Prefix tool names with serverName__ to prevent silent overwrites when
  multiple servers expose identically-named tools. Original names are
  preserved and used for the actual callTool requests.
- Fix incorrect type guard that narrowed MCP ContentBlock to
  Anthropic.TextBlock (wrong type). Now uses plain .filter().
- Move this.servers.set() before listTools() so cleanup can still close
  the client if tool discovery throws.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines +96 to +108
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;
}
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 }),
});

Comment on lines +193 to +210
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);
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
}

Comment on lines +180 to +192
rl.close();
}
}

async cleanup() {
for (const [name, client] of this.servers) {
console.log(`Disconnecting from ${name}...`);
await client.close();
}
}
}

async function main() {
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.

Comment on lines +47 to +55

// 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.`
);
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.

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Thanks for the iterations - apologies, my previous review was posted prematurely.

It's probably valid to call out some multi-server example. but this mostly duplicates the quickstart and pins another anthropic SDK version. The novel part is the tool-name prefixing and server routing.

I think this could be slimmed down to a minimal routing example, something that spawns 2 in-repo servers (e.g. server-quickstart + mcpServerOutputSchema) and just shows the routing between them rather than a full chatbot client implementation. That would also drop the Anthropic API key dep so it runs without it.

Replace the full chatbot implementation with a minimal script that:
- Spawns two in-repo servers (server-quickstart + mcpServerOutputSchema)
- Connects a Client to each via StdioClientTransport
- Discovers tools and builds a prefixed routing table
- Calls one tool on each server to demonstrate routing

Removes the Anthropic SDK dependency and servers.json config file.
No API key required to run.

Co-Authored-By: Tadao <tadao@travisfixes.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can we have a chatbot example that connected to multiple remote servers

2 participants