Menu

AI

How to Build an MCP Server for a REST API (Python Wrapper Tutorial)

Wrap any REST API in an MCP server with Python. Share one HTTP client, map endpoints to focused tools, turn HTTP errors into tool errors, then test and connect it.

By: Irina ShvayaNovember 2, 2026

How to Build an MCP Server for a REST API (Python Wrapper Tutorial)

This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.

To build an MCP server for a REST API, you write a small Python program that holds one HTTP client and exposes a handful of tools, each calling one or two endpoints. The model never sees your API directly. It sees tool names, typed inputs and short descriptions, and your code makes the HTTP calls.

This tutorial wraps a public test API so you can run every step without an account. The same pattern works for a CRM, a booking system or any JSON API with a key. You will add read tools, one write tool, readable error handling, and then test and connect the server.

Checked against: the Python SDK docs on 2 November 2026 with mcp 2.3.0 (the latest PyPI release) and httpx 0.28.1. The code ran locally on 2 November 2026 against JSONPlaceholder, a free fake REST API.

Key Takeaways

  • Create one httpx.AsyncClient in the server's lifespan and reuse it for every call.
  • Design tools around jobs ("get a customer's open orders"), not around every endpoint in the API.
  • Trim responses to the fields the model needs. Raw API payloads waste context and can leak data.
  • Turn 404, 429 and 5xx responses into ToolError messages that tell the model what to do next.
  • Keep the API key in the server's environment. Never ask the model for it, and never forward a user's MCP token to the API.

Hand-written wrapper or generated server?

There are two ways to put a REST API behind MCP:

ApproachGood forWatch out for
Hand-written wrapper (this guide)3 to 15 well-chosen tools, clean outputsYou maintain the code
Generate from an OpenAPI specFast prototypes, very large APIsOne tool per endpoint, raw payloads

FastMCP's own OpenAPI docs say "LLMs achieve significantly better performance with well-designed and curated MCP servers than with auto-converted OpenAPI servers." If you have a spec and want to start there, read how to turn an OpenAPI spec into an MCP server. Our MCP vs OpenAPI page explains how the two fit together.

Prerequisites

  • Python 3.11 or newer (the SDK README says 3.11+).
  • The API's base URL and, usually, an API key.
  • uv or pip, and Node.js 22.19+ for the MCP Inspector CLI.

New to building tools? Lesson 14 of the Learn MCP course covers the basics.

Install

uv init rest-mcp --python 3.12
cd rest-mcp
uv add "mcp[cli]" httpx

Minimal working server

Create server.py:

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Annotated, Any

import httpx
from pydantic import Field

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

BASE_URL = os.environ.get("API_BASE_URL", "https://jsonplaceholder.typicode.com")
API_KEY = os.environ.get("API_KEY")  # optional; sent as a bearer token


@dataclass
class AppContext:
    http: httpx.AsyncClient


@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
    headers = {"Authorization": f"Bearer {API_KEY}"} if API_KEY else {}
    async with httpx.AsyncClient(base_url=BASE_URL, headers=headers, timeout=15) as http:
        yield AppContext(http=http)


mcp = MCPServer("rest-api", lifespan=lifespan)


async def api(ctx: Context[AppContext], method: str, path: str, **kwargs: Any) -> Any:
    """Call the API and turn HTTP failures into errors the model can read."""
    http = ctx.request_context.lifespan_context.http
    try:
        response = await http.request(method, path, **kwargs)
    except httpx.HTTPError as exc:
        raise ToolError(f"Could not reach the API: {exc}") from exc
    if response.status_code == 404:
        raise ToolError(f"Not found: {path}")
    if response.status_code == 429:
        raise ToolError("The API is rate limiting requests. Wait a minute and try again.")
    if response.is_error:
        raise ToolError(f"API returned {response.status_code}: {response.text[:300]}")
    return response.json()


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def get_user(user_id: Annotated[int, Field(ge=1)], ctx: Context[AppContext]) -> dict[str, Any]:
    """Get one user's name, email and company by numeric ID."""
    user = await api(ctx, "GET", f"/users/{user_id}")
    return {"id": user["id"], "name": user["name"], "email": user["email"], "company": user["company"]["name"]}


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

The important parts:

  • One client for the server's life. The httpx async docs advise against creating many client instances, for example with async with inside a hot loop, and recommend "a single scoped client." The lifespan is that scope. The SDK's lifespan guide says it runs once, and every tool shares what it yields.
  • base_url, headers and timeout are set once. Each tool passes only a path.
  • The api() helper is where HTTP turns into MCP. A 404 becomes "Not found," a 429 tells the model to wait, and other failures include the status code and a short slice of the body.
  • get_user trims the response. JSONPlaceholder returns address, phone, website and more. The tool returns four fields. Less data means fewer tokens and less chance of exposing something you did not intend.

Add more tools

Add a list tool and a write tool:

@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def list_posts_by_user(
    user_id: Annotated[int, Field(ge=1)],
    ctx: Context[AppContext],
    limit: Annotated[int, Field(ge=1, le=20)] = 5,
) -> list[dict[str, Any]]:
    """List a user's most recent posts (id and title only)."""
    posts = await api(ctx, "GET", "/posts", params={"userId": user_id})
    return [{"id": p["id"], "title": p["title"]} for p in posts[:limit]]


@mcp.tool(annotations=ToolAnnotations(destructive_hint=False, idempotent_hint=False))
async def create_post(
    user_id: Annotated[int, Field(ge=1)],
    title: Annotated[str, Field(min_length=3, max_length=120)],
    body: Annotated[str, Field(max_length=2000)],
    ctx: Context[AppContext],
) -> dict[str, Any]:
    """Create a new post for a user. Ask the user to confirm the text first."""
    return await api(ctx, "POST", "/posts", json={"userId": user_id, "title": title, "body": body})

Design notes:

  • Limits on everything. limit caps list size. max_length caps text the model can send to your API.
  • Annotations describe risk. create_post is not read-only and not idempotent, because calling it twice makes two posts. Hosts can use that to ask the user first. The SDK's Tools guide says annotations are hints, not security, so your API permissions still matter.
  • The description asks for confirmation. It nudges the model to show the user the text before it writes.

Test it in memory

Create check.py:

import anyio
from mcp import Client
from server import mcp


async def main() -> None:
    async with Client(mcp) as c:
        print((await c.call_tool("get_user", {"user_id": 1})).structured_content)
        print((await c.call_tool("list_posts_by_user", {"user_id": 1, "limit": 2})).structured_content)
        r = await c.call_tool("get_user", {"user_id": 9999})
        print(r.is_error, r.content[0].text)
        r = await c.call_tool("create_post", {"user_id": 1, "title": "Hello", "body": "Draft from MCP"})
        print(r.structured_content)

anyio.run(main)

The output on 2 November 2026:

{'id': 1, 'name': 'Leanne Graham', 'email': 'Sincere@april.biz', 'company': 'Romaguera-Crona'}
{'result': [{'id': 1, 'title': 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit'}, {'id': 2, 'title': 'qui est esse'}]}
True Error executing tool get_user: Not found: /users/9999
{'userId': 1, 'title': 'Hello', 'body': 'Draft from MCP', 'id': 101}

The missing user came back as a readable tool error, not a crash. The post "created" with ID 101, but JSONPlaceholder's guide notes that "The resource is not really created on the server." Your real API will store it.

Test with the MCP Inspector

npx @modelcontextprotocol/inspector --cli \
  -e API_BASE_URL=https://jsonplaceholder.typicode.com \
  uv --directory /ABSOLUTE/PATH/rest-mcp run server.py \
  --method tools/list --format json

Read each tool's description and schema as the model will. If you cannot tell which tool to call for a request, the model cannot either. The Inspector docs and our Inspector lesson cover tool calls, exit codes and CI use.

Connect it to a host

Pass the API key as an environment variable in the host config. For Claude Code:

claude mcp add -e API_KEY=your-key -e API_BASE_URL=https://api.example.com \
  --transport stdio my-api -- uv run --directory /ABSOLUTE/PATH/rest-mcp server.py

The Claude Code MCP docs say options go before the name and the command after --. For Claude Desktop, Cursor and VS Code, use the SDK's real-host guide or our MCP config generator.

Deploy notes

A local stdio server uses one API key: yours. That is fine for personal use. For a team, you have two choices:

  1. A shared service key on a remote server. Simple, but every user acts with the same rights. Log who called what.
  2. Per-user access. Each person signs in to your MCP server with OAuth, and your server uses its own credentials for the API, scoped to that user. See adding OAuth to a remote MCP server.

Do not accept a user's token and forward it to the API. The MCP security best practices call this "token passthrough," an anti-pattern, and state that MCP servers "MUST NOT accept any tokens that were not explicitly issued for the MCP server." Our page on token passthrough explains the risk.

Pitfalls

  1. One tool per endpoint. Fifty near-identical tools confuse the model and fill its context. Group by task.
  2. Returning the raw payload. Trim fields. Nested IDs and internal flags rarely help the model.
  3. No timeout. A hung API call leaves the model waiting. Set a client timeout.
  4. Letting the model choose the URL. Accept IDs and filters as arguments, never a full URL. A free URL argument invites server-side request forgery.
  5. Old SDK code. from mcp.server.fastmcp import FastMCP is gone in v2. Use from mcp.server import MCPServer.
  6. Printing to stdout. It corrupts a stdio server. Log to stderr.

Next step

The free Learn MCP course covers error handling, tool design and remote deployment in more depth. If you want your product's API wrapped as a polished MCP server, eSEOspace MCP development can help plan the tool set. For APIs inside your firewall, see building an MCP server for an internal API.

More in the MCP Guide

Free tools: server.json Validator · MCP Error Code Lookup

Latest research: State of MCP: October 2026

Frequently asked questions

How many tools should a REST API server have?

Start with the five to ten tasks people actually ask for. You can add more later. Fewer, clearer tools usually beat full coverage.

Should I use resources instead of tools for GET endpoints?

Tools are simpler for most hosts, because the model calls them on its own. Resources suit content the user or app attaches deliberately, such as a document. Our page on MCP tools compares them.

How do I handle pagination?

Accept a cursor or page argument and return the next cursor with the results. Cap page size so one call cannot return thousands of rows.

Can I use requests instead of httpx?

requests is synchronous, so you would use plain def tools. The SDK's Tools guide says it runs those in a thread so they do not block the server. httpx with async def is the cleaner fit.

What about retries?

Retry on network errors and 5xx responses a small number of times, with backoff. Do not retry writes unless the API supports idempotency keys, or you may create duplicates.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.