mcpstepbystep

Tutorials Builder

How to wrap a REST API in an MCP server (the right way)

Wrap a REST API in an MCP server with Python FastMCP and httpx: task-shaped tools, env-var auth, pagination, response trimming, and errors models can fix.

updated 2026-07-19 ⏱ 40–50 min

Wrapping a REST API is the most common MCP server anyone builds — and the easiest one to build badly. The tempting path is mechanical: one endpoint, one tool, pass the JSON through. The result is a server with thirty tools the model can’t choose between, responses that flood the context window, and 401s that read like stack traces.

The right way starts from a different question: not “what does the API expose?” but “what does the model need to do?” We’ll build it with the official Python SDK’s FastMCP and httpx, wrapping the free, no-auth Open Notify API (ISS position, people in space), then layer on the patterns every real API needs: auth from env vars, pagination, trimming, and error mapping.

sequenceDiagram
    participant C as MCP client
    participant S as MCP server
    participant A as REST API
    C->>S: tools/call where_is_the_iss
    S->>A: GET /iss-now.json
    A-->>S: raw JSON
    S-->>C: trimmed, task-shaped answer

Step 1 — Design tools around tasks, not endpoints

Before writing code, translate the API surface into a task surface. Open Notify has two endpoints; a naive wrapper mirrors them and returns raw JSON. A designed wrapper asks what a model will actually be asked by a user:

Endpoint (API-shaped)Tool (task-shaped)
GET /iss-now.json → raw lat/long JSONwhere_is_the_iss → “The ISS is at latitude 47.6, longitude −122.3”
GET /astros.json → flat list of 12 peoplewho_is_in_space → names grouped by spacecraft, with a count

Two endpoints is a toy, but the principle scales brutally: a 30-endpoint CRUD API should usually become 5–8 tools, each doing one job a user would name. Every tool definition costs context in every conversation (the token budget lesson), and every extra near-duplicate tool makes the model’s choice worse. Merge list/get-by-X variants into one search tool; drop admin endpoints the model should never touch; fold multi-call sequences the user thinks of as one action into one tool.

Step 2 — Set up the project

uv init space-station
cd space-station
uv add "mcp[cli]" httpx

Create server.py in the project root. Everything below goes in this one file.

Step 3 — Build the core server

import os
import sys

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("space-station")

OPEN_NOTIFY = "http://api.open-notify.org"
TIMEOUT = httpx.Timeout(10.0)


@mcp.tool()
async def where_is_the_iss() -> str:
    """Current position of the International Space Station as latitude/longitude."""
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        r = await client.get(f"{OPEN_NOTIFY}/iss-now.json")
        r.raise_for_status()
        pos = r.json()["iss_position"]
    return f"The ISS is at latitude {pos['latitude']}, longitude {pos['longitude']}."


@mcp.tool()
async def who_is_in_space() -> str:
    """Everyone currently in space, grouped by spacecraft."""
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        r = await client.get(f"{OPEN_NOTIFY}/astros.json")
        r.raise_for_status()
        data = r.json()

    by_craft: dict[str, list[str]] = {}
    for person in data["people"]:
        by_craft.setdefault(person["craft"], []).append(person["name"])

    lines = [f"{data['number']} people are in space right now."]
    for craft, names in by_craft.items():
        lines.append(f"{craft}: {', '.join(names)}")
    return "\n".join(lines)


if __name__ == "__main__":
    mcp.run(transport="stdio")

Note what the tools return: a sentence, not a payload. The model doesn’t need {"message": "success", "timestamp": 1752900000, ...} — it needs the answer. Explicit timeouts matter too: without one, a hung API leaves the tool call dangling until the client gives up, and the model learns nothing useful.

Test it before going further, Inspector first as always:

npx @modelcontextprotocol/inspector uv --directory /absolute/path/to/space-station run server.py

List tools, call both, read the results. One rule from the debugging tutorial bears repeating here because API wrappers are the worst offenders: never print() in a stdio server — stdout is the protocol channel. Debug to stderr: print(data, file=sys.stderr).

Step 4 — Auth headers from environment variables

Open Notify needs no auth, but nearly every API you wrap at work does. The pattern: read the key from the environment at startup, never hardcode it, never make it a tool parameter (the model should never see or handle credentials).

API_BASE = "https://api.example.com/v1"
API_KEY = os.environ.get("EXAMPLE_API_KEY", "")


def auth_headers() -> dict[str, str]:
    return {"Authorization": f"Bearer {API_KEY}"}

MCP clients don’t inherit your shell environment — the export in your .bashrc is invisible to Claude Desktop. Pass secrets through the config’s env block. Claude Desktop (claude_desktop_config.json, literal values only):

{
  "mcpServers": {
    "space-station": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/space-station", "run", "server.py"],
      "env": {
        "EXAMPLE_API_KEY": "your-key-here"
      }
    }
  }
}

Claude Code’s project-level .mcp.json supports ${VAR} expansion, so the key can stay out of the file entirely:

{
  "mcpServers": {
    "space-station": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/space-station", "run", "server.py"],
      "env": {
        "EXAMPLE_API_KEY": "${EXAMPLE_API_KEY}"
      }
    }
  }
}

Or register it directly: claude mcp add --transport stdio space-station -- uv --directory /absolute/path/to/space-station run server.py.

Step 5 — Pagination and response trimming

The context window is your scarcest resource, and REST APIs are engineered to fill it. A generic search tool against a paginated API should do three things: cap the page size, select fields, and hand the model a cursor instead of the firehose.

@mcp.tool()
async def search_incidents(
    query: str, max_results: int = 10, cursor: str | None = None
) -> dict:
    """Search incidents by keyword. Returns at most max_results items and a
    next_cursor — pass it back to get the next page. Keep max_results small."""
    params: dict[str, str | int] = {"q": query, "per_page": min(max_results, 25)}
    if cursor:
        params["cursor"] = cursor

    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        r = await client.get(
            f"{API_BASE}/incidents", params=params, headers=auth_headers()
        )
        r.raise_for_status()
        data = r.json()

    return {
        "items": [
            {"id": i["id"], "title": i["title"], "status": i["status"]}
            for i in data["items"]
        ],
        "next_cursor": data.get("next_cursor"),
        "hint": "Call get_incident with an id for full details.",
    }

Three deliberate choices here. Field selection: the upstream record has twenty fields; the search result carries three, enough to choose from — a follow-up tool fetches full details for one record. A hard cap (min(max_results, 25)) so neither the model nor a generous default can request 500 rows into context. The cursor pattern: the model gets next_cursor and instructions in the docstring, so “show me more” costs one page, not a re-fetch of everything.

The same trimming instinct applies to single-record responses: strip nulls, internal IDs, HATEOAS _links blocks, and anything else the model can’t use. If a response routinely exceeds a screenful of JSON, you’re shipping tokens the user is paying for and the model is skimming.

Step 6 — Map errors so the model can self-correct

When a raw httpx.HTTPStatusError traceback reaches the model, the turn is wasted. But a tool error that says what to do differently often gets fixed on the model’s very next attempt — it retries with a valid ID, narrows the query, or tells the user the key is bad. Error messages are model prompts; write them that way.

def explain_http_error(e: httpx.HTTPStatusError) -> str:
    status = e.response.status_code
    if status == 401:
        return (
            "Authentication failed (401). EXAMPLE_API_KEY is missing or invalid — "
            "tell the user to check the env block in their MCP config."
        )
    if status == 404:
        return (
            "Not found (404). That ID does not exist — "
            "use search_incidents first to find a valid one."
        )
    if status == 429:
        return "Rate limited (429). Wait before retrying, or use a narrower query."
    return f"The API returned HTTP {status}: {e.response.text[:200]}"


@mcp.tool()
async def get_incident(incident_id: str) -> str:
    """Full details for one incident. Get IDs from search_incidents."""
    try:
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            r = await client.get(
                f"{API_BASE}/incidents/{incident_id}", headers=auth_headers()
            )
            r.raise_for_status()
    except httpx.HTTPStatusError as e:
        return explain_http_error(e)
    except httpx.RequestError as e:
        return f"Could not reach the API ({type(e).__name__}). Check network access and try again."

    inc = r.json()
    return f"[{inc['status']}] {inc['title']}\n{inc['description']}"

Notice each message pairs the diagnosis with the next action: 404 points at the search tool, 401 points at the config. That’s the difference between a model that loops on failures and one that recovers in a single turn.

Step 7 — Why not just generate this from the OpenAPI spec?

Tools exist that turn an OpenAPI document into an MCP server automatically, and for a first prototype they’re fine. But a generated server inherits every problem this tutorial just designed away: one tool per endpoint (the 106-tool server measured at ~54K tokens of definitions at init is exactly this failure mode), parameter names written for developers rather than models, raw pass-through responses, and HTTP errors surfaced verbatim. The API’s shape is optimised for programmers calling it from code; the tool list’s shape must be optimised for a model choosing under uncertainty with a metered context window. Those are different artefacts. Generate as a starting inventory if you like — then curate by hand. The full argument, with the decision table, is in MCP vs OpenAPI: which interface should you give the model?.

Troubleshooting

MCP error -32000: Connection closed The server crashed during startup — with API wrappers the usual culprit is an import error because the client launched your script outside the project environment, so httpx (or mcp) isn’t installed. Launch through uv with an absolute project path, exactly as in the config above: uv --directory /absolute/path/to/space-station run server.py. To see the real traceback, run that command yourself in a terminal.

spawn uv ENOENT The client can’t find uv because GUI apps don’t get your shell’s PATH. Use the absolute path from which uv (macOS/Linux) or where uv (Windows) as the command value. On Windows, remember JSON path escaping — "C:\\Users\\you\\.local\\bin\\uv.exe" — or route through "command": "cmd", "args": ["/c", "uv", ...].

httpx.ConnectError: [Errno -2] Name or service not known DNS or network failure from inside the server process. If the same URL works in your browser, you’re probably behind a corporate proxy the spawned process doesn’t know about: pass HTTP_PROXY/HTTPS_PROXY in the config’s env block, since the client won’t forward your shell’s proxy variables. Also check for a typo in the base URL — httpx reports the hostname it tried in the message.

The API returns 401 in the tool, but the same key works in curl Your shell has the variable; the server process doesn’t. MCP clients do not inherit your terminal environment, so os.environ.get("EXAMPLE_API_KEY") comes back empty and you send Bearer with nothing after it. Put the key in the env block of the client config (literal value for Claude Desktop; ${VAR} expansion works in Claude Code’s .mcp.json), then fully restart the client.

Responses blow up the context window (or get truncated by the client) You’re passing the API’s response through unfiltered. Apply Step 5: select only the fields the model needs, cap page sizes with a min() guard, and return a cursor plus a hint instead of more rows. If one record is inherently large (long description bodies, embedded logs), split access into a summary tool and a detail tool so the model pays for depth only when it asks for it.

Unexpected token / JSON parse errors right after adding debug prints A print() in a stdio server writes into the protocol stream and corrupts it. Use print(..., file=sys.stderr) — stderr shows up in the client’s per-server log and the Inspector’s notifications pane. The full stdout/stderr rule, and where each client keeps its logs, is in the debugging workflow tutorial.

Next step

Your wrapper runs on stdio, on one machine, for one user. Next: lifting the same server onto Streamable HTTP so a whole team — and remote clients — can reach it.

newsletter

One practical MCP guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.