Guide · MCP Architecture

MCP multi-server setup

A single MCP server is rarely the right boundary for a growing toolset. Teams split servers by domain — the data team owns database tools, the auth team owns identity tools, the platform team owns infrastructure tools — or by runtime (Python for ML tools, Node.js for API integrations). MCP clients support this natively: Claude Desktop, Cursor, and other hosts let you configure multiple server entries, each exposing its own tools independently. But client-side multi-server configuration has limits: tool name collisions become unmanageable at scale, and you cannot control which tools from which server a client chooses. The aggregator pattern — one server that acts as an MCP client to several child servers and re-exposes their tools under namespaced names — solves these problems at the cost of an additional network hop and a new monitoring surface.

TL;DR

For two to five servers with different tool domains, configure each client to connect to multiple servers directly — it is simpler and has no single point of failure. For more than five servers, or when you need a single connection endpoint, build an aggregator: one MCP server that connects to child servers as an MCP client, prefixes their tool names (github__search_repos, slack__send_message), and proxies calls to the appropriate child. Monitor every child server with a separate AliveMCP probe — the aggregator being up does not mean all children are healthy.

Pattern 1: client-side multi-server configuration

The simplest multi-server setup is to configure each host application with multiple server entries. Claude Desktop, Cursor, Cline, and Continue all support this natively via their mcpServers configuration object.

// ~/.claude/claude_desktop_config.json (Claude Desktop)
// ~/.cursor/mcp.json (Cursor)
{
  "mcpServers": {
    "data-tools": {
      "command": "node",
      "args": ["/path/to/data-server/dist/server.js"],
      "env": { "DATABASE_URL": "postgres://..." }
    },
    "github-tools": {
      "type": "sse",
      "url": "https://github-mcp.internal/sse",
      "headers": { "Authorization": "Bearer ghp_..." }
    },
    "infra-tools": {
      "type": "sse",
      "url": "https://infra-mcp.internal/sse",
      "headers": { "Authorization": "Bearer eyJ..." }
    }
  }
}

The client initializes a separate session with each server and merges their tool lists into a single pool. When the LLM invokes a tool, the client routes the call to whichever server registered that tool name.

ConsiderationClient-side multi-serverAggregator
Setup complexityLowHigh
Single point of failureNo — one server down = its tools missing, others unaffectedYes — aggregator down = all tools unavailable
Tool name collisionClient-specific behaviour (usually last-write-wins)Explicit prefix — fully controlled
Client config requiredYes — every client must be reconfigured for each new serverNo — one endpoint, clients add one entry
Auth per serverEach client entry has its own auth credentialsAggregator holds all child credentials, client authenticates once
Max practical servers~5 before config management becomes painfulUnlimited

Tool name collision handling

When two servers register a tool with the same name (e.g., both a GitHub server and a GitLab server register search_repos), the client behaviour is undefined — typically last-registered wins and the first server's tool is silently shadowed. This is a reliability risk that scales with the number of servers.

The fix at the client-side level is to use server-name prefixes consistently when naming tools. If you control the servers, adopt a convention: all GitHub tools are named github_*, all Slack tools are named slack_*, etc. If you do not control the servers (third-party or community MCPs), an aggregator with explicit prefixing is the only reliable solution.

Pattern 2: aggregator server

An aggregator acts as an MCP server to upstream clients and as an MCP client to each child server. It collects tool lists from all children at startup, registers each tool under a namespaced name on its own McpServer instance, and proxies calls to the appropriate child.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { z } from 'zod';

interface ChildConfig {
  name: string;           // Used as namespace prefix
  url: string;
  authToken?: string;
}

const CHILDREN: ChildConfig[] = [
  { name: 'github', url: 'https://github-mcp.internal/sse', authToken: process.env.GITHUB_TOKEN },
  { name: 'slack',  url: 'https://slack-mcp.internal/sse',  authToken: process.env.SLACK_TOKEN  },
  { name: 'data',   url: 'https://data-mcp.internal/sse',   authToken: process.env.DATA_TOKEN   },
];

async function buildAggregator() {
  const aggregator = new McpServer({ name: 'aggregator', version: '1.0.0' });
  const childClients = new Map<string, Client>(); // namespace → client

  for (const child of CHILDREN) {
    const transport = new SSEClientTransport(new URL(child.url), {
      headers: child.authToken ? { Authorization: `Bearer ${child.authToken}` } : {},
    });

    const client = new Client({ name: 'aggregator', version: '1.0.0' }, { capabilities: {} });
    await client.connect(transport);
    childClients.set(child.name, client);

    // Fetch the child's tool list
    const { tools } = await client.listTools();

    for (const tool of tools) {
      const prefixedName = `${child.name}__${tool.name}`;

      // Register each child tool on the aggregator under a prefixed name
      aggregator.tool(
        prefixedName,
        tool.description ?? '',
        buildZodSchema(tool.inputSchema), // convert JSON Schema to Zod
        async (args) => {
          // Proxy the call to the child
          const childClient = childClients.get(child.name)!;
          try {
            return await childClient.callTool({ name: tool.name, arguments: args });
          } catch (err) {
            return {
              content: [{ type: 'text', text: `Child server "${child.name}" error: ${err.message}` }],
              isError: true,
            };
          }
        }
      );
    }
  }

  return aggregator;
}

The buildZodSchema() helper converts the child's JSON Schema inputSchema into a Zod schema for the aggregator's tool registration. A simple implementation wraps the entire schema as z.object({}).passthrough() and forwards args as-is; a more thorough implementation reconstructs the Zod schema from the JSON Schema properties.

Failure isolation

One of the aggregator's most important responsibilities is ensuring that a broken child does not take down the entire aggregator. Wrap every child connection in an error boundary at startup and every child tool call in a try/catch that returns isError: true rather than throwing.

// At startup — continue even if some children fail to connect
const childResults = await Promise.allSettled(
  CHILDREN.map(async (child) => {
    const client = await connectChild(child);
    const { tools } = await client.listTools();
    return { child, client, tools };
  })
);

for (const result of childResults) {
  if (result.status === 'rejected') {
    logger.error({ err: result.reason }, 'Child server failed to connect at startup — skipping');
    continue; // Aggregator starts with remaining healthy children
  }
  registerChildTools(aggregator, result.value);
}

// In proxy tool handlers — never let child errors propagate as uncaught exceptions
aggregator.tool(prefixedName, '', schema, async (args) => {
  try {
    return await child.callTool({ name: toolName, arguments: args });
  } catch (err) {
    return {
      content: [{
        type: 'text',
        text: `The ${childName} server is temporarily unavailable. Please try again later.`,
      }],
      isError: true,
    };
  }
});

Dynamic aggregation and tool list changes

Child servers can update their tool lists at runtime using notifications/tools/list_changed. An aggregator that handles this notification can refresh its merged tool list and notify its own upstream clients:

// Subscribe to tool list changes from each child
client.setNotificationHandler(
  { method: 'notifications/tools/list_changed' },
  async () => {
    logger.info({ child: childName }, 'Child tool list changed — refreshing');

    // Remove old tools from this child, re-fetch and re-register
    const { tools: newTools } = await client.listTools();
    updateAggregatorToolsForChild(aggregator, childName, newTools);

    // Notify upstream clients that the aggregator's tool list changed
    await aggregator.server.sendToolListChanged();
  }
);

Note: most clients cache their tools/list response for the session lifetime and do not automatically re-issue tools/list after receiving notifications/tools/list_changed. Dynamic tool registration is most reliable at session-initialization time; mid-session changes are advisory and may not take effect until the client reconnects.

Auth forwarding

The aggregator holds credentials for each child server. Never forward the upstream client's credentials to child servers — the client authenticated with the aggregator, and child servers should authenticate with the aggregator's service account, not the individual user.

Credential typeWhere storedHow forwarded to child
Child service account API keyAggregator environment variableStatic Authorization header in SSEClientTransport
Per-user context (userId, tenantId)Aggregator session contextCustom MCP metadata headers, or embedded in tool arguments
Client's JWT tokenAggregator incoming requestNever forward — verify at aggregator, extract claims, pass claims not token

If children need user identity (for per-user data isolation), forward it via a custom header on the child's SSEClientTransport that the aggregator sets per session, or add the userId as an argument to the child's tool calls. Never pass the upstream client's raw JWT to a child server — the child would need to verify a token issued by a third-party authority it may not trust.

Monitoring multi-server deployments

The aggregator endpoint being healthy does not mean the child servers are healthy. If the github child is down, the aggregator responds normally to tools/list — the GitHub tools simply return isError: true when called. From the aggregator's perspective, it is healthy. From the user's perspective, GitHub tools are broken.

Monitor each server independently:

aggregator.tool('health_check', {}, async () => {
  const childHealth = await Promise.allSettled(
    CHILDREN.map(async (child) => {
      const client = childClients.get(child.name)!;
      const start = Date.now();
      await client.ping();
      return { name: child.name, status: 'healthy', latency_ms: Date.now() - start };
    })
  );

  const results = childHealth.map((r, i) =>
    r.status === 'fulfilled'
      ? r.value
      : { name: CHILDREN[i].name, status: 'unhealthy', error: r.reason.message }
  );

  const anyUnhealthy = results.some(r => r.status === 'unhealthy');
  return {
    content: [{ type: 'text', text: JSON.stringify({ children: results }) }],
    isError: anyUnhealthy,
  };
});

Frequently asked questions

How many child servers can an aggregator handle?

In practice, dozens. Each child requires one persistent SSE connection (or one HTTP connection per tool call for stateless children). The aggregator's Node.js process can hold many concurrent SSE connections without issue — each is an open HTTP/1.1 or HTTP/2 connection with an idle SSE stream. The bottleneck is usually the child server's own connection limits, not the aggregator. At very large scales (hundreds of children), consider connection pooling with client reconnection on demand rather than holding all connections open at startup.

Can I use an aggregator to merge stdio servers with SSE servers?

Yes. The aggregator connects to each child using whichever transport that child supports. Use StdioClientTransport for stdio children (spawning the child process as a subprocess of the aggregator) and SSEClientTransport for remote HTTP/SSE children. The aggregator itself should expose an HTTP/SSE endpoint — it cannot meaningfully expose a stdio endpoint if it needs to serve multiple upstream clients simultaneously (stdio is inherently one-to-one).

What happens to the tool namespace prefix when the tool name is already long?

MCP tool names have a practical length limit in LLM context (not a protocol limit, but a quality concern — very long names consume tokens and degrade LLM selection accuracy). For aggregators, keep prefixes short and meaningful: gh__ not github_production_api_v3__. Use single-underscore or double-underscore as a separator convention and stick to it. If a child's tool name is already long (e.g., create_pull_request_with_template), a prefix of gh__ yields gh__create_pull_request_with_template — still within a workable length.

Can I share a session context across the aggregator and its children?

Indirectly. The aggregator resolves the upstream client's identity (userId, tenantId) from their JWT or API key at session initialization. It stores this in its own sessionContextMap. When proxying a tool call, it can embed the resolved identity in a custom header sent to the child, or include it as an additional argument to the child's tool. The child does not see the upstream client's session or credentials — it sees the aggregator's service account credentials plus whatever identity the aggregator chooses to forward.

Should each child server have its own AliveMCP monitor?

Yes, absolutely. The aggregator probe verifies that the aggregator endpoint is reachable and that its tool list is served correctly. It cannot detect that a specific child is down — the aggregator's tools/list always includes all registered tools regardless of child health. Configure one AliveMCP health check per child server URL so that a child outage immediately alerts the on-call engineer at the child layer, not only when users start reporting that specific tools are failing.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free