Introduction

Real-world MCP applications often need capabilities from multiple servers. A coding assistant might connect to a filesystem server, a database server, and a GitHub server simultaneously. This lesson covers managing multiple Client instances, the one-client-per-server relationship, initialization strategies, merging tool lists, and handling tool name collisions.

Key Concepts

  • One Client Per Server: Each MCP server connection requires its own dedicated Client instance. A single Client cannot connect to multiple servers.
  • Parallel Initialization: Starting multiple server connections concurrently using Promise.all() to reduce startup time.
  • Tool Merging: Combining tool lists from multiple servers into a single array that represents all available capabilities.
  • Name Collision: When two or more servers expose tools with the same name, requiring a disambiguation strategy such as server-prefixed names.

Real World Context

A DevOps chatbot connects to three MCP servers: one for GitHub (pull requests, issues), one for a PostgreSQL database (query execution), and one for Docker (container management). At startup, the application creates three Client instances, connects each to its respective server, merges all available tools into a single list, and presents them to the LLM. When the LLM calls a tool, the application routes the call to the correct Client based on which server owns that tool.

Deep Dive

Each server connection requires its own Client instance. The MCP protocol is a stateful, one-to-one connection between a client and a server.

The following code creates and connects multiple clients:

typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const dbClient = new Client({ name: 'app', version: '1.0.0' });
await dbClient.connect(
  new StdioClientTransport({ command: 'node', args: ['db-server.js'] })
);

const fsClient = new Client({ name: 'app', version: '1.0.0' });
await fsClient.connect(
  new StdioClientTransport({ command: 'node', args: ['fs-server.js'] })
);

const ghClient = new Client({ name: 'app', version: '1.0.0' });
await ghClient.connect(
  new StdioClientTransport({ command: 'node', args: ['gh-server.js'] })
);

Each Client instance maintains its own connection state, protocol session, and capability negotiation with its server.

For applications with many servers, parallel initialization significantly reduces startup time.

Here is how to initialize multiple servers concurrently:

typescript
interface ServerConfig {
  name: string;
  command: string;
  args: string[];
}

const servers: ServerConfig[] = [
  { name: 'database', command: 'node', args: ['db-server.js'] },
  { name: 'filesystem', command: 'node', args: ['fs-server.js'] },
  { name: 'github', command: 'node', args: ['gh-server.js'] }
];

const clients = new Map<string, Client>();

await Promise.all(servers.map(async (config) => {
  const client = new Client({ name: 'app', version: '1.0.0' });
  const transport = new StdioClientTransport({
    command: config.command,
    args: config.args
  });
  await client.connect(transport);
  clients.set(config.name, client);
}));

Using Promise.all starts all server processes simultaneously instead of waiting for each one sequentially.

Once all servers are connected, you merge their tool lists into a single array. This gives the LLM a unified view of all available tools.

The following code merges tools and builds a routing map:

typescript
const serverMap = new Map<string, Client>();
const allTools = [];

for (const [serverName, client] of clients) {
  const { tools } = await client.listTools();
  for (const tool of tools) {
    serverMap.set(tool.name, client);
    allTools.push(tool);
  }
}

// allTools can now be sent to the LLM as available functions
console.log(`Total tools available: ${allTools.length}`);

The serverMap lets you look up which Client to use when the LLM calls a specific tool.

Tool name collisions happen when two servers expose a tool with the same name. The simplest solution is to prefix tool names with the server identifier.

Here is a collision-safe merging strategy:

typescript
const toolRegistry = new Map<string, { client: Client; originalName: string }>();
const allTools = [];

for (const [serverName, client] of clients) {
  const { tools } = await client.listTools();
  for (const tool of tools) {
    const prefixedName = `${serverName}__${tool.name}`;
    toolRegistry.set(prefixedName, { client, originalName: tool.name });
    allTools.push({ ...tool, name: prefixedName });
  }
}

// When calling a tool, look up the original name and client
async function callTool(prefixedName: string, args: Record<string, unknown>) {
  const entry = toolRegistry.get(prefixedName);
  if (!entry) throw new Error(`Unknown tool: ${prefixedName}`);
  return entry.client.callTool({ name: entry.originalName, arguments: args });
}

This approach uses a double-underscore separator (e.g., database__query, github__query) to create unique names while preserving the original tool name for the actual server call.

Common Pitfalls

  1. Reusing a single Client for multiple servers: Each Client can only connect to one server. Attempting to call connect() twice on the same Client will fail or produce undefined behavior.
  2. Ignoring tool name collisions: If two servers expose a tool named search, the second one silently overwrites the first in a simple Map. Always check for collisions or use prefixing.
  3. Sequential initialization without need: Connecting to servers one at a time wastes startup time. Use Promise.all unless servers have dependencies on each other.

Best Practices

  1. Store server configurations externally (JSON file, environment variables) so users can add or remove servers without code changes.
  2. Always use server-prefixed tool names when connecting to more than one server to prevent collisions.
  3. Log each server connection's success or failure independently so you can diagnose startup issues per server.

Summary

  • Each MCP server requires its own dedicated Client instance (one-to-one relationship).
  • Use Promise.all for parallel initialization to reduce startup time.
  • Merge tool lists from all servers into a unified array for the LLM.
  • Handle tool name collisions with server-prefixed names (e.g., serverName__toolName).
  • Build a routing map that associates each tool name with its owning Client for correct call dispatching.

Code Examples

typescript
const servers = [
  { name: 'database', command: 'node', args: ['db-server.js'] },
  { name: 'filesystem', command: 'node', args: ['fs-server.js'] },
  { name: 'github', command: 'node', args: ['gh-server.js'] }
];

const clients = new Map<string, Client>();

await Promise.all(servers.map(async (config) => {
  const client = new Client({ name: 'app', version: '1.0.0' });
  await client.connect(
    new StdioClientTransport({ command: config.command, args: config.args })
  );
  clients.set(config.name, client);
}));
typescript
const toolRegistry = new Map<string, { client: Client; originalName: string }>();
const allTools = [];

for (const [serverName, client] of clients) {
  const { tools } = await client.listTools();
  for (const tool of tools) {
    const prefixedName = `${serverName}__${tool.name}`;
    toolRegistry.set(prefixedName, { client, originalName: tool.name });
    allTools.push({ ...tool, name: prefixedName });
  }
}
✓ Completed