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 whose MockTransport stands in for the real API (and enforces an API key).
  • server.py: the from_openapi call 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 pytest suite covering the mapping, the reads, a tool call, and auth.
  • 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).

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

  • httpx is listed explicitly because the code constructs the client and its MockTransport directly. 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_SPEC describes four operations. Each has an operationId — FastMCP uses it to name the generated component, so list_products becomes a resource named list_products. A spec with no operationId gets a name derived from the method and path instead.
  • _handler is the fake service. The API-key check up top is the important part: it rejects any request that arrives without X-API-Key: secret-key, which is what the auth test later relies on.
  • make_client builds the httpx.AsyncClient. In a real project you drop the transport= argument and the client talks to base_url over the network; the headers are 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

  • RouteMap and MCPType come from fastmcp.server.providers.openapi. Each map matches by HTTP methods and a regex pattern against the route path, and assigns an mcp_type.
  • Order matters. The 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.
  • 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_order is a tool, list_products a resource, get_product/{product_id} a template, and delete_product is 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_order is a POST tool. FastMCP builds its input schema from the spec’s requestBody, 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 = 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 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_component pins the whole mapping in one place: POST is a tool, the two GETs split into a resource and a template.
  • test_delete_route_is_excluded proves the EXCLUDE rule worked — the destructive route is not exposed under any component type.
  • test_read_template_resource and test_tool_call_reaches_the_api confirm the generated components actually reach the backing API and return its data.
  • test_missing_api_key_fails builds a second server with the wrong key; the backing API 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:  ## 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 := 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     # 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_SPEC with httpx.get("https://api.example.com/openapi.json").json(), and drop the MockTransport so 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 EXCLUDE route 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. If operationIds are ugly (getProductsById_v2), pass mcp_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_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.
  • Calls return 401/403. The client has no auth or the wrong credentials. Set the token or key in make_client’s headers, not on individual operations.
  • A component has an unreadable name. The spec’s operationId is used as-is. Fix the operationId, or remap it 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 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_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 real API.