Tutorials Builder
How to wrap a REST API in an MCP server (without handing the agent the whole API)
Turn any REST API into agent-safe MCP tools: a narrow allowlist instead of a proxy, PII redaction before the model sees data, and a fake API so you can test the whole thing offline.
The most common real-world MCP server isn’t a database or a filesystem —
it’s a wrapper around some REST API your organization already has. And the
most common mistake is building it as a proxy: one call_api(method, path, body) tool that forwards anything. That hands the model your entire
API surface, including endpoints you never considered. The right shape is
a few narrow tools that each do one blessed thing.
Step 1 — A fake API to develop against
Never develop an agent integration against production. Ten lines of
stdlib give us a realistic stand-in. Create fake_api.py:
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
ORDERS = {
"1001": {"id": "1001", "status": "shipped", "total": 49.90,
"customer_email": "jane@example.com"},
"1002": {"id": "1002", "status": "processing", "total": 129.00,
"customer_email": "sam@example.com"},
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
order = ORDERS.get(self.path.removeprefix("/orders/"))
self.send_response(200 if order else 404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(order or {"error": "not found"}).encode())
def log_message(self, *args):
pass # quiet logs
if __name__ == "__main__":
print("fake API on http://127.0.0.1:8765")
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
Run it in one terminal: python fake_api.py. Note the seeded data
includes customer_email — deliberately. Real APIs return more than the
agent should see, and we’ll deal with that properly.
Step 2 — The MCP server: narrow tools, not a proxy
Create api_mcp.py in the same venv as tutorial #1
(pip install "mcp[cli]"):
import json
import os
import urllib.request
from mcp.server.fastmcp import FastMCP
API_BASE = os.environ.get("ORDERS_API", "http://127.0.0.1:8765")
mcp = FastMCP("orders-api")
def _get(path: str) -> dict:
# Allowlist: this server can reach /orders/* and nothing else.
if not path.startswith("/orders/"):
raise ValueError(f"path not allowed: {path}")
with urllib.request.urlopen(f"{API_BASE}{path}", timeout=5) as resp:
return json.load(resp)
@mcp.tool()
def get_order(order_id: str) -> str:
"""Fetch one order's status and total by its numeric ID."""
if not order_id.isdigit():
return "invalid order id: must be numeric"
try:
data = _get(f"/orders/{order_id}")
except Exception:
return f"order {order_id} not found"
data.pop("customer_email", None) # redact PII before the model sees it
return json.dumps(data)
if __name__ == "__main__":
mcp.run()
Three deliberate choices, in order of importance:
- Input validation before the request —
isdigit()kills path injection ("1001/../admin") before it can exist. - Redaction at the boundary — the model never receives
customer_email, so it can’t leak, log, or reason about it. Anything the model sees can end up in a transcript; filter before, not after. - The allowlist in
_get— even if you add ten more tools, this server physically cannot reach other endpoints.
Step 3 — Verify with the Inspector, then a client
mcp dev api_mcp.py
Call get_order with 1001 — you should get status and total, and no
email field. Then try 1001/../admin and watch validation reject it.
Testing the misuse paths is the part everyone skips; for an agent-facing
server, the misuse paths are the product.
Step 4 — Where auth goes (when the real API needs it)
The pattern extends without changing shape: the API key lives in an
environment variable read by the server (os.environ["ORDERS_API_KEY"]),
attached to requests inside _get. The model never sees the credential —
it can’t; it’s not in any prompt or tool result. One process owns the
secret, and rotating it touches one place.
Troubleshooting
URLError: Connection refused from every tool call
The fake API isn’t running, or the MCP client launched your server with
a different environment. Check ORDERS_API and remember: MCP clients
spawn servers as subprocesses that don’t inherit your shell exports
unless configured to.
Works in the Inspector, fails when an agent uses it
Look at how the model formats arguments — agents pass "order 1001"
or 1001.0 where you expect "1001". Be liberal in what you parse
(strip non-digits), strict in what you forward.
Was this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical agentic-AI guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.