n4nAI

Building an MCP server for your internal REST API

Hands-on tutorial: build an mcp server rest api wrapper using the Model Context Protocol Python SDK, with a mock internal API, runnable code, and client tests.

n4n Team3 min read572 words

Audio narration

Coming soon — every post will get a voice note here.

Wrapping an internal service as an mcp server rest api proxy is the fastest way to give LLM agents structured access to systems you already run. This tutorial builds a working MCP server in Python that forwards calls to a hypothetical inventory REST API, using the official mcp SDK. You’ll get runnable code, a mock backend, and a client script that prints real protocol output.

Prerequisites

  • Python 3.10 or newer
  • pip install mcp httpx fastapi uvicorn
  • Familiarity with async/await and JSON
  • An internal REST API to wrap. We’ll mock one locally so the example is fully runnable.

If you already have a REST service, skip the mock and point BASE at your URL.

Step 1: Mock the internal REST API

We’ll stand up a minimal inventory API. This is the thing our mcp server rest api wrapper will call.

# internal_api.py
from fastapi import FastAPI

app = FastAPI()

ITEMS = {"SKU-001": {"sku": "SKU-001", "name": "Widget", "qty": 42}}

@app.get("/items/{sku}")
async def get_item(sku: str):
    item = ITEMS.get(sku)
    if not item:
        return {"error": "not found"}
    return item

@app.post("/items")
async def create_item(item: dict):
    ITEMS[item["sku"]] = item
    return item

Run it:

uvicorn internal_api:app --port 8000

Checkpoint — confirm the backend responds:

curl localhost:8000/items/SKU-001

Expected output:

{"sku":"SKU-001","name":"Widget","qty":42}

Step 2: Scaffold the MCP server

The mcp package ships FastMCP, a decorator-based server. We define tools (callable functions) and resources (read-only data). The mcp server rest api pattern is just: each tool makes an HTTP call and returns a serializable object.

# server.py
from mcp.server.fastmcp import FastMCP
import httpx

BASE = "http://localhost:8000"
mcp = FastMCP("inventory")

Step 3: Implement tools that call the REST API

Tools are the agent-facing surface. Keep signatures strict and docstrings precise—the LLM uses them for selection.

@mcp.tool()
async def get_item(sku: str) -> dict:
    """Retrieve an item by SKU from the internal inventory API."""
    async with httpx.AsyncClient() as client:
        r = await client.get(f"{BASE}/items/{sku}")
        r.raise_for_status()
        return r.json()

@mcp.tool()
async def create_item(sku: str, name: str, qty: int) -> dict:
    """Create a new item. Requires sku, name, and integer qty."""
    async with httpx.AsyncClient() as client:
        r = await client.post(f"{BASE}/items", json={"sku": sku, "name": name, "qty": qty})
        r.raise_for_status()
        return r.json()

Add a resource for health checks. Resources are URI-addressable read endpoints.

@mcp.resource("inventory://health")
async def health() -> str:
    """Return 'ok' if the backend responds, else 'degraded'."""
    async with httpx.AsyncClient() as client:
        try:
            r = await client.get(f"{BASE}/items/SKU-001", timeout=2.0)
            return "ok" if r.status_code == 200 else "degraded"
        except httpx.HTTPError:
            return "degraded"

Finally, boot the server over stdio (default for local agents):

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

Step 4: Run the server and verify tool registration

Start the server in one terminal:

python server.py

It blocks on stdio; you won’t see stdout logs unless you add them. To confirm the mcp server rest api contract is correct, we use a client.

Step 5: Write an MCP client test

The SDK provides a stdio client. This script launches server.py as a subprocess, initializes the session, and calls get_item.

# client.py
import asyncio
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession

async def main():
    params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print("TOOLS:", [t.name for t in tools.tools])
            result = await session.call_tool("get_item", {"sku": "SKU-001"})
            print("RESULT:", result.content)

asyncio.run(main())

Run it while the mock API is still up:

python client.py

Expected output:

TOOLS: ['get_item', 'create_item']
RESULT: [{'type': 'text', 'text': '{"sku": "SKU-001", "name": "Widget", "qty": 42}'}]

The dict returned from the tool is JSON-encoded into a text content block. That’s the wire format agents consume.

Step 6: Test the write path

Modify client.py to call create_item:

result = await session.call_tool("create_item", {"sku": "SKU-002", "name": "Gadget", "qty": 7})
print("CREATED:", result.content)

Expected output:

CREATED: [{'type': 'text', 'text': '{"sku": "SKU-002", "name": "Gadget", "qty": 7}'}]

Hit the mock API directly to confirm state persisted in the process memory:

curl localhost:8000/items/SKU-002
{"sku":"SKU-002","name":"Gadget","qty":7}

Design notes for production

The toy example above is missing things you cannot skip in real deployments.

Auth and secrets

Never hardcode tokens. Inject them via environment variables and set httpx headers per request:

headers = {"Authorization": f"Bearer {os.environ['API_TOKEN']}"}
await client.get(url, headers=headers)

Error mapping

raise_for_status() surfaces httpx.HTTPStatusError. Wrap it and return a structured error dict so the agent can reason instead of crashing:

try:
    r.raise_for_status()
except httpx.HTTPStatusError as e:
    return {"error": str(e), "status": e.response.status_code}

Timeouts and retries

Internal APIs degrade. Set explicit timeouts (timeout=5.0) and consider a single retry on 503. The mcp server rest api layer should fail soft, not hang the agent loop.

Schema tightening

FastMCP infers JSON schema from type hints. Use pydantic models for complex inputs instead of bare dict to get validation and better LLM prompts:

from pydantic import BaseModel
class Item(BaseModel):
    sku: str
    name: str
    qty: int

@mcp.tool()
async def create_item(item: Item) -> dict: ...

Transport

Stdio is fine for local. For remote agents, run the server with mcp.run(transport="sse") behind an authenticated reverse proxy.

Why this pattern wins

Building an mcp server rest api adapter keeps your LLM tools thin. Business logic stays in the REST service; the MCP layer is pure translation. You get one auditable surface for agent access, and you can swap the backend without touching prompt engineering. If you later route model traffic through a gateway like n4n.ai, the agent’s tool calls remain unchanged because they speak MCP, not provider specifics.

Ship the server as a small container, mock the backend in CI, and treat tool docstrings as API documentation—because they are.

Tagsmcprest-apimcp-servertutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All model context protocol (mcp) deep dives posts →