hubagenticai

Tutorials Intermediate · 2/12

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.

updated 2026-10-08 ⏱ 45 min

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:

  1. Input validation before the request. The ORDER_ID pattern stops path injection ("1001/../admin") before a URL is ever built. Use a regex rather than str.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.
  2. 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.
  3. 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.

newsletter

One practical agentic-AI guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.

Quick Jump: