Introduction
An MCP gateway aggregates multiple backend servers behind a single interface, acting as a proxy that simplifies client connections. Instead of connecting to five servers individually, a client connects to one gateway. This lesson covers the gateway pattern, dynamic server registration, proxy architecture, load balancing, and adding cross-cutting concerns like authentication, logging, and caching.
Key Concepts
- MCP Gateway: An intermediary that exposes a unified MCP interface to clients while internally routing requests to multiple backend MCP servers.
- Dynamic Registration: Adding or removing backend servers from the gateway at runtime without restarting.
- Proxy Pattern: The gateway presents backend server tools as its own, translating client requests into backend server calls.
- Cross-Cutting Concerns: Middleware-like functionality (auth, logging, caching, rate limiting) added at the gateway layer, applied uniformly to all backend servers.
Real World Context
A company has ten MCP servers for different teams (frontend, backend, database, DevOps, etc.). Instead of configuring every developer's Claude Desktop with ten server entries, they deploy a single MCP gateway. Developers connect to the gateway, which aggregates all tools. The gateway adds SSO authentication, request logging, and rate limiting. When a new team deploys a server, they register it with the gateway and all developers immediately get access.
Deep Dive
A gateway is itself an MCP server that internally manages multiple MCP client connections. It exposes tools from all backend servers as if they were its own.
The following code shows the core structure of an MCP gateway:
typescriptimport { McpServer } from '@modelcontextprotocol/server'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import * as z from 'zod/v4'; class McpGateway { private gateway: McpServer; private backends = new Map<string, Client>(); private toolMap = new Map<string, { client: Client; originalName: string }>(); constructor() { this.gateway = new McpServer( { name: 'mcp-gateway', version: '1.0.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } } ); } async addBackend(name: string, command: string, args: string[]) { const client = new Client({ name: 'gateway', version: '1.0.0' }); await client.connect( new StdioClientTransport({ command, args }) ); this.backends.set(name, client); const { tools } = await client.listTools(); for (const tool of tools) { const gatewayName = `${name}__${tool.name}`; this.toolMap.set(gatewayName, { client, originalName: tool.name }); this.gateway.tool( gatewayName, `[${name}] ${tool.description ?? ''}`, { inputSchema: z.object({}).passthrough() }, async (args) => { return client.callTool({ name: tool.name, arguments: args }); } ); } } }
The gateway creates its own McpServer, connects to backend servers as a client, then re-exposes each backend tool under a prefixed name. When a client calls a gateway tool, the handler forwards the request to the appropriate backend.
Dynamic registration allows adding and removing servers at runtime.
Here is how to add a registration API to the gateway:
typescriptclass McpGateway { // ... previous code ... async removeBackend(name: string) { const client = this.backends.get(name); if (!client) return; // Remove all tools from this backend for (const [gatewayName, entry] of this.toolMap) { if (gatewayName.startsWith(`${name}__`)) { this.toolMap.delete(gatewayName); } } await client.close(); this.backends.delete(name); } listBackends(): string[] { return Array.from(this.backends.keys()); } getToolCount(): number { return this.toolMap.size; } }
Removing a backend cleans up its tools from the registry and closes the client connection.
The gateway is the ideal place to add cross-cutting concerns. Since all requests flow through it, you can add authentication, logging, and caching in one place.
The following example adds logging and basic caching to tool calls:
typescriptasync addBackendWithMiddleware(name: string, command: string, args: string[]) { const client = new Client({ name: 'gateway', version: '1.0.0' }); await client.connect(new StdioClientTransport({ command, args })); this.backends.set(name, client); const { tools } = await client.listTools(); const cache = new Map<string, { result: any; timestamp: number }>(); for (const tool of tools) { const gatewayName = `${name}__${tool.name}`; this.gateway.tool( gatewayName, `[${name}] ${tool.description ?? ''}`, { inputSchema: z.object({}).passthrough() }, async (toolArgs) => { const cacheKey = JSON.stringify({ name: tool.name, args: toolArgs }); const cached = cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < 60_000) { console.error(`[CACHE HIT] ${gatewayName}`); return cached.result; } console.error(`[CALL] ${gatewayName} at ${new Date().toISOString()}`); const start = Date.now(); const result = await client.callTool({ name: tool.name, arguments: toolArgs }); console.error(`[DONE] ${gatewayName} in ${Date.now() - start}ms`); cache.set(cacheKey, { result, timestamp: Date.now() }); return result; }); } }
This adds a 60-second cache for identical tool calls and logs every call with timing information. In a production gateway, you would add authentication checks, rate limiting, and structured logging.
Common Pitfalls
- Single point of failure: The gateway itself becomes a critical component. If it goes down, all tool access is lost. Plan for high availability.
- Stale tool registrations: If a backend server adds new tools after initial registration, the gateway will not expose them unless it re-scans. Implement periodic refresh or listen for server notifications.
- Cache invalidation for stateful tools: Caching results from tools that modify state (like database writes) leads to incorrect behavior. Only cache read-only tool results.
Best Practices
- Prefix all gateway tool names with the backend server name to avoid collisions and provide clear provenance.
- Add health checks for each backend server and remove unhealthy servers from the available tool set.
- Implement structured logging at the gateway level so you have a single source of truth for all MCP interactions across your organization.
Summary
- An MCP gateway is an MCP server that internally manages multiple MCP client connections to backend servers.
- It re-exposes backend tools under prefixed names, routing calls to the correct backend.
- Dynamic registration allows adding and removing servers at runtime.
- The gateway is the ideal place for cross-cutting concerns like authentication, logging, caching, and rate limiting.
- Plan for high availability since the gateway becomes a single point of failure.
Code Examples
class McpGateway {
private gateway: McpServer;
private backends = new Map<string, Client>();
private toolMap = new Map<string, { client: Client; originalName: string }>();
constructor() {
this.gateway = new McpServer(
{ name: 'mcp-gateway', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
);
}
async addBackend(name: string, command: string, args: string[]) {
const client = new Client({ name: 'gateway', version: '1.0.0' });
await client.connect(new StdioClientTransport({ command, args }));
this.backends.set(name, client);
const { tools } = await client.listTools();
for (const tool of tools) {
const gatewayName = `${name}__${tool.name}`;
this.toolMap.set(gatewayName, { client, originalName: tool.name });
this.gateway.tool(
gatewayName,
`[${name}] ${tool.description ?? ''}`,
{ inputSchema: z.object({}).passthrough() },
async (a) => client.callTool({ name: tool.name, arguments: a })
);
}
}
}