Chapter 8 of 22 · intermediate
MCP servers and the Model Context Protocol
What this session covers
Last week you gave a model a dispatch dict of Python functions and let it loop.
That works right up until you want to reuse those tools. OpenAI wants function
schemas in one shape, Gemini wants FunctionDeclarations in another, the next
framework wants a third, and the tool underneath — read a file, query a
database, hit an API — is the same every time. So you rewrite the same wiring
for every model you support. Multiply that by every tool and the integration
surface eats the project.
The Model Context Protocol is the attempt to make that a solved problem. It's a small standard from Anthropic for how a model's runtime discovers and calls external capabilities. An MCP server is a process that exposes tools — and optionally resources and prompts — behind a fixed interface. An MCP client is whatever wraps the model: it connects to servers, discovers what they offer, and invokes it. The transport is JSON-RPC, over stdio for a local process or HTTP for a remote one. Write a tool once as an MCP server and any MCP-speaking client can use it, with any model behind it.
This hour we build both sides from scratch, no SDK, so you can watch the protocol on the wire. It's less code than you'd guess, because MCP comes down to JSON-RPC messages with a handshake and three verbs.
OpenAI
The teaching script plays both roles in one file: run it and it spawns a copy of itself as the server, then talks to it. Start with the server. It's a tiny weather-station network with two tools. It reads JSON-RPC requests line by line from stdin and writes responses to stdout — the stdio transport every local MCP server speaks.
# A minimal MCP server: a weather-station network exposing two tools. It reads
# JSON-RPC requests line by line from stdin and writes responses to stdout.
# Nothing here mentions a model — that separation is the whole point of MCP.
_STATIONS = {
"PDX-01": {"name": "Portland", "temp_c": 14.2, "humidity": 71},
"SFO-02": {"name": "San Francisco", "temp_c": 17.5, "humidity": 65},
"PHX-03": {"name": "Phoenix", "temp_c": 33.6, "humidity": 12},
}
_TOOLS = [
{"name": "list_stations",
"description": "List every weather station id and name. Call this first.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_reading",
"description": "Latest reading for one station by id (temp °C, humidity %).",
"inputSchema": {"type": "object",
"properties": {"station_id": {"type": "string"}},
"required": ["station_id"]}},
]
def _serve() -> None:
def call(name, args):
if name == "list_stations":
return {"stations": [{"id": k, "name": v["name"]} for k, v in _STATIONS.items()]}
if name == "get_reading":
s = _STATIONS.get(str(args.get("station_id", "")).upper())
return s or {"error": "unknown station"}
return {"error": f"unknown tool {name}"}
for line in sys.stdin:
if not line.strip():
continue
msg = json.loads(line)
mid = msg.get("id")
if mid is None: # notification, no reply
continue
method, params = msg.get("method"), msg.get("params") or {}
if method == "initialize":
result = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
"serverInfo": {"name": "weather", "version": "1.0"}}
elif method == "tools/list":
result = {"tools": _TOOLS}
elif method == "tools/call":
payload = call(params.get("name"), params.get("arguments") or {})
result = {"content": [{"type": "text", "text": json.dumps(payload)}]}
else:
result = {}
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result}) + "\n")
sys.stdout.flush()
Notice what isn't there: any mention of a model. The server declares its tools
with tools/list and runs them with tools/call, and it behaves exactly the
same whether the caller is GPT, Gemini, an IDE, or a shell script. That
separation is the entire point.
The client spawns the server and does the initialize handshake — version plus
capabilities, then an initialized notification — and from there it's request,
then read the matching response.
# The client side: spawn the server, do the initialize handshake, then send
# requests and read the matching responses. This is all MCP is on the wire —
# JSON-RPC objects, one per line.
class MCP:
def __init__(self):
self.p = subprocess.Popen([sys.executable, __file__, "serve"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1)
self.n = 0
self._req("initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "demo"}})
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
def _send(self, m):
self.p.stdin.write(json.dumps(m) + "\n")
self.p.stdin.flush()
def _req(self, method, params):
self.n += 1
self._send({"jsonrpc": "2.0", "id": self.n, "method": method, "params": params})
while True:
msg = json.loads(self.p.stdout.readline())
if msg.get("id") == self.n:
return msg["result"]
def list_tools(self):
return self._req("tools/list", {})["tools"]
def call_tool(self, name, args):
blocks = self._req("tools/call", {"name": name, "arguments": args})["content"]
return "".join(b.get("text", "") for b in blocks)
def close(self):
self.p.stdin.close()
self.p.wait()
Now the part that touches the model. MCP describes tool inputs with plain JSON Schema, which is the same thing OpenAI's function tools want, so the translation is close to a rename. From there it's last week's bounded loop, except every call gets dispatched over MCP instead of to a local function.
# Translate MCP tools into OpenAI function tools, then run the ordinary bounded
# loop — except every tool call goes over MCP, not to a local function.
def main():
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
from openai import OpenAI
client = OpenAI()
mcp = MCP()
try:
tools = [{"type": "function", "name": t["name"],
"description": t["description"], "parameters": t["inputSchema"]}
for t in mcp.list_tools()]
question = "Which station is warmest, and what is its humidity?"
input_list = [{"role": "user", "content": question}]
for _ in range(6): # bounded — never ship an open loop
response = client.responses.create(
model="gpt-5.4-nano",
instructions="Answer using the weather tools. Base numbers on tool results.",
input=input_list, tools=tools,
)
calls = [i for i in response.output if i.type == "function_call"]
if not calls:
break
input_list += response.output
for c in calls:
out = mcp.call_tool(c.name, json.loads(c.arguments))
print(f"tool: {c.name}({c.arguments}) -> {out}")
input_list.append({"type": "function_call_output",
"call_id": c.call_id, "output": out})
print("\n" + response.output_text)
finally:
mcp.close()
The model never learns it's using MCP. It sees function tools, emits calls, reads results. The client is the only piece that speaks both languages, and that's what makes the tool portable.
Gemini
Point a different model at the same server and nothing on the server side
moves. The server and the client are byte-for-byte the ones from the OpenAI
script, and only the translation layer changes: MCP tools become Gemini
FunctionDeclarations, and tool results go back as function-response parts.
# The only real difference from the OpenAI script: MCP tools become Gemini
# FunctionDeclarations, and tool results go back as function-response parts.
def main():
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
from google import genai
from google.genai import types
_T = {"string": types.Type.STRING, "object": types.Type.OBJECT}
def to_schema(js):
props = {k: types.Schema(type=_T.get(v.get("type"), types.Type.STRING))
for k, v in (js.get("properties") or {}).items()}
return types.Schema(type=types.Type.OBJECT, properties=props,
required=js.get("required") or None)
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
mcp = MCP()
try:
decls = [types.FunctionDeclaration(name=t["name"], description=t["description"],
parameters=to_schema(t["inputSchema"]))
for t in mcp.list_tools()]
config = types.GenerateContentConfig(
system_instruction="Answer using the weather tools. Base numbers on tool results.",
tools=[types.Tool(function_declarations=decls)])
question = "Which station is warmest, and what is its humidity?"
contents = [types.Content(role="user", parts=[types.Part(text=question)])]
for _ in range(6): # bounded — never ship an open loop
response = client.models.generate_content(
model="gemini-3.1-flash-lite", contents=contents, config=config)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
out = mcp.call_tool(fc.name, dict(fc.args))
print(f"tool: {fc.name}({dict(fc.args)}) -> {out}")
parts.append(types.Part.from_function_response(name=fc.name, response={"result": out}))
contents.append(types.Content(role="user", parts=parts))
print("\n" + (response.text or ""))
finally:
mcp.close()
That's the promise made concrete. One server, two models, zero changes to the thing that actually does the work. In a real client, this translation is where the SDK earns its keep: it maps MCP schemas into whatever tool format the model on the other end expects, and you never write it by hand.
Put it to work
Three docker-compose apps under code/showcase/<slug>/, same drill: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000.
Each one builds a real MCP server against the same from-scratch client, and each
demonstrates one of MCP's three primitives. Together they cover the server's whole
vocabulary: tools do things, resources carry context, prompts hold instructions.
Showcase 1 — MCP tools
The weather-station server, now a standalone process the backend talks to. Ask
"which station is warmest and how does its humidity compare to Seattle" and the
model discovers the stations, reads the ones it needs, and answers, every call
crossing the stdio transport as tools/call. Flip PROVIDER and the same
server answers through the other model. This is the primitive from the teaching
script, pulled into its own file so you can see it's genuinely separable.
Showcase 2 — MCP resources
Resources aren't actions. They're read-only data the client pulls into context
— files, records, docs, each with a URI. This server is a support knowledge
base: shipping, returns, warranty, and accounts docs behind doc:// URIs. The
backend lists them with resources/list, reads them with resources/read, and
grounds the model in that text. Ask "I opened it three days ago, can I get a
cash refund" and it answers from the returns policy — store credit only, inside
fourteen days — and when you ask something the docs don't cover, it says so
instead of inventing an answer.
Showcase 3 — MCP prompts
The third primitive, and the one people forget. A prompt is a template the
server owns. Paste a code snippet and the backend calls
prompts/get("code_review"); the server wraps its own reviewing instructions
around your code and hands back ready-to-send messages. The reviewing expertise
lives with the server, versioned alongside the tools it belongs to. Improve the
prompt there and every client that fetches it improves at once, no redeploy.
That's how a tool provider ships the optimized prompt right next to the tool.
All three run the same backend/main.py, dispatching on PROVIDER, so a Gemini
run is one environment variable away. And all three share one mcp_client.py,
which is the proof that the client is generic and the servers are the interesting
part.
Run it
The README in this folder lists the Python version, the install line, the two
environment variables, and the exact commands for the standalone scripts and
each showcase. Keys come from the untracked .env at the course root, same as
every other week. The MCP client and servers are pure standard library —
json and subprocess — so the only third-party installs are the model SDKs.
Takeaways
Strip away the branding and MCP is JSON-RPC with a handshake and three verbs:
tools/* for actions, resources/* for context, prompts/* for instructions.
Function calling defines the format of a single tool call. MCP defines the whole
lifecycle — discovery, invocation, results, errors — across many tools and many
servers, and it doesn't care which model is on the other end. That's why an
agent can connect to a database server, a filesystem server, and a browser
server at once and reason across the union, and why the ecosystem already ships
servers for GitHub, Slack, Postgres, and the rest: build the tool once, publish
it, and every MCP client can reach it. You've now seen the messages those
clients send — Claude Desktop, your IDE, and this blog's own /mcp endpoint all
speak exactly what you just wrote by hand. Next week the tools step aside and the
data takes over: embeddings, and the road to retrieval.