Skip to content

External APIs and MCP Servers as Tools

Before you start

Prerequisite: Lesson 08 (CLI wrappers and in-process functions — the JSON-in/Python-executes/JSON-out loop, and the return-contract discipline from lesson 07 applied to real code). This lesson assumes that vocabulary without redefining it. After this lesson, you can: wrap an external API with the six disciplines that keep it from being the source of a production incident, and explain precisely what MCP standardises versus what it doesn't.

The question this lesson answers

MCP gets talked about as if it's a fundamentally different, more advanced way to give a model tools — a new paradigm sitting apart from "ordinary" API wrappers. It isn't. Both external APIs and MCP servers are the same loop from lesson 08, JSON in, Python executes, JSON out — the only thing that changes between the two is who owns the schema. An API wrapper means you hardcode the tool's schema into a TOOLS array by hand. An MCP server means the server publishes its own schema, and your client discovers it at runtime via list_tools(). That ownership question is the entire decision, and this lesson works through both halves of it against the same Edinburgh scenario.

Paradigm 3: external APIs, the most common paradigm in production

The shift from in-process functions: the call now leaves your process. That means rate limits, auth tokens, HTTP errors, and response shapes you don't control — none of which existed when a function call was just Python calling Python. Design rule that follows directly: wrap the raw response. The model should never see a 200-field JSON blob. Return the five fields it needs to make a decision.

Before writing a single API call, six disciplines belong on the checklist, and the lesson's own claim is blunt about the stakes: every missing item here has caused a production incident somewhere.

ConcernImplementation
AuthEnv vars only, never in schema or prompt
TimeoutAlways set, timeout=8.0 minimum
RetryExponential backoff on 429 and 5xx
NormaliseStrip unused fields before returning to the model
Error map404 to NOT_FOUND, 429 to RATE_LIMITED, 5xx to UPSTREAM_ERROR
Size limitCap the response before injecting it into context

The Edinburgh geocoding and weather chain

Two real calls against Open-Meteo, free and requiring no API key: geocode "Edinburgh Old Town" to latitude and longitude, then fetch current weather for those coordinates, to decide whether tonight's event should use the venue's outdoor terrace.

python
def http_get(url: str, params: dict = None, timeout: float = 8.0,
             max_retries: int = 3) -> dict:
    """GET with exponential backoff. Returns parsed JSON."""
    if params:
        url = url + "?" + urllib.parse.urlencode(params)
    for attempt in range(max_retries + 1):
        try:
            req = urllib.request.Request(url, headers={"Accept": "application/json"})
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except Exception as e:
            if attempt == max_retries:
                raise
            time.sleep(0.5 * (2 ** attempt))

get_current_weather() calls Open-Meteo's forecast endpoint, which returns more than 40 fields, and normalises the response down to five: temperature, wind, precipitation, a human-readable description, and outdoor_suitable — a boolean computed in Python from the WMO weather code, not left to the model to infer from raw numbers. That's the normalisation discipline made concrete: logic that belongs in deterministic code stays in deterministic code.

Terminal
$
python api_lab.py

Run against "Check the current weather and tell us whether the outdoor terrace is a good idea," the model knew to geocode first — the tool description said "Call this FIRST if you only have a location name," and that disambiguation instruction did its job with zero orchestration code. Retries are invisible to the model too: if Open-Meteo returns a 429, http_get backs off and retries without the model ever seeing it. Every byte of raw API noise not returned is a byte not stolen from the model's reasoning.

Paradigm 4: MCP, solving the M×N problem

Here's the arithmetic that names the problem: before MCP, three models each integrating ten tools by hand means thirty custom integrations to maintain. Change one tool's schema, and three separate integrations need updating. Add a fourth model, and all ten integrations get rebuilt. After MCP, three models plus ten MCP servers is thirteen things to maintain — change a tool, update one server; add a model, connect once. Anthropic published MCP in November 2024, and by early 2026 Claude and GitHub Copilot both connect to MCP servers natively — the two adoptions this lesson can point to directly, not a survey of the wider ecosystem.

MCP: three roles, one protocol

Tools are callable functions. Resources are read-only data. Prompts are reusable templates. Three transports carry this traffic: stdio, where the client spawns the server as a subprocess, for local dev and desktop apps; HTTP/SSE, server as an HTTP service, for remote and containerised deployments; and Streamable HTTP, the 2025 bidirectional standard, for high-throughput production.

The Edinburgh venue server

Files changed
mcp_venue_server.py

A FastMCP server exposing three tools — search_venues(min_capacity, requires_vegan), get_venue_details(pub_name), and check_booking_window() — each defined with the @mcp.tool() decorator:

python
@mcp.tool()
def search_venues(min_capacity: int, requires_vegan: bool) -> str:
    """Search Edinburgh venues by minimum capacity and dietary requirements."""
    results = [
        {"name": name, **info}
        for name, info in VENUES.items()
        if info["capacity"] >= min_capacity
        and (not requires_vegan or info["vegan"])
        and info["status"] == "available"
    ]
    return json.dumps({"matches": results, "count": len(results)})

Any MCP-compatible host — Claude Desktop, or the Python client below — connects and immediately has all three tools available, with zero manual schema registration:

python
tool_list = await session.list_tools()
tools = [{"type": "function", "function": {"name": t.name,
         "description": t.description or "", "parameters": t.inputSchema}}
         for t in tool_list.tools]

That list_tools() call is the entire difference from the API wrapper above. In the API section, the TOOLS array was hand-typed. Here, the server is the single source of truth, and the client reads its schema at session start instead.

session.list_tools() replaced schema registration — you never copy-paste a JSON schema between files again. And the transport that feels like magic isn't: stdio is subprocess.run with stdin and stdout. The JSON-RPC 2.0 messages are what MCP actually standardises, not some new networking paradigm underneath them.

Build the engineering rule to apply

Build tools as MCP servers when you need them shared across agents, teams, or platforms.

Build them as plain functions when

The tool is private to one agent and nobody else needs to discover it.

Watch the ownership question, not the label

"Is this MCP or an API wrapper" is really "who owns the schema — me, hardcoded, or the server, published." Everything else about the two paradigms is the same loop.

Quick check — A colleague says MCP is a fundamentally more advanced protocol than a REST API wrapper, with its own execution model. What does this lesson's architecture diagram actually show?

What's still missing, on purpose

This lesson deliberately left two things out. MCP-specific security — auditing a server before connecting to it, sanitising tool output that could carry an injection payload — belongs to lesson 10's security block, not here. And trust boundaries in general, the fifth part of lesson 07's tool definition, stay unbuilt until that same lesson, because delegating to another agent is exactly where a trust boundary gets tested hardest.

Continue to Lesson 10

A2A and trust boundaries: what happens when the "tool" you're calling is another autonomous agent, and the lethal trifecta that makes that delegation dangerous.

Have a question about this lesson?

Reply here and it goes straight to Rod. Same as replying to one of his emails.