External APIs and MCP Servers as Tools
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.
| Concern | Implementation |
|---|---|
| Auth | Env vars only, never in schema or prompt |
| Timeout | Always set, timeout=8.0 minimum |
| Retry | Exponential backoff on 429 and 5xx |
| Normalise | Strip unused fields before returning to the model |
| Error map | 404 to NOT_FOUND, 429 to RATE_LIMITED, 5xx to UPSTREAM_ERROR |
| Size limit | Cap 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.
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.
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.
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
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:
@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:
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 tools as MCP servers when you need them shared across agents, teams, or platforms.
The tool is private to one agent and nobody else needs to discover it.
"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.
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.
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.
Reply here and it goes straight to Rod. Same as replying to one of his emails.