How to Build an MCP Router with FastMCP
Build a FastMCP router that mounts upstream MCP servers, adds local tools, routes modern HTTP safely, and handles version, auth, and failures.
Direct answer
Build the router as one client-facing FastMCP server. Create one create_proxy() bridge for each upstream MCP server, mount those bridges on the parent server, and define local tools on the parent when you need cross-backend behavior. The client connects only to the router.
from fastmcp import FastMCP
from fastmcp.server import create_proxy
router = FastMCP("company-router")
weather = create_proxy("http://localhost:8101/mcp")
calendar = create_proxy("http://localhost:8102/mcp")
router.mount(weather)
router.mount(calendar)
@router.tool
def router_health() -> str:
"""Return a local health message without calling an upstream."""
return "router process is running"
if __name__ == "__main__":
router.run()
This is the core MCP composition pattern documented by FastMCP: a proxy acts as an MCP client to another server and exposes that server through the parent. Pin a FastMCP release and run this example against your selected transport before publishing it; the documentation tracks the moving main branch and does not establish a tested lockfile. See the FastMCP proxy documentation.
What “router” means here
There are two layers:
- MCP composition router: the FastMCP process above. It mounts local components and upstream proxies behind one MCP endpoint.
- HTTP edge router: a reverse proxy or load balancer that chooses a FastMCP instance or backend for each HTTP request. It is optional and must understand the protocol revision it is fronting.
Keep these layers separate. A mounted proxy does not configure TLS termination, credentials, authorization policy, retries, or secret storage for you.
Prerequisites and version pinning
- Choose the Python version and FastMCP release you will support.
- Choose a frontend transport (stdio or Streamable HTTP) and record each backend transport.
- Pin FastMCP and its MCP SDK in your dependency file. The current docs do not provide a stable version tested by this article, so replace the placeholders with versions you have run.
# requirements.txt (replace placeholders with your approved versions)
fastmcp==<PINNED_FASTMCP_VERSION>
mcp==<PINNED_MCP_SDK_VERSION>
Install in a virtual environment, then start each backend independently. Test the backend directly before adding the router; otherwise an upstream failure can look like a router failure.
Build a router step by step
1. Start with one upstream
Use an upstream URL or another supported local target. Proxy creation is lazy: constructing the proxy and starting the router do not contact the upstream. The first client initialization of that proxy starts the upstream connection.
from fastmcp import FastMCP
from fastmcp.server import create_proxy
router = FastMCP("router")
backend = create_proxy("http://backend.example/mcp")
router.mount(backend)
if __name__ == "__main__":
router.run()
Start the process, then connect a real MCP client. If the URL is wrong, the endpoint is not MCP, or authentication fails, initialization will fail at client connection time rather than process startup.
2. Add local orchestration
Local tools are useful for status, policy checks, or workflows that call more than one backend. They live on the parent server beside mounted proxies.
from fastmcp import FastMCP
from fastmcp.server import create_proxy
router = FastMCP("ops-router")
weather = create_proxy("http://localhost:8101/mcp")
calendar = create_proxy("http://localhost:8102/mcp")
router.mount(weather)
router.mount(calendar)
@router.tool
def service_hint() -> str:
"""Explain which MCP endpoint the client should use."""
return "Use this router endpoint; weather and calendar are mounted upstreams."
if __name__ == "__main__":
router.run()
The local function is your code. FastMCP supplies the server container, proxying, and component exposure; it does not invent an orchestration policy.
3. Mount several configured backends
For a fixed fleet, keep backend definitions in configuration and create one proxy per entry. Give every backend an unambiguous service identity. FastMCP’s proxy guide also demonstrates a multi-server configuration that creates one proxy per configured backend; verify the precise configuration keys and how names appear to clients in your pinned release.
BACKENDS = {
"weather": "http://localhost:8101/mcp",
"calendar": "http://localhost:8102/mcp",
}
router = FastMCP("router")
for service_name, target in BACKENDS.items():
router.mount(create_proxy(target))
Document the resulting tool, resource, and prompt names for your clients. If two upstreams export the same name, resolve the collision explicitly in configuration or with a local facade; do not rely on an undocumented auto-prefix.
Transport choices and authentication
FastMCP proxies can bridge transports. For example, a stdio client can talk to your router while a mounted proxy reaches an HTTP backend. State both hops in your deployment diagram:
- Client → router: stdio or Streamable HTTP.
- Router → backend: the backend’s configured MCP transport.
Authenticate the client-facing endpoint, then decide which credential the router uses for each upstream. Do not forward every incoming header to every backend. Bind credentials to a backend, validate scopes at both boundaries, and test that a token for one service cannot be reused with another. See the FastMCP proxy guide for proxy patterns and the HTTP deployment guide for transport integration.
Protocol-era compatibility
The MCP 2026-07-28 Streamable HTTP revision removes the older initialize/initialized exchange and Mcp-Session-Id. Requests are self-describing, so any request can reach any server instance behind a conventional load balancer. Earlier clients and servers still use handshake-era session semantics, and FastMCP proxies mirror the frontend protocol era when opening their upstream connection.
Version-gate your deployment. A modern, stateless edge does not make every mixed-version client/backend pair stateless. If a tool needs continuity, pass an explicit state identifier in tool arguments instead of depending on hidden transport session state. Refer to the MCP 2026-07-28 specification release notes for the revision-specific changes.
Optional HTTP edge routing
For modern Streamable HTTP, FastMCP documents these routing hints:
| Header | Meaning | How to use it |
|---|---|---|
Mcp-Method |
JSON-RPC method such as tools/call |
Choose a route class, then validate the body. |
Mcp-Name |
Tool, prompt, or resource name/URI | Choose a backend only after checking the requested operation. |
Mcp-Param-* |
Selected opted-in tool arguments | Treat as hints; validate against the JSON body. |
FastMCP leaves these headers intact. Legacy clients may send none. Route headerless traffic to a deliberate default or inspect the body; do not reject every request merely because modern headers are absent.
# Pseudocode for an edge policy
if protocol_version == "2026-07-28":
method = request.headers.get("Mcp-Method")
name = request.headers.get("Mcp-Name")
candidate = choose_backend(method, name, request.headers)
else:
candidate = legacy_default
body = parse_jsonrpc(request.body)
validate_candidate_against_body(candidate, body)
proxy(request, candidate)
The body remains the source of truth. Reject mismatches, unknown services, and malformed JSON-RPC with a clear client error. Keep the edge policy and FastMCP endpoint on compatible protocol revisions. See the FastMCP HTTP transport guide.
Deployment with FastAPI or Starlette
Mounting FastMCP into a larger ASGI application is useful when the router shares health checks or other HTTP routes. Verify the endpoint path and lifecycle wiring against your pinned release. Streamable HTTP applications require their lifespan context to be passed to the enclosing Starlette application; omitting it can break startup and shutdown behavior.
# Shape only: confirm method names and paths for your pinned release.
from fastapi import FastAPI
from fastmcp import FastMCP
mcp = FastMCP("router")
app = FastAPI()
# Mount the Streamable HTTP app using the integration documented by your release.
# Ensure the MCP lifespan is passed to the ASGI parent.
Security checklist
- Authenticate the client-facing endpoint.
- Authorize each tool, resource, and prompt at the router and, where required, upstream.
- Use separate upstream credentials and scopes.
- Validate
Mcp-*routing hints against the request body. - Allow only configured backend targets; never route to a URL supplied by an untrusted tool argument.
- Redact authorization headers and tokens from logs.
- Exercise denial cases for every backend, not just the router default.
Reliability, performance, and cost
Proxying adds a network hop and another process that can fail. Measure your own workload; the source material provides no latency, throughput, or adoption benchmark. Set connection and request timeouts at the edge and upstream, bound concurrent work, and return actionable errors when a backend is unavailable.
Lazy initialization means a green process check is not proof that every backend works. Add an authenticated smoke test that initializes each mounted proxy. For modern stateless HTTP, ordinary round-robin balancing is appropriate; for older session-based traffic, preserve the session behavior required by your client and server versions.
There is no special MCP fee for mounting a proxy. Your costs come from compute, network transfer, observability, and any upstream services. Cache only responses that are safe to cache, and carry explicit state when workflows span requests.
Testing plan
- Run each backend alone and call one tool.
- Start the router and connect a client to the router endpoint.
- Initialize every proxy to expose bad URLs, non-MCP endpoints, and authentication failures.
- Test the intended transport bridge in both directions.
- Use a modern client and one handshake-era client if compatibility matters.
- Send modern headers that disagree with the body and confirm rejection.
- Send headerless requests and confirm the documented fallback.
- Run concurrent clients and verify state isolation for your pinned release.
- Test authorization independently for each backend.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Router starts, then initialization fails | Lazy proxy connection reached a bad URL, non-MCP service, or failed auth. | Call the backend directly, verify its MCP path and credentials, then reconnect through the router. |
| Tools are missing or collide | Upstreams export duplicate names or the release’s naming behavior differs from your assumption. | Inspect the pinned release’s naming rules and configure explicit service identities or a facade. |
| Modern request is rejected by the edge | Gateway expects headers from every client. | Implement a body-inspection or safe default fallback for legacy/headerless traffic. |
| Request reaches the wrong backend | Header hints disagree with JSON-RPC body. | Validate method/name/parameter hints against the body and reject mismatches. |
| Random failures behind a load balancer | Mixed protocol eras or hidden session dependence. | Version-gate clients and servers; use stateless explicit arguments for modern traffic and preserve required affinity for older traffic. |
| ASGI app hangs on shutdown | FastMCP lifespan was not passed to the parent app. | Wire the MCP lifespan according to the pinned HTTP deployment docs. |
| Secrets appear in logs | Proxy or edge logs include forwarded headers. | Redact authorization and cookie headers and restrict debug logging. |
Or skip the browser setup
If your router’s job includes taking website screenshots, ScreenshotNeo provides a single HTTP endpoint instead of maintaining browser workers. One request returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account and try the endpoint with your router.
FAQ
Does a proxy contact its upstream at startup?
No. Initialization is lazy and normally starts when an MCP client initializes the proxy.
Can I mix stdio and HTTP?
Yes, proxies are intended to bridge transports, but test both hops with the versions you pin.
Do modern MCP requests require sticky sessions?
The 2026-07-28 Streamable HTTP revision is stateless at the protocol layer. Older handshake-era traffic may still require its session semantics.
Should a gateway trust Mcp-Name?
Use it as a routing hint only. Parse and validate the JSON-RPC body before forwarding.
Where should authorization live?
At the client-facing router and, when required, independently at every upstream.


