Course ES

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.