As an MCP deployment grows, one giant server file stops scaling. FastMCP offers two ways to build bigger servers from smaller ones:
- Composition mounts independently-developed sub-servers into one gateway,
so a team can own a
mathserver and another own atextserver, and a client sees them as one. - Proxying puts a FastMCP server in front of an existing MCP server (even a remote one), forwarding every request to it. That lets you add TLS, auth, or rate limiting in front of a server you do not control, or expose a remote server under your own address.
This article builds both: a gateway composed from two sub-servers, and a proxy
that fronts an existing HTTP server. The stack is Mac-native: uv and make.
What you will build
- Two sub-servers (
math,text), each a normal FastMCP server. - A gateway
server.pythat mounts both under namespaces, so their tools appear asmath_add,text_slugify, and so on, next to the gateway’s own tool. - A
proxy.pythat fronts an existing MCP server withcreate_proxy. - A
pytestsuite covering the composed tools and the proxy’s forwarding, plusmaketargets to run each.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Comfort with
async/await.
Everything runs through uv and make.
Step 1: Scaffold and lock down hygiene
Create the files
mkdir -p compose-proxy-fastmcp-server-macos
cd compose-proxy-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
.DS_Store
Detailed breakdown
- Standard
uv/macOS hygiene; this project has no secrets or generated data.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "Composing and proxying FastMCP servers"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpprovidesFastMCP.mountfor composition andcreate_proxyfor proxying.
Step 3: The sub-servers
Each sub-server is an ordinary FastMCP server. It can run on its own or be mounted into a gateway. Keeping them in separate files is the point: independent modules that compose.
Create the files
touch math_server.py text_server.py
Add the code: math_server.py
"""A small, self-contained FastMCP sub-server: arithmetic tools.
This is an ordinary FastMCP server. It can run on its own, or be mounted into a
larger gateway server (see server.py), where its tools appear under a namespace.
"""
from fastmcp import FastMCP
math_mcp = FastMCP("math")
@math_mcp.tool
def add(a: float, b: float) -> float:
"""Add two numbers."""
return a + b
@math_mcp.tool
def multiply(a: float, b: float) -> float:
"""Multiply two numbers."""
return a * b
Add the code: text_server.py
"""A small, self-contained FastMCP sub-server: text tools."""
from fastmcp import FastMCP
text_mcp = FastMCP("text")
@text_mcp.tool
def word_count(text: str) -> int:
"""Count the whitespace-separated words in a block of text."""
return len(text.split())
@text_mcp.tool
def slugify(text: str) -> str:
"""Turn a title into a URL-safe, lowercase slug."""
cleaned = [c.lower() if c.isalnum() else "-" for c in text.strip()]
slug = "".join(cleaned)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-")
Detailed breakdown
- Each module exposes a
FastMCPinstance (math_mcp,text_mcp) with its own tools. Nothing here knows about the gateway; the servers are independent and independently testable. - The tool names (
add,slugify) are plain; the namespace is added when they are mounted, so a sub-server does not have to worry about collisions with other sub-servers.
Step 4: Compose the gateway
The gateway mounts the sub-servers under namespaces. A mounted sub-server’s tools
appear on the gateway prefixed with its namespace, so add on the math server
becomes math_add. The gateway can also define its own tools.
Create the file
touch server.py
Add the code: server.py
"""A gateway FastMCP server composed from mounted sub-servers.
Mounting lets you build one server out of independently-developed pieces. Each
sub-server keeps its own tools; when mounted under a namespace, its tools appear
on the gateway prefixed with that namespace (for example, `math_add`). The
gateway can also have tools of its own.
"""
import os
from fastmcp import FastMCP
from math_server import math_mcp
from text_server import text_mcp
mcp = FastMCP("macmcp")
@mcp.tool
def health() -> dict:
"""The gateway's own tool, alongside the mounted ones."""
return {"status": "ok", "server": "macmcp gateway"}
# Mount the sub-servers under namespaces. Their tools become `math_add`,
# `math_multiply`, `text_word_count`, `text_slugify` on this gateway.
mcp.mount(math_mcp, namespace="math")
mcp.mount(text_mcp, namespace="text")
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio
if __name__ == "__main__":
main()
Detailed breakdown
mcp.mount(math_mcp, namespace="math")links the math sub-server into the gateway. Mounting is a live link: changes to the sub-server show up on the gateway. (import_serverdoes a one-time static copy instead.)- The namespace prevents collisions. Both sub-servers could define a tool named
format; under namespaces they becomemath_formatandtext_format, which stay distinct. Usenamespace, not the deprecatedprefixargument. - The gateway keeps its own tools.
healthsits alongside the mounted ones, so a gateway can add cross-cutting tools (status, routing helpers) on top of what it composes.
Step 5: The proxy
A proxy is a FastMCP server that forwards every request to another MCP server.
create_proxy(target) accepts a URL (to front a remote HTTP server), a FastMCP
object (handy for tests), a config, or a Client.
Create the file
touch proxy.py
Add the code: proxy.py
"""A FastMCP proxy in front of an existing MCP server.
`create_proxy(target)` builds a server that forwards every request to another MCP
server. The target can be a URL (proxy a remote HTTP server), a `FastMCP` object
(useful in tests), a config, or a `Client`. Fronting a server this way lets you
add TLS (see the Caddy article), auth, or rate limiting in front of a server you
do not control.
"""
import os
from fastmcp.server import create_proxy
BACKEND_URL = os.environ.get("BACKEND_URL", "http://127.0.0.1:8001/mcp/")
def build_proxy(target=None):
"""Return a proxy server forwarding to `target` (default: BACKEND_URL)."""
return create_proxy(target or BACKEND_URL)
def main() -> None:
proxy = build_proxy()
proxy.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
if __name__ == "__main__":
main()
Detailed breakdown
create_proxy(target)(fromfastmcp.server) returns aFastMCPProxythat mirrors the target’s tools, resources, and prompts and forwards calls to it. This is the current API;FastMCP.as_proxy()is deprecated.build_proxytakes an optional target so tests can pass an in-memoryFastMCPobject whilemainproxies theBACKEND_URL. That one seam makes the forwarding testable without a running backend.- Why proxy at all? The proxy runs on your address and terminates the client connection, so you can wrap a server you do not own with the pieces from earlier articles: put Caddy in front for TLS, add an auth verifier, or add the rate-limit middleware, all without touching the backend.
Step 6: Test composition and proxying
Two test files, both hermetic. Composition is tested through an in-memory client
on the gateway; proxying is tested by pointing create_proxy at an in-memory
backend, so no HTTP server is needed.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_compose.py tests/test_proxy.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_compose.py
"""Test the composed gateway: mounted sub-server tools are namespaced."""
import pytest
from fastmcp import Client
from server import mcp
async def test_all_tools_are_exposed_with_namespaces():
async with Client(mcp) as client:
names = sorted(t.name for t in await client.list_tools())
assert names == [
"health",
"math_add",
"math_multiply",
"text_slugify",
"text_word_count",
]
async def test_mounted_math_tool_works():
async with Client(mcp) as client:
result = await client.call_tool("math_add", {"a": 2, "b": 3})
assert result.data == 5
async def test_mounted_text_tool_works():
async with Client(mcp) as client:
result = await client.call_tool("text_slugify", {"text": "Compose Me!"})
assert result.data == "compose-me"
async def test_gateway_own_tool_works():
async with Client(mcp) as client:
result = await client.call_tool("health", {})
assert result.data["status"] == "ok"
Add the code: tests/test_proxy.py
"""Test the proxy: it forwards requests to the target server.
`create_proxy` accepts a FastMCP object, so the whole forwarding path is tested
in-memory without a running HTTP backend.
"""
import pytest
from fastmcp import Client, FastMCP
from proxy import build_proxy
# A stand-in "existing" server the proxy will front.
backend = FastMCP("backend")
@backend.tool
def reverse(s: str) -> str:
return s[::-1]
async def test_proxy_exposes_backend_tools():
proxy = build_proxy(backend)
async with Client(proxy) as client:
names = [t.name for t in await client.list_tools()]
assert "reverse" in names
async def test_proxy_forwards_calls():
proxy = build_proxy(backend)
async with Client(proxy) as client:
result = await client.call_tool("reverse", {"s": "macmcp"})
assert result.data == "pcmcam"
Detailed breakdown
test_compose.pyasserts the exact namespaced tool list (math_add,text_slugify, and the gateway’shealth) and that mounted tools actually run. This proves the gateway composes the sub-servers without name collisions.test_proxy.pybuilds a stand-in backend and confirms the proxy exposes and forwards to its tools. Passing aFastMCPobject tocreate_proxykeeps the test in-memory while exercising the real forwarding path.
Run them:
uv run pytest -v
Step 7: The Makefile
Wrap the gateway, the proxy, tests, and the launchd deployment. Plain make
prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent for the gateway) ----------------
LABEL := com.macmcp.gateway
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
# `make proxy` forwards to this backend
BACKEND_URL ?= http://127.0.0.1:8001/mcp/
.PHONY: help install serve proxy test demo deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
serve: ## Run the composed gateway over HTTP (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
proxy: ## Run a proxy forwarding to BACKEND_URL (set BACKEND_URL=...)
BACKEND_URL=$(BACKEND_URL) uv run python proxy.py
test: ## Run the test suite
uv run pytest -v
demo: ## List and call the gateway's composed tools against a running server
uv run python scripts/demo.py
deploy: ## Run the gateway as a launchd service
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Deployed $(LABEL). Check: make status"
undeploy: ## Stop the gateway service and remove its plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the gateway service is running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the gateway log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
serve/deployrun the composed gateway.proxyruns the standalone proxy againstBACKEND_URL. The launchd lifecycle matches the companion deploy article.
Step 8: Run it
Add the launchd plist and the demo client, then exercise both patterns.
Create the files
mkdir -p scripts deploy
touch scripts/demo.py deploy/com.macmcp.gateway.plist.template
Add the code: scripts/demo.py
"""List and call the composed gateway's tools over HTTP."""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
async def main() -> None:
async with Client(URL) as client:
names = sorted(t.name for t in await client.list_tools())
print("tools:", names)
print("math_add(2, 3) ->", (await client.call_tool("math_add", {"a": 2, "b": 3})).data)
print("text_slugify(...) ->", (await client.call_tool("text_slugify", {"text": "Compose and Proxy"})).data)
print("health() ->", (await client.call_tool("health", {})).data)
if __name__ == "__main__":
asyncio.run(main())
Add the code: deploy/com.macmcp.gateway.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.gateway</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Detailed breakdown
demo.pylists the gateway’s tools (showing the namespaced names) and calls one from each mounted sub-server plus the gateway’s own, confirming composition works over HTTP.
Run the composed gateway:
make deploy
make status # state = running, a live pid
make demo
# tools: ['health', 'math_add', 'math_multiply', 'text_slugify', 'text_word_count']
# math_add(2, 3) -> 5.0
# text_slugify(...) -> compose-and-proxy
# health() -> {'status': 'ok', 'server': 'macmcp gateway'}
make undeploy
Run the proxy in front of an existing server (two terminals):
# Terminal 1 — an existing MCP server on port 8001
MCP_TRANSPORT=http MCP_PORT=8001 uv run python -c \
"from text_server import text_mcp; text_mcp.run(transport='http', host='127.0.0.1', port=8001)"
# Terminal 2 — a proxy on port 8000 forwarding to it
make proxy BACKEND_URL=http://127.0.0.1:8001/mcp/
A client that connects to the proxy (http://127.0.0.1:8000/mcp/) sees and calls
the backend’s tools, forwarded transparently.
Troubleshooting
- A mounted tool has the wrong name. Tools are named
namespace_tool. If you used the deprecatedprefix=argument, switch tonamespace=. - Two sub-servers clash. They should not once mounted under different namespaces. A clash means both were mounted under the same namespace, or one was imported without a namespace.
- The proxy shows no tools. The backend is unreachable. Confirm the
BACKEND_URLis right and the backend is running (curlits/mcp/endpoint). FastMCP.as_proxy() is deprecated. Usecreate_proxyfromfastmcp.server, as shown here.
Recap
You built bigger servers from smaller ones:
- Composition mounts sub-servers into a gateway under namespaces, so independent modules combine into one server without name collisions.
- Proxying fronts an existing MCP server with
create_proxy, forwarding every request and giving you a place to add TLS, auth, or rate limiting. - The tests are hermetic (in-memory client for composition, in-memory backend
for the proxy), and
uv,make, andlaunchdrun and deploy it.
Next improvements
- Put the Caddy TLS proxy in front of the gateway, or the auth/rate-limit pieces from the earlier articles, so the composed server ships production-ready.
- Proxy a remote third-party MCP server and add your own auth in front of it.
- Mount servers dynamically from a config file so the gateway’s contents change without a code edit.
- Use
import_serverinstead ofmountwhen you want a static snapshot rather than a live link.