If you already run a FastAPI service, you do not have to hand-write an MCP tool
for each endpoint or maintain a separate OpenAPI file. FastMCP.from_fastapi
takes the app object itself, reads the schema FastAPI already generates, and
mounts the app in-process: every operation becomes an MCP tool, resource, or
resource template, and each call runs the real route handler with no network hop.
The API stays the single source of truth for schemas, validation, and behavior.
This tutorial wraps a small Tasks API with FastMCP. You
will map operations to the right component type, exclude a destructive admin
route, attach an API key that rides along on every call, keep that key out of the
generated tool schemas, and test the result. Because the app runs in-process over
an ASGI transport, the whole thing is offline and deterministic with no mock to
maintain. The stack is Mac-native: uv, make, and pytest.
How from_fastapi differs from from_openapi
Both generators share the same machinery, but the input differs:
from_openapi(spec, client=...)takes an OpenAPI dict plus anhttpxclient you point at the live service. You wrap an API you do not own.from_fastapi(app, ...)takes the FastAPIappobject. FastMCP derives the spec fromapp.openapi()and builds an in-process ASGI client for you, so calls execute the actual handlers. You wrap an API you do own.
Everything downstream is identical: each operation defaults to a tool, and a
list of RouteMap rules (matched in order, first match wins) reshapes that into
resources, templates, or exclusions.
What you will build
api.py: an ordinary FastAPI app (Pydantic models, an in-memory store, CRUD routes) guarded by an API key. Nothing in it knows about MCP.server.py: thefrom_fastapicall with route maps that exclude a DELETE and turn GETs into resources, plus the API key wired onto the in-process client.client.py: an in-memory client that shows how each route was mapped and calls one of each.- A
pytestsuite covering the mapping, the reads, a tool call, auth, and the no-key-leak guarantee. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP and the in-memory
Client(see Build an MCP Server with FastMCP). The route-map mechanics mirror Generate an MCP Server from an OpenAPI Spec, so that article is a useful companion.
Step 1: Add project hygiene
Create the file
mkdir -p mcp-from-fastapi-macos
cd mcp-from-fastapi-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches never get committed.
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 fastapi httpx
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "Expose an existing FastAPI app as an MCP server with FastMCP"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.139.2",
"fastmcp>=3.4.4",
"httpx>=0.28.1",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastapiis the app you are wrapping.httpxis listed explicitly because FastMCP mounts the app over an httpx ASGI transport; FastMCP already depends on it, but naming it makes the intent clear.
Step 3: The FastAPI app you already own
This is a plain FastAPI service. The point of the tutorial is that you write nothing MCP-specific here: the same app that serves HTTP is the app you wrap.
Create the file
touch api.py
Add the code: api.py
"""The FastAPI app you already own.
This is an ordinary FastAPI service: Pydantic models, an in-memory store, and
CRUD routes guarded by an API key. Nothing here knows about MCP. `server.py`
wraps this exact app with `FastMCP.from_fastapi`, so the API stays the single
source of truth for schemas, validation, and behavior.
"""
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
from pydantic import BaseModel, Field
API_KEY = "secret-key"
# A security scheme, not a plain Header parameter: it lands under the OpenAPI
# `securitySchemes` block instead of every operation's parameter list, so the
# key never leaks into a generated tool's input schema.
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_api_key(key: str = Security(api_key_header)) -> None:
"""Reject any request that does not carry the shared API key."""
if key != API_KEY:
raise HTTPException(status_code=401, detail="unauthorized")
class Task(BaseModel):
id: int
title: str
done: bool = False
class NewTask(BaseModel):
title: str = Field(min_length=1, description="What needs doing")
app = FastAPI(title="Tasks API", version="1.0.0", dependencies=[Depends(require_api_key)])
_TASKS: dict[int, Task] = {
1: Task(id=1, title="Write the article", done=True),
2: Task(id=2, title="Review the project", done=False),
}
_NEXT_ID = 3
@app.get("/tasks", operation_id="list_tasks", summary="List all tasks")
def list_tasks() -> list[Task]:
return list(_TASKS.values())
@app.get("/tasks/{task_id}", operation_id="get_task", summary="Get one task by id")
def get_task(task_id: int) -> Task:
if task_id not in _TASKS:
raise HTTPException(status_code=404, detail="task not found")
return _TASKS[task_id]
@app.post("/tasks", operation_id="create_task", summary="Create a task", status_code=201)
def create_task(new: NewTask) -> Task:
global _NEXT_ID
task = Task(id=_NEXT_ID, title=new.title)
_TASKS[task.id] = task
_NEXT_ID += 1
return task
@app.delete("/tasks/{task_id}", operation_id="delete_task", summary="Delete a task (admin)")
def delete_task(task_id: int) -> dict[str, bool]:
_TASKS.pop(task_id, None)
return {"deleted": True}
Detailed breakdown
operation_idon every route is the one habit worth adopting before you wrap an app. FastMCP names each generated component after the operation id, solist_tasksbecomesresource://list_tasks. Without an explicit id, FastAPI synthesizes a noisy one likelist_tasks_tasks_getand your component names inherit it.APIKeyHeaderas a security scheme, not aHeaderparameter. This is the key detail for wrapping. Ifrequire_api_keytook a plainx_api_key: str = Header(...), FastAPI would list that header as an operation parameter, and FastMCP would turn it into a tool argument — exposing the secret to the model and asking it to supply the key. As a security scheme it lands undersecuritySchemesinstead, and never becomes a tool input. Step 4 supplies the actual key on the client side.- The in-memory store with a monotonic
_NEXT_IDkeeps the example runnable with no database.create_taskmutates it, which the test suite accounts for with a reset fixture.
Step 4: Wrap the app with route maps
Create the file
touch server.py
Add the code: server.py
"""Turn the FastAPI app into an MCP server.
`FastMCP.from_fastapi` reads the app's OpenAPI schema (FastAPI generates it for
you) and mounts the app in-process over an ASGI transport, so every MCP call runs
the real route handler without a network hop. By default each operation becomes a
**tool**; `route_maps` reshape that:
- destructive admin routes (DELETE) are **excluded** entirely;
- a GET with a path parameter becomes a **resource template**;
- a GET without one becomes a plain **resource**;
- everything else (POST here) stays a **tool**.
`httpx_client_kwargs` sets headers on the in-process client, so the API key rides
along on every generated component just as it would against a deployed API.
Run over stdio with `python server.py`, or drive it with client.py.
"""
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import MCPType, RouteMap
from api import API_KEY, app
# Route maps are evaluated in order; the first match wins.
ROUTE_MAPS = [
# Never expose destructive admin operations as MCP components.
RouteMap(methods=["DELETE"], pattern=r".*", mcp_type=MCPType.EXCLUDE),
# GET with a path parameter -> resource template (e.g. get_task/{id}).
RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE),
# Other GETs -> a plain resource.
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
# POST/PUT/PATCH fall through to the default: a tool.
]
def build_server(api_key: str = API_KEY) -> FastMCP:
"""Wrap the FastAPI app, attaching the API key to the in-process client."""
return FastMCP.from_fastapi(
app,
name="tasks",
route_maps=ROUTE_MAPS,
httpx_client_kwargs={"headers": {"X-API-Key": api_key}},
)
mcp = build_server()
if __name__ == "__main__":
mcp.run()
Detailed breakdown
from_fastapi(app, ...)is the whole integration. FastMCP callsapp.openapi()internally to get the spec and builds an httpx client backed by an ASGI transport pointed at the app, so no server has to be listening on a port.RouteMapandMCPTypecome fromfastmcp.server.providers.openapi— the same reshaping rules the OpenAPI generator uses. Each map matches by HTTPmethodsand a regexpatternagainst the route path, and assigns anmcp_type.- Order matters. Rules are tried top to bottom and the first match wins. The
DELETEexclusion is first so a destructive route can never fall through to a later rule and get exposed. The{...}template rule precedes the catch-all GET rule so a parameterized GET is not swallowed as a plain resource. httpx_client_kwargs={"headers": ...}is how auth is attached. Whatever headers you set here go on every request the in-process client makes, so the API key authorizes each generated component.build_serveris a factory so a test can pass a wrong key.
Step 5: See how each route was mapped
Create the file
touch client.py
Add the code: client.py
"""Drive the wrapped Tasks API in-memory.
Shows how the four FastAPI routes landed: which became tools, resources, and
templates (and which was excluded), then calls one of each. Every call runs the
real FastAPI handler in-process, with the API key attached to the client.
"""
import asyncio
from fastmcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print("tools: ", [t.name for t in await client.list_tools()])
print("resources: ", [str(r.uri) for r in await client.list_resources()])
print("templates: ", [t.uriTemplate for t in await client.list_resource_templates()])
print("\nread resource://list_tasks:")
print(" ", (await client.read_resource("resource://list_tasks"))[0].text)
print("read resource://get_task/2:")
print(" ", (await client.read_resource("resource://get_task/2"))[0].text)
print("\ncall create_task(title='Ship it'):")
r = await client.call_tool("create_task", {"title": "Ship it"})
print(" ", r.structured_content)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The three list calls show the split:
create_taskis a tool,list_tasksa resource,get_task/{task_id}a template, anddelete_taskis absent because it was excluded. - The reads run the real GET handlers in-process; the returned text is the JSON
body FastAPI serialized from the
Taskmodels. create_taskis a POST tool. FastMCP flattens the request body into the tool’s input schema, so you call it with{"title": "Ship it"}directly — there is nonewwrapper key even though the handler takes aNewTaskbody model.
Run it:
uv run python client.py
Expected output:
tools: ['create_task']
resources: ['resource://list_tasks']
templates: ['resource://get_task/{task_id}']
read resource://list_tasks:
[{"id": 1, "title": "Write the article", "done": true}, {"id": 2, "title": "Review the project", "done": false}]
read resource://get_task/2:
{"id": 2, "title": "Review the project", "done": false}
call create_task(title='Ship it'):
{'id': 3, 'title': 'Ship it', 'done': False}
Step 6: Test the mapping, the calls, auth, and the schema
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autoruns the async tests without a marker.tests/__init__.pymakestests/a package so the project root lands onsys.pathandfrom server import mcpresolves.
Add the code: tests/test_server.py
"""Tests for the FastAPI-derived Tasks MCP server.
Covers how the four routes were mapped (tool vs resource vs template vs
excluded), that reads and a tool call run the real FastAPI handler, that the API
key the client carries is what authorizes those calls, and that the security
scheme does not leak into a tool's input schema.
"""
import pytest
from fastmcp import Client
from mcp.shared.exceptions import McpError
import api
from server import build_server, mcp
@pytest.fixture(autouse=True)
def reset_store():
"""Snapshot and restore the in-memory store so create/delete tests do not
bleed into each other."""
tasks = {k: v.model_copy() for k, v in api._TASKS.items()}
next_id = api._NEXT_ID
yield
api._TASKS = tasks
api._NEXT_ID = next_id
async def test_operations_map_to_the_right_component():
async with Client(mcp) as client:
tools = {t.name for t in await client.list_tools()}
resources = {str(r.uri) for r in await client.list_resources()}
templates = {t.uriTemplate for t in await client.list_resource_templates()}
assert tools == {"create_task"} # POST -> tool
assert "resource://list_tasks" in resources # GET -> resource
assert "resource://get_task/{task_id}" in templates # GET+param -> template
async def test_delete_route_is_excluded():
async with Client(mcp) as client:
names = {t.name for t in await client.list_tools()}
names |= {str(r.uri) for r in await client.list_resources()}
names |= {t.uriTemplate for t in await client.list_resource_templates()}
assert not any("delete_task" in n for n in names)
async def test_api_key_does_not_leak_into_tool_schema():
"""The APIKeyHeader security scheme must not appear as a tool argument."""
async with Client(mcp) as client:
(tool,) = await client.list_tools()
assert set(tool.inputSchema["properties"]) == {"title"}
async def test_read_list_resource():
async with Client(mcp) as client:
text = (await client.read_resource("resource://list_tasks"))[0].text
assert '"title": "Write the article"' in text
assert '"title": "Review the project"' in text
async def test_read_template_resource():
async with Client(mcp) as client:
text = (await client.read_resource("resource://get_task/2"))[0].text
assert text == '{"id": 2, "title": "Review the project", "done": false}'
async def test_tool_call_runs_the_handler():
async with Client(mcp) as client:
r = await client.call_tool("create_task", {"title": "Ship it"})
assert r.structured_content == {"id": 3, "title": "Ship it", "done": False}
async def test_missing_api_key_fails():
"""A client built without the right key gets a 401 from the FastAPI app,
surfaced as an MCP error — proving the header is what authorizes the call."""
bad = build_server("wrong-key")
async with Client(bad) as client:
with pytest.raises(McpError):
await client.read_resource("resource://list_tasks")
Detailed breakdown
reset_storesnapshots_TASKSand_NEXT_IDbefore each test and restores them after, sotest_tool_call_runs_the_handler(which creates task id 3) does not perturb the reads in another test. Without it the mutation would leak across tests in the shared process.test_operations_map_to_the_right_componentpins the whole mapping in one place: POST is a tool, the two GETs split into a resource and a template.test_api_key_does_not_leak_into_tool_schemais the guard for the Step 3 gotcha: the tool exposes onlytitle, never the API key.test_tool_call_runs_the_handlerconfirms the generated tool executes the real FastAPI handler: it returns the createdTaskwith the assigned id.test_missing_api_key_failsbuilds a second server with the wrong key; the app answers401, which surfaces as anMcpError. The auth lives on the client, so a bad client fails every call.
Step 7: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test 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
demo: ## Wrap the FastAPI app and exercise each component
uv run python client.py
serve: ## Run the derived server over stdio (for a real MCP client)
uv run python server.py
test: ## Run the test suite
uv run pytest -q
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
Step 8: Run everything
make # prints the help screen
make demo # wraps the app and calls each component (output in Step 5)
make test # runs the suite
The suite passes:
....... [100%]
7 passed in 0.41s
Notes on wrapping a real FastAPI app
- Wrap the app object, not a URL.
from_fastapi(app)mounts the app in-process, so you do not deploy anything or open a port to expose it as MCP. If the API is already running elsewhere and you only have its URL, usefrom_openapiwith anhttpxclient instead. - Auth goes on the client. Headers set in
httpx_client_kwargsapply to every generated component. There is no per-tool auth to wire up. For a bearer token, set{"Authorization": f"Bearer {token}"}the same way. - Keep secrets out of the schema. Model auth as a security scheme
(
APIKeyHeader,HTTPBearer, OAuth2), never as a bareHeaderparameter, or the credential becomes a tool argument the model is asked to fill in. - Exclude before you expose. A wrapped app mirrors every route. Use an
EXCLUDEroute map for destructive, admin, or internal routes so they never reach a model, and prefer an allow-list mindset for anything sensitive. - Big apps make big servers. A 200-route app becomes 200 components, which is a lot of tokens and choices for a model. Map the handful you need to tools and either exclude the rest or leave them as resources.
Troubleshooting
- Everything is a tool. No
route_mapswere passed, so the default applied. Add maps to turn GETs into resources and to exclude routes. - A GET with a parameter became a plain resource. The template rule
(
r".*\{.*\}.*") must come before the catch-all GET rule; order decides the match. - The API key shows up as a tool argument. The auth is a plain
Headerparameter. Switch it to a security scheme (APIKeyHeaderviaSecurity(...)) so it moves tosecuritySchemesand out of the parameter list. - Calls return 401/403. The client has no auth or the wrong credentials. Set
the token or key in
httpx_client_kwargs["headers"], not on individual operations. - Components have unreadable names like
list_tasks_tasks_get. Add an explicitoperation_id=to each route, or remap withmcp_names={...}. ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.
Recap
Four FastAPI routes became an MCP server with no per-endpoint code and no separate
spec file: a POST turned into a tool, two GETs split into a resource and a
template, and a DELETE was excluded. from_fastapi mounted the app in-process so
every call ran the real handler, route maps decided the shape, and an API key set
on the in-process client authorized each component. Modeling that key as a security
scheme kept it out of the generated tool inputs.
Next improvements:
- Point an MCP client at the server over stdio with
make serveand register it with Claude Desktop or Claude Code. - Add
mcp_component_fnto customize tags or descriptions on the generated components. - Put the whole thing behind the auth and rate-limiting patterns from the other articles before exposing a production app.