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.