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.
| Consideration | Client-side multi-server | Aggregator |
|---|---|---|
| Setup complexity | Low | High |
| Single point of failure | No — one server down = its tools missing, others unaffected | Yes — aggregator down = all tools unavailable |
| Tool name collision | Client-specific behaviour (usually last-write-wins) | Explicit prefix — fully controlled |
| Client config required | Yes — every client must be reconfigured for each new server | No — one endpoint, clients add one entry |
| Auth per server | Each client entry has its own auth credentials | Aggregator holds all child credentials, client authenticates once |
| Max practical servers | ~5 before config management becomes painful | Unlimited |
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 type | Where stored | How forwarded to child |
|---|---|---|
| Child service account API key | Aggregator environment variable | Static Authorization header in SSEClientTransport |
| Per-user context (userId, tenantId) | Aggregator session context | Custom MCP metadata headers, or embedded in tool arguments |
| Client's JWT token | Aggregator incoming request | Never 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:
- Add a separate AliveMCP monitor for each child server URL. A child going down appears immediately in the child's probe, not in the aggregator's probe.
- Add a
health_checktool on the aggregator that pings each child via their client connection and returns per-child health. Configure a synthetic probe to call this tool on a schedule. - Alert on child-specific error rates: if tools with the
github__prefix are returningisError: trueat a high rate, the GitHub child is degraded even if the aggregator probe shows green.
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
- MCP server session lifecycle — connection management and reconnection
- MCP server authentication — JWT, OAuth, and session-level identity
- MCP server load balancing — sticky sessions and stateless mode
- MCP server health check — protocol probe and /healthz patterns
- MCP server with Claude Desktop — multi-server configuration
- AliveMCP — continuous protocol monitoring for MCP servers