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 an httpx client you point at the live service. You wrap an API you do not own.
  • from_fastapi(app, ...) takes the FastAPI app object. FastMCP derives the spec from app.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: the from_fastapi call 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 pytest suite covering the mapping, the reads, a tool call, auth, and the no-key-leak guarantee.
  • A make wrapper with a help screen.

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.
  • 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

  • fastapi is the app you are wrapping. httpx is 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_id on every route is the one habit worth adopting before you wrap an app. FastMCP names each generated component after the operation id, so list_tasks becomes resource://list_tasks. Without an explicit id, FastAPI synthesizes a noisy one like list_tasks_tasks_get and your component names inherit it.
  • APIKeyHeader as a security scheme, not a Header parameter. This is the key detail for wrapping. If require_api_key took a plain x_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 under securitySchemes instead, and never becomes a tool input. Step 4 supplies the actual key on the client side.
  • The in-memory store with a monotonic _NEXT_ID keeps the example runnable with no database. create_task mutates 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 calls app.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.
  • RouteMap and MCPType come from fastmcp.server.providers.openapi — the same reshaping rules the OpenAPI generator uses. Each map matches by HTTP methods and a regex pattern against the route path, and assigns an mcp_type.
  • Order matters. Rules are tried top to bottom and the first match wins. The DELETE exclusion 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_server is 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_task is a tool, list_tasks a resource, get_task/{task_id} a template, and delete_task is 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 Task models.
  • create_task is 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 no new wrapper key even though the handler takes a NewTask body 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 = auto runs the async tests without a marker.
  • tests/__init__.py makes tests/ a package so the project root lands on sys.path and from server import mcp resolves.

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_store snapshots _TASKS and _NEXT_ID before each test and restores them after, so test_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_component pins 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_schema is the guard for the Step 3 gotcha: the tool exposes only title, never the API key.
  • test_tool_call_runs_the_handler confirms the generated tool executes the real FastAPI handler: it returns the created Task with the assigned id.
  • test_missing_api_key_fails builds a second server with the wrong key; the app answers 401, which surfaces as an McpError. 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 := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

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, use from_openapi with an httpx client instead.
  • Auth goes on the client. Headers set in httpx_client_kwargs apply 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 bare Header parameter, 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 EXCLUDE route 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_maps were 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 Header parameter. Switch it to a security scheme (APIKeyHeader via Security(...)) so it moves to securitySchemes and 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 explicit operation_id= to each route, or remap with mcp_names={...}.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py so pytest puts the project root on sys.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 serve and register it with Claude Desktop or Claude Code.
  • Add mcp_component_fn to 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.