Introduction
An MCP server does not have to be a terminal endpoint. It can also act as an MCP client, connecting to other servers to compose capabilities. This pattern enables middleware servers that add cross-cutting concerns like authentication, logging, and caching, as well as aggregator servers that present a unified tool surface from multiple backends. Composability is what transforms MCP from a point-to-point protocol into a full architecture.
Key Concepts
- Server-as-Client: An MCP server can instantiate an MCP
Clientto connect to downstream MCP servers. This creates a chain: Host connects to your server, and your server connects to other servers. Your server acts as both server (to the host) and client (to downstream servers). - Middleware Servers: Servers that intercept calls, apply cross-cutting logic, and forward to downstream servers. Common middleware patterns include authentication (validate tokens before forwarding), logging (record all tool invocations), caching (return cached results for identical inputs), and rate limiting.
- Dynamic Tool Registration: Servers can register and unregister tools at runtime, then notify clients via
notifications/tools/list_changed. This enables plugin architectures where new capabilities are added without restarting the server. - Tool Versioning: As tools evolve, use semver-inspired naming (e.g.,
query_v2) and deprecation patterns to maintain backward compatibility while introducing new behavior.
Real World Context
A fintech company runs three MCP servers: one for market data, one for trade execution, and one for compliance checks. Instead of connecting the host to all three directly, they build a gateway MCP server that connects to all three as a client. This gateway adds authentication (validates API keys), audit logging (records every trade tool call), and policy enforcement (blocks trades that fail compliance checks) — all transparently. The host sees a single server with a curated set of tools.
Deep Dive
Building a Server That Is Also a Client
An MCP server can create Client instances to connect to downstream servers. Here is a server that wraps another server and adds logging:
typescriptimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { z } from "zod"; const gateway = new McpServer({ name: "gateway", version: "1.0.0" }); // Connect to a downstream MCP server as a client const downstreamTransport = new StdioClientTransport({ command: "node", args: ["./downstream-server.js"] }); const downstreamClient = new Client( { name: "gateway-client", version: "1.0.0" } ); await downstreamClient.connect(downstreamTransport); // Discover downstream tools const { tools } = await downstreamClient.listTools(); // Re-expose downstream tools with added logging for (const tool of tools) { gateway.tool( tool.name, tool.description || "", tool.inputSchema ?? {}, async (args) => { console.error(`[gateway] Forwarding call to ${tool.name}`); const start = performance.now(); const result = await downstreamClient.callTool({ name: tool.name, arguments: args }); const duration = performance.now() - start; console.error(`[gateway] ${tool.name} completed in ${Math.round(duration)}ms`); return result; } ); } const transport = new StdioServerTransport(); await gateway.connect(transport);
The gateway discovers all tools from the downstream server, re-registers them on itself with added logging, and forwards every call. The host sees the gateway as a regular MCP server — the composition is transparent.
Middleware Patterns
Common middleware patterns you can implement with composable servers:
textPattern What It Does ──────────────── ───────────────────────────────────────── Auth middleware Validates bearer tokens before forwarding Cache middleware Returns cached results for identical inputs Rate limiter Throttles calls per tool per time window Audit logger Records every invocation with timestamp Circuit breaker Opens circuit after N consecutive failures
Each middleware server wraps a downstream server and adds a single concern. You can stack them: Host -> Auth -> RateLimiter -> AuditLog -> Actual Server.
Dynamic Tool Registration
Servers can register new tools at runtime and notify connected clients:
typescript// Register a new tool dynamically server.tool( "new_analysis_tool", "Run advanced analysis", { dataset: z.string() }, async ({ dataset }) => { // ... implementation return { content: [{ type: "text", text: "Analysis complete" }] }; } ); // Notify the client that the tool list has changed server.server.sendNotification({ method: "notifications/tools/list_changed" });
The client will re-fetch the tool list and see the newly registered tool. This enables plugin architectures where administrators can add capabilities to a running server.
Tool Versioning Strategies
As tools evolve, maintain backward compatibility:
textStrategy Example ──────────────────── ───────────────────────────────────── Suffix versioning query_v1, query_v2 Deprecation flag Mark old tools as deprecated in description Input schema growth Add optional fields, never remove required ones Parallel deployment Run v1 and v2 side by side during migration
Never break existing tool signatures that clients depend on. Add new versions alongside old ones and deprecate gracefully.
Common Pitfalls
- Circular dependencies — Server A connects to Server B, which connects to Server A. This creates an infinite loop during initialization. Design your server topology as a directed acyclic graph (DAG).
- Losing error context in forwarding — When a middleware server catches an error from a downstream server and re-throws it, the original error context (stack trace, error code) can be lost. Preserve error details when forwarding.
- Capability mismatch — A gateway that advertises tool capabilities but does not forward resource or prompt capabilities from downstream servers creates a confusing experience. Explicitly decide which capabilities to expose.
Best Practices
- Keep middleware servers stateless — Middleware servers should not maintain business state. They add cross-cutting concerns (auth, logging, caching) and forward to stateful downstream servers. This makes them easy to scale and replace.
- Document the server topology — When you have multiple layers of MCP servers, maintain a diagram showing the flow from host to final server. Future developers need to understand the composition chain.
- Test composition end-to-end — Unit-test each server individually, but also run integration tests through the full composition chain to catch issues that only manifest when servers interact.
Summary
- An MCP server can instantiate an MCP Client to connect to downstream servers, creating composable server chains.
- Middleware servers add cross-cutting concerns (auth, caching, rate limiting, audit logging) transparently.
- Dynamic tool registration with
notifications/tools/list_changedenables plugin architectures. - Version tools with suffixes or parallel deployment to maintain backward compatibility.
- Design server topologies as directed acyclic graphs to avoid circular dependencies.
Code Examples
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// An MCP server acting as a client to another server
const transport = new StdioClientTransport({
command: "node",
args: ["./downstream-server.js"]
});
const client = new Client({ name: "gateway", version: "1.0.0" });
await client.connect(transport);
// Discover and forward tools
const { tools } = await client.listTools();
const result = await client.callTool({
name: tools[0].name,
arguments: { query: "SELECT 1" }
});