If a service already publishes an OpenAPI spec, you do not have to hand-write a
tool for each endpoint. FastMCP.from_openapi reads the spec and generates the
whole server: every operation becomes an MCP tool, resource, or resource template,
and each call is proxied to the real API through an HTTP client you supply. Point
it at a spec, attach your auth, decide which routes to expose, and you have an MCP
server.
This tutorial generates a server from a small Catalog 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, and test the result. To keep it offline and deterministic, the backing
API is an httpx MockTransport; swapping in a real base URL is a one-line change.
The stack is Mac-native: uv, make, and pytest.
How generation works
from_openapi(spec, client=...) walks the spec’s operations. By default each one
becomes a tool. A list of RouteMap rules reshapes that, matched in order,
first match wins:
MCPType.TOOL— callable action (the default).MCPType.RESOURCE— a readable endpoint with no path parameters.MCPType.RESOURCE_TEMPLATE— a readable endpoint with a{path}parameter.MCPType.EXCLUDE— drop the operation; it becomes no MCP component at all.
The client you pass is a plain httpx.AsyncClient. Its base URL and headers
apply to every generated component, so authentication is configured once, on the
client, not per operation.
What you will build
catalog.py: the OpenAPI spec, plus an httpx client whoseMockTransportstands in for the real API (and enforces an API key).server.py: thefrom_openapicall with route maps that exclude a DELETE and turn GETs into resources.client.py: an in-memory client that shows how each operation was mapped and calls one of each.- A
pytestsuite covering the mapping, the reads, a tool call, and auth. - 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).
Step 1: Add project hygiene
Create the file
mkdir -p mcp-from-openapi-macos
cd mcp-from-openapi-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 httpx
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "Generate an MCP server from an OpenAPI spec with FastMCP"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
"httpx>=0.28",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
httpxis listed explicitly because the code constructs the client and itsMockTransportdirectly. FastMCP already depends on it, but naming it makes the intent clear.
Step 3: The spec and the backing API
In production the spec comes from the service (httpx.get(".../openapi.json"))
and the client points at its real base URL. To keep the tutorial reproducible, the
spec is written out by hand and the client’s MockTransport answers requests
locally. Only the transport differs from a real deployment.
Create the file
touch catalog.py
Add the code: catalog.py
"""Stand-in for the REST API you are wrapping.
In a real project the spec comes from the service (`httpx.get(".../openapi.json")`)
and the client points at its base URL with real auth. To keep this tutorial
offline and deterministic, `OPENAPI_SPEC` is written out by hand and `make_client`
returns an httpx client whose `MockTransport` answers requests locally — swap that
transport for a real `base_url` and the rest of the server is unchanged.
"""
import json
import httpx
# The OpenAPI document that describes the API. Four operations, each with an
# operationId (FastMCP uses it to name the generated component).
OPENAPI_SPEC: dict = {
"openapi": "3.1.0",
"info": {"title": "Catalog API", "version": "1.0.0"},
"paths": {
"/products": {
"get": {
"operationId": "list_products",
"summary": "List all products",
"responses": {"200": {"description": "ok"}},
}
},
"/products/{product_id}": {
"get": {
"operationId": "get_product",
"summary": "Get one product by id",
"parameters": [
{
"name": "product_id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {"200": {"description": "ok"}},
},
"delete": {
"operationId": "delete_product",
"summary": "Delete a product (admin only)",
"parameters": [
{
"name": "product_id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {"204": {"description": "deleted"}},
},
},
"/orders": {
"post": {
"operationId": "create_order",
"summary": "Place an order",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"product_id": {"type": "integer"},
"qty": {"type": "integer"},
},
"required": ["product_id", "qty"],
}
}
},
},
"responses": {"201": {"description": "created"}},
}
},
},
}
API_KEY = "secret-key"
_PRODUCTS = {
1: {"id": 1, "name": "Widget", "price": 9.99},
2: {"id": 2, "name": "Gadget", "price": 19.99},
}
def _handler(request: httpx.Request) -> httpx.Response:
"""The mock backing service. Rejects requests without the API key."""
if request.headers.get("X-API-Key") != API_KEY:
return httpx.Response(401, json={"error": "unauthorized"})
path, method = request.url.path, request.method
if path == "/products" and method == "GET":
return httpx.Response(200, json=list(_PRODUCTS.values()))
if path.startswith("/products/") and method == "GET":
pid = int(path.rsplit("/", 1)[1])
return httpx.Response(200, json=_PRODUCTS.get(pid, {}))
if path == "/orders" and method == "POST":
body = json.loads(request.content)
return httpx.Response(201, json={"order_id": 100, **body, "status": "placed"})
return httpx.Response(404, json={"error": "not found"})
def make_client(api_key: str = API_KEY) -> httpx.AsyncClient:
"""Build the httpx client FastMCP calls. Auth headers set here apply to
every generated tool and resource automatically."""
return httpx.AsyncClient(
transport=httpx.MockTransport(_handler),
base_url="https://api.example.com",
headers={"X-API-Key": api_key},
)
Detailed breakdown
OPENAPI_SPECdescribes four operations. Each has anoperationId— FastMCP uses it to name the generated component, solist_productsbecomes a resource namedlist_products. A spec with nooperationIdgets a name derived from the method and path instead._handleris the fake service. The API-key check up top is the important part: it rejects any request that arrives withoutX-API-Key: secret-key, which is what the auth test later relies on.make_clientbuilds thehttpx.AsyncClient. In a real project you drop thetransport=argument and the client talks tobase_urlover the network; theheadersare where the auth lives, applied to every request FastMCP makes.
Step 4: Generate the server with route maps
Create the file
touch server.py
Add the code: server.py
"""Generate an MCP server from an OpenAPI spec.
`FastMCP.from_openapi` reads the spec and turns each operation into an MCP
component. By default every 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**.
The httpx client carries the base URL and auth header, so every generated
component calls the real API with credentials attached.
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
import catalog
# 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_product/{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.
]
mcp = FastMCP.from_openapi(
catalog.OPENAPI_SPEC,
client=catalog.make_client(),
name="catalog",
route_maps=ROUTE_MAPS,
)
if __name__ == "__main__":
mcp.run()
Detailed breakdown
RouteMapandMCPTypecome fromfastmcp.server.providers.openapi. Each map matches by HTTPmethodsand a regexpatternagainst the route path, and assigns anmcp_type.- Order matters. The 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. - The pattern
r".*\{.*\}.*"matches any path containing a{name}segment, which is how a GET with a path parameter is routed to a template. - Nothing lists the routes as tools by hand. The generation is entirely driven by the spec plus these four rules, so adding an endpoint to the spec adds a component automatically.
Step 5: See how each operation was mapped
Create the file
touch client.py
Add the code: client.py
"""Drive the generated catalog server in-memory.
Shows how the four OpenAPI operations landed: which became tools, resources, and
templates (and which was excluded), then calls one of each. Every call reaches
the backing API through the httpx client, with the API key attached.
"""
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_products:")
print(" ", (await client.read_resource("resource://list_products"))[0].text)
print("read resource://get_product/2:")
print(" ", (await client.read_resource("resource://get_product/2"))[0].text)
print("\ncall create_order(product_id=1, qty=3):")
r = await client.call_tool("create_order", {"product_id": 1, "qty": 3})
print(" ", r.structured_content)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The three list calls show the split:
create_orderis a tool,list_productsa resource,get_product/{product_id}a template, anddelete_productis absent because it was excluded. - The reads resolve to real HTTP GETs against the backing API; the returned text is the JSON body the service produced.
create_orderis a POST tool. FastMCP builds its input schema from the spec’srequestBody, so{"product_id": 1, "qty": 3}is validated and sent as the JSON body.
Run it:
uv run python client.py
Expected output:
tools: ['create_order']
resources: ['resource://list_products']
templates: ['resource://get_product/{product_id}']
read resource://list_products:
[{"id": 1, "name": "Widget", "price": 9.99}, {"id": 2, "name": "Gadget", "price": 19.99}]
read resource://get_product/2:
{"id": 2, "name": "Gadget", "price": 19.99}
call create_order(product_id=1, qty=3):
{'order_id': 100, 'product_id': 1, 'qty': 3, 'status': 'placed'}
Step 6: Test the mapping, the calls, and auth
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 OpenAPI-generated catalog server.
Covers how the four operations were mapped (tool vs resource vs template vs
excluded), that reads and a tool call reach the backing API, and that the auth
header the httpx client carries is what makes those calls succeed.
"""
import pytest
from fastmcp import Client, FastMCP
from fastmcp.server.providers.openapi import RouteMap
from mcp.shared.exceptions import McpError
import catalog
from server import ROUTE_MAPS, mcp
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_order"} # POST -> tool
assert "resource://list_products" in resources # GET -> resource
assert "resource://get_product/{product_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_product" in n for n in names)
async def test_read_list_resource():
async with Client(mcp) as client:
text = (await client.read_resource("resource://list_products"))[0].text
assert '"name": "Widget"' in text
assert '"name": "Gadget"' in text
async def test_read_template_resource():
async with Client(mcp) as client:
text = (await client.read_resource("resource://get_product/2"))[0].text
assert text == '{"id": 2, "name": "Gadget", "price": 19.99}'
async def test_tool_call_reaches_the_api():
async with Client(mcp) as client:
r = await client.call_tool("create_order", {"product_id": 1, "qty": 3})
assert r.structured_content == {
"order_id": 100,
"product_id": 1,
"qty": 3,
"status": "placed",
}
async def test_missing_api_key_fails():
"""A client built without the right key gets a 401 from the backing API,
surfaced as an MCP error — proving the header is what authorizes the call."""
bad = FastMCP.from_openapi(
catalog.OPENAPI_SPEC,
client=catalog.make_client("wrong-key"),
route_maps=ROUTE_MAPS,
)
async with Client(bad) as client:
with pytest.raises(McpError):
await client.read_resource("resource://list_products")
Detailed breakdown
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_delete_route_is_excludedproves theEXCLUDErule worked — the destructive route is not exposed under any component type.test_read_template_resourceandtest_tool_call_reaches_the_apiconfirm the generated components actually reach the backing API and return its data.test_missing_api_key_failsbuilds a second server with the wrong key; the backing API 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: ## Generate the server from the spec and exercise each component
uv run python client.py
serve: ## Run the generated 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 # generates the server and calls each component (output in Step 5)
make test # runs the suite
The suite passes:
...... [100%]
6 passed in 0.36s
Notes on wrapping a real API
- Fetch the spec, do not hand-write it. Replace
OPENAPI_SPECwithhttpx.get("https://api.example.com/openapi.json").json(), and drop theMockTransportso the client hits the real base URL. - Auth goes on the client. A bearer token or API key set in the client’s
headers(or via an httpx auth flow) applies to every generated component. There is no per-tool auth to wire up. - Exclude before you expose. A generated server mirrors the whole API. 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. - Rename with
mcp_names. IfoperationIds are ugly (getProductsById_v2), passmcp_names={"getProductsById_v2": "get_product"}to give components clean names. - Big APIs make big servers. A 200-endpoint spec 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. - Calls return 401/403. The client has no auth or the wrong credentials. Set
the token or key in
make_client’sheaders, not on individual operations. - A component has an unreadable name. The spec’s
operationIdis used as-is. Fix theoperationId, or remap it withmcp_names. ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.
Recap
Four OpenAPI operations became an MCP server with no per-endpoint code: a POST turned into a tool, two GETs split into a resource and a template, and a DELETE was excluded. Route maps decided the shape, and a single httpx client carried the base URL and API key so every generated component authenticated the same way. Swapping the mock transport for a real base URL is all it takes to wrap a live service.
Next improvements:
- Load the spec from the live API and regenerate on a schedule as it changes.
- 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 real API.