CourseModel Context Protocol · Module 2: The Protocol and Its Transports · part 13 of 83
Part 13 · Module 2: The Protocol and Its Transports

Topic 5: Module 2 lab

5 min read·22 Sept 2026

The Lab combines the module into one script. It starts its own HTTP server on port 8024, checks each behavior from the module, measures a little, prints a pass/fail report, and shuts the server down. It uses the raw driver for the wire-level checks and the SDK Client for the rest, across both protocol eras.

python
"""Module 2 Lab: one script that exercises the notes server across both transports and both eras.

It starts its own HTTP server on port 8024, runs every check, prints a report, and stops the server.
"""
from __future__ import annotations

import contextlib
import io
import json
import os
import statistics
import subprocess
import sys
import time

import anyio
import httpx2

from mcp import Client, StdioServerParameters
from mcp.types import ResourceTemplateReference

from m02_wire import StdioWire, meta

PORT = 8024
URL = f"http://127.0.0.1:{PORT}/mcp"
STDIO = StdioServerParameters(command=sys.executable, args=["examples/m02_server.py"], env={"PYTHONPATH": "."})
report: list[tuple[str, bool, str]] = []


def check(name: str, ok: bool, detail: str) -> None:
    report.append((name, ok, detail))


def start_http_server() -> subprocess.Popen[bytes]:
    env = {**os.environ, "PYTHONPATH": ".", "NOTES_TRANSPORT": "streamable-http", "NOTES_PORT": str(PORT)}
    proc = subprocess.Popen([sys.executable, "examples/m02_server.py"], env=env, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
    for _ in range(100):  # wait until the port answers
        try:
            httpx2.post(URL, content=b"{}", headers={"Content-Type": "application/json"})
            return proc
        except httpx2.TransportError:
            time.sleep(0.1)
    raise RuntimeError("HTTP server did not start")


def raw_wire_checks() -> None:
    """Hand-written JSON-RPC over stdio: discovery, the envelope rule, and stdio line sizes."""
    with contextlib.redirect_stdout(io.StringIO()):  # keep the lab report short
        wire = StdioWire(sys.executable, "examples/m02_server.py")
        discovered = wire.request("server/discover")
        wire.send({"jsonrpc": "2.0", "id": 50, "method": "tools/list",
                   "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}})
        missing = wire.receive()
        request = {"jsonrpc": "2.0", "id": 51, "method": "tools/call",
                   "params": {"name": "search_notes", "arguments": {"query": "sleep memory", "limit": 3}, "_meta": meta()}}
        wire.send(request)
        response_line = wire.lines.get(timeout=5)  # the raw line, to count its bytes
        wire.close()
    check("server/discover over stdio", discovered["result"]["supportedVersions"] == ["2026-07-28"],
          f"supportedVersions={discovered['result']['supportedVersions']}")
    check("missing clientCapabilities rejected", missing["error"]["code"] == -32602, f"code {missing['error']['code']}")
    sent = len(json.dumps(request, separators=(",", ":")).encode()) + 1  # +1 for the newline
    received = len(response_line.encode()) + 1
    check("stdio bytes for one search_notes call", True, f"{sent} sent + {received} received = {sent + received}")


async def sdk_checks() -> None:
    async with Client(URL) as client:
        check("modern HTTP negotiation", client.protocol_version == "2026-07-28", client.protocol_version)
        created = await client.call_tool("start_reading_list", {"name": "Lab"})
        list_id = created.structured_content["list_id"]
        await client.call_tool("add_to_reading_list", {"list_id": list_id, "note_id": "sleep-and-memory"})
    async with Client(URL, mode="legacy") as legacy:  # a different connection, a different era
        shown = await legacy.call_tool("show_reading_list", {"list_id": list_id})
        links = [str(b.uri) for b in shown.content if b.type == "resource_link"]
        check("handle survives a new legacy connection", links == ["notes://sleep-and-memory"],
              f"{legacy.protocol_version} sees {links}")
        progress: list[float] = []

        async def on_progress(value: float, total: float | None, message: str | None) -> None:
            progress.append(value)

        await legacy.call_tool("reindex_notes", {"delay_seconds": 0.01}, progress_callback=on_progress)
        await anyio.sleep(0.2)  # progress notifications are delivered beside the response
        check("progress over legacy HTTP", len(progress) == 8, f"{len(progress)} notifications")
    async with Client(URL) as client:
        done = await client.complete(ref=ResourceTemplateReference(type="ref/resource", uri="notes://{note_id}"),
                                     argument={"name": "note_id", "value": "sp"})
        check("completion for notes://{note_id}", done.completion.values == ["spaced-repetition"], str(done.completion.values))


def security_checks() -> None:
    body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
    evil = httpx2.post(URL, content=body, headers={"Content-Type": "application/json", "Origin": "http://evil.example"})
    check("foreign Origin rejected", evil.status_code == 403, f"HTTP {evil.status_code}")
    rebound = httpx2.post(URL, content=body, headers={"Content-Type": "application/json", "Host": f"attacker.example:{PORT}"})
    check("foreign Host rejected", rebound.status_code == 421, f"HTTP {rebound.status_code}")


async def latency(label: str, target: object, calls: int = 30) -> None:
    async with Client(target) as client:
        for _ in range(3):
            await client.call_tool("search_notes", {"query": "sleep memory", "limit": 3})
        samples = []
        for _ in range(calls):
            t0 = time.perf_counter()
            await client.call_tool("search_notes", {"query": "sleep memory", "limit": 3})
            samples.append((time.perf_counter() - t0) * 1000)
    check(f"latency {label}", True, f"median {statistics.median(samples):.1f} ms over {calls} calls")


async def main() -> None:
    server = start_http_server()
    try:
        raw_wire_checks()
        await sdk_checks()
        security_checks()
        await latency("stdio", STDIO)
        await latency("http", URL)
    finally:
        server.terminate()
        server.wait(timeout=10)
    width = max(len(name) for name, _, _ in report)
    for name, ok, detail in report:
        print(f"{'PASS' if ok else 'FAIL'}  {name:{width}}  {detail}")
    print(f"{sum(ok for _, ok, _ in report)}/{len(report)} checks passed")


if __name__ == "__main__":
    anyio.run(main)

Code explained

The rest of this course is yours to keep

This course is bought on its own, once, and stays readable afterwards, including the parts added to it later.