Lesson 2 · Phase 1 · The Protocol

MCP: JSON-RPC + 3 Method Names

The Model Context Protocol demystified: initialize, tools/list, tools/call. How an agent discovers what a server can do and executes it — the same handshake used by Claude, ChatGPT and every MCP client.

What you'll learn

  • Learn the three MCP methods and the version handshake
  • Write a tool descriptor with an inputSchema the LLM can read
  • Build a complete MCP server by hand in plain Python

The whole protocol is three verbs

Last lesson you built a JSON-RPC server where method could be anything. The Model Context Protocol simply fixes the vocabulary: every MCP server in the world answers initialize, tools/list and tools/call. That's why one client — Claude, ChatGPT, your IDE — can talk to any server it has never seen before.

Step through a real session

These are the exact request/response pairs printed by the notebook's client loop. Use Next / Prev to walk the handshake, the discovery, and the call.

1 · Handshake

→ client sends

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {}
}

← server replies

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "serverInfo": {
      "name": "MyServer",
      "version": "1.0"
    }
  }
}

The client and server agree on a protocol version and introduce themselves. It happens once, when the connection opens — before any tool is mentioned.

step 1 / 3

The tool descriptor

A tool descriptor is the contract the LLM reads. The description tells the model when to use the tool; the inputSchema tells it how:

TOOLS = [
    {
        "name": "add",                                                   # matches FUNCTIONS["add"]
        "description": "Add two numbers together and return the sum.",   # the LLM reads this
        "inputSchema": {
            "type": "object",                        # arguments always arrive as a dict
            "properties": {"a": {"type": "number"},  # argument a -> a number
                           "b": {"type": "number"}}, # argument b -> a number
            "required": ["a", "b"],                  # both mandatory
        },
    },
    {
        "name": "shout",
        "description": "Return the given text in capital letters with an exclamation mark.",
        "inputSchema": {
            "type": "object",
            "properties": {"text": {"type": "string"}},
            "required": ["text"],
        },
    },
]

The server: same shape, three known methods

Compare this with lesson 1's handle() — it is the same function with the method names pinned down:

def mcp_server(req):
    m, p, rid = req["method"], req.get("params", {}), req["id"]

    if m == "initialize":
        result = {"protocolVersion": "2024-11-05",
                  "serverInfo": {"name": "MyServer", "version": "1.0"}}

    elif m == "tools/list":
        result = {"tools": TOOLS}                       # <- hand back the catalogue

    elif m == "tools/call":
        out = FUNCTIONS[p["name"]](**p["arguments"])    # <- run the real function
        result = {"content": [{"type": "text", "text": str(out)}]}   # always a list of blocks

    return {"jsonrpc": "2.0", "id": rid, "result": result}

You have now written a complete MCP server in plain Python — no framework, no SDK. Everything a framework adds from here is convenience, which is exactly what the next lesson is about.