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 math server and another own a text server, 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.py that mounts both under namespaces, so their tools appear as math_add, text_slugify, and so on, next to the gateway’s own tool.
  • A proxy.py that fronts an existing MCP server with create_proxy.
  • A pytest suite covering the composed tools and the proxy’s forwarding, plus make targets to run each.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • fastmcp provides FastMCP.mount for composition and create_proxy for 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 FastMCP instance (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_server does a one-time static copy instead.)
  • The namespace prevents collisions. Both sub-servers could define a tool named format; under namespaces they become math_format and text_format, which stay distinct. Use namespace, not the deprecated prefix argument.
  • The gateway keeps its own tools. health sits 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) (from fastmcp.server) returns a FastMCPProxy that mirrors the target’s tools, resources, and prompts and forwards calls to it. This is the current API; FastMCP.as_proxy() is deprecated.
  • build_proxy takes an optional target so tests can pass an in-memory FastMCP object while main proxies the BACKEND_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.py asserts the exact namespaced tool list (math_add, text_slugify, and the gateway’s health) and that mounted tools actually run. This proves the gateway composes the sub-servers without name collisions.
  • test_proxy.py builds a stand-in backend and confirms the proxy exposes and forwards to its tools. Passing a FastMCP object to create_proxy keeps 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/deploy run the composed gateway. proxy runs the standalone proxy against BACKEND_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.py lists 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 deprecated prefix= argument, switch to namespace=.
  • 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_URL is right and the backend is running (curl its /mcp/ endpoint).
  • FastMCP.as_proxy() is deprecated. Use create_proxy from fastmcp.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, and launchd run 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_server instead of mount when you want a static snapshot rather than a live link.