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]", which installs SDK 2.x):
import json
import os
import re
import urllib.error
import urllib.request
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
API_BASE = os.environ.get("ORDERS_API", "http://127.0.0.1:8765")
ORDER_ID = re.compile(r"[0-9]{1,10}") # ASCII digits only, bounded length
mcp = MCPServer("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, e.g. "1001"."""
if not ORDER_ID.fullmatch(order_id):
raise ToolError("invalid order id: send ASCII digits only, like '1001'")
try:
data = _get(f"/orders/{order_id}")
except urllib.error.HTTPError as e:
if e.code == 404:
return f"order {order_id} not found"
raise ToolError(f"orders API returned HTTP {e.code}")
except urllib.error.URLError:
raise ToolError("orders API is unreachable; try again later")
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. The
ORDER_IDpattern stops path injection ("1001/../admin") before a URL is ever built. Use a regex rather thanstr.isdigit():isdigit()also accepts Unicode digits such as"١٠٠١"(Arabic-Indic) or"1001"(full-width), and even"²". An allowlist should spell out exactly what it allows. - 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.
Note how errors are reported. A ToolError comes back to the model as a
tool result marked as an error, with your message, so the model can correct
itself (“send digits only”). Since mcp 2.1, any other exception reaches
the model only as Error executing tool get_order, and the details stay in
your server log. That is a sensible default for an API wrapper: stack
traces and internal URLs should not end up in a transcript. A missing
order is a normal answer, not an error, so it is returned as plain text.
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.
So don’t leave them as a manual check. The SDK’s Client can talk to your
server object in memory, with no subprocess and no port, so the misuse tests
fit in one short script. Create test_api_mcp.py next to api_mcp.py:
import asyncio
import json
from mcp import Client
from api_mcp import mcp # the MCPServer object, tested in-memory
async def main() -> None:
async with Client(mcp) as client:
# Happy path: status and total come back, the email does not.
result = await client.call_tool("get_order", {"order_id": "1001"})
order = json.loads(result.content[0].text)
assert order["status"] == "shipped"
assert "customer_email" not in order, "PII leaked!"
# Misuse paths: every one must be rejected before any HTTP request.
for bad in ["1001/../admin", "١٠٠١", "1001", "1001?x=1", ""]:
result = await client.call_tool("get_order", {"order_id": bad})
assert result.is_error, f"accepted bad id {bad!r}"
assert "invalid order id" in result.content[0].text, bad
print("all checks passed")
asyncio.run(main())
With the fake API running, python test_api_mcp.py prints
all checks passed. Swap the regex back to order_id.isdigit() and the
script fails on "١٠٠١". That one-line change is easy to miss in review,
which is why the test exists. Then connect a real client the same way as in
tutorial #1, for example
claude mcp add orders-api -- /full/path/to/.venv/bin/python /full/path/to/api_mcp.py.
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
Every call returns “orders API is unreachable”
That is the URLError branch: the connection to ORDERS_API was
refused. Either 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 (claude mcp add -e ORDERS_API=...).
Catch this case explicitly. An earlier version of this tutorial used a
bare except Exception and reported a down API as “order not found”,
which sends the model looking for the wrong problem.
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
You have SDK 2.x with 1.x code. Use from mcp.server import MCPServer
and MCPServer("orders-api"). ToolError lives in
mcp.server.mcpserver.exceptions. To stay on 1.x, install
"mcp[cli]<2". See migrating to the 2026 spec.
The model only sees Error executing tool get_order
Since mcp 2.1, the text of an unexpected exception stays on the server.
The full error is in your server’s stderr log. If the model should read
the message and act on it, raise ToolError("...") instead.
Works in the Inspector, fails when an agent uses it
Look at how the model formats arguments. Agents pass "order 1001",
1001.0, or the number 1001 where you expect the string "1001".
A number is rejected before your code runs
(Input should be a valid string). Don’t “fix” this by stripping
non-digits: "1001.0" becomes "10010", which is a different order.
Stay strict, and make the error say what you want, as the ToolError
above does. A clear error is usually enough for the model to retry with
the right format. The example in the docstring (e.g. "1001") also helps.
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.