ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 2 of 5 30 min

MCP architecture

Trace an MCP session message by message, explain hosts, clients, servers and their primitives, and choose between the stdio and Streamable HTTP transports.

Words in this lessonMCP host · MCP client · MCP resource · JSON-RPC · stdio transport · Streamable HTTP · Capability negotiation
MCP host
The AI application the user works in, such as Claude Desktop or Claude Code, which connects to MCP servers. Like the hotel itself: it serves the guest, keeps the rules and decides which outside services to call.
MCP client
The part of an AI app that manages one connection to one MCP server. Like a hotel concierge's dedicated phone line to one outside business.
MCP resource
Read-only data an MCP server offers at an address, like crumb://menu/today, for an app to load into context. Like a printed menu on the counter: you can read it, but reading it does not place an order.
JSON-RPC
A simple standard for one program to ask another to run a function, using JSON messages. Like a booking system where every request gets a reference number and every reply quotes it.
stdio transport
Running an MCP server as a local child process and exchanging messages over its standard input and output. Like a private intercom between two rooms in the same house.
Streamable HTTP
The MCP transport for servers reached over a network at a single URL, such as /mcp. Like a shop's phone number anyone can call, rather than an intercom inside one house.
Capability negotiation
How an MCP client and server agree on a protocol version and on which features each supports. Like two people agreeing which language to speak and what each can help with before getting down to business.
On this page
  1. The hotel concierge
  2. The three roles, precisely
  3. Following one session, message by message
  4. Opening the hood: JSON-RPC
  5. The newest revision: no handshake at all
  6. Two ways to carry the messages: transports
  7. stdio: a local pipe
  8. Streamable HTTP: over a network
  9. The real code: speaking MCP by hand
  10. Solving Arjun's mystery
  11. Where it breaks
  12. Try it yourself
  13. Common mistakes
  14. Recap
  15. Go deeper

That evening Arjun found a community MCP server for Google Sheets on GitHub. Crumb’s stock lives in a Google Sheet, so this looked perfect: no need to write his own. He followed the README, added it to Claude Desktop’s configuration, and restarted the app.

A small error appeared: the server had disconnected. No tools showed up.

He checked the configuration for typos. He reinstalled the server. He restarted Claude Desktop three more times. Same result, every time. At 9 pm he messaged Priya: “MCP is broken on my machine.”

Her reply came a minute later: “MCP is not broken. Something in a conversation between two programs went wrong. Do you know what they say to each other?”

He did not. He had been treating MCP as a magic plug. Tomorrow, Priya said, they would open it up.

Important

The idea in one sentence: MCP is a conversation between a host app, a client inside it and a server, carried as JSON-RPC messages over a transport (a local pipe called stdio, or Streamable HTTP over a network), in which both sides must agree on a protocol version and capabilities before tools are listed or called: through a handshake in the 2025 versions of MCP, and through version details attached to every request in the newest, stateless version.

The hotel concierge

Priya started with a picture, as usual.

“Think of a big hotel. A guest asks the concierge for a taxi, a dinner table and theatre tickets. The concierge has a separate phone line to each outside business: one to the taxi company, one to the restaurant, one to the theatre. When a call starts, what happens first?”

“They say who they are,” said Arjun. “And… what they can do? ‘Hello, this is the hotel, we’d like to book.’ ‘Hello, this is Sharma Taxis, we do airport runs and city trips.’”

“Then?”

“The concierge asks for specific things. Each request gets a booking reference, so when the answer comes back, they know which request it was for.”

“And who decides whether the guest is allowed to charge a ₹20,000 dinner to their room?”

“The hotel. Not the restaurant.”

Priya mapped it onto MCP:

Hotel MCP At Crumb
The hotel, with its rules and its guest Host Claude Desktop, with Meera and the Claude model
One dedicated phone line Client One connection, managed by the host, to one server
An outside business Server The Google Sheets server, crumb-orders, crumb-inventory
Phone etiquette with booking references JSON-RPC The message format both sides use
The phone network itself Transport stdio (a local pipe) or Streamable HTTP
“We do airport runs and city trips” Capabilities “I offer tools and resources”

The three roles, precisely

The host is the application the user actually sees: Claude Desktop, Claude Code, an IDE, or an agent you wrote. It holds the conversation with the model, decides which servers to start, gathers all their tools for the model, and enforces the rules: asking the user before running a tool, deciding what data can be shared with which server.

A client is the part of the host that manages one connection to one server. If Meera’s Claude Desktop connects to three servers, it runs three clients. Each connection is kept separate, so the Google Sheets server never sees what the crumb-orders server says. That isolation is a deliberate security choice: servers cannot eavesdrop on each other.

A server wraps one system and offers capabilities to whichever client connects. A server can offer three kinds of thing, and the specification is careful about who is in control of each:

Primitive What it is Who decides to use it Crumb example
Tools Actions with inputs, which may change things The model chooses, during a conversation stock_level(product, store)
Resources Read-only data identified by a URI (an address like crumb://menu/today) The application chooses what to load into context Today’s menu, a recipe file
Prompts Reusable message templates with arguments The user picks one, often from a menu or slash command “Draft an allergy reply”

That control column is the key idea. Tools are things the model may do, so hosts guard them with permission prompts. Resources are things the app shows the model. Prompts are shortcuts the person triggers.

The conversation is not one-way. Clients can offer capabilities to servers too. With elicitation, a server can ask the user a question mid-task (“Which store did you mean?”). Earlier versions also offered sampling, where a server borrows the host’s model to generate some text, and roots, where the host tells a server which folders it may work in. The newest revision of the specification deprecates sampling and suggests servers call a model provider directly instead, so expect hosts to support these features to varying degrees. Each one is something the user should stay in control of.

Note

Pause and think. Why is “today’s menu” a better fit as a resource than as a tool, and “check stock at a store” a better fit as a tool?

Show the answer

The menu is read-only reference data with a natural address; the app can load it into context when relevant, or let the user attach it, with no decision from the model needed and no risk of changing anything. Checking stock depends on inputs (which product, which store) chosen in the middle of a conversation, which is exactly what tool calling is for. A rough rule: if the model needs to decide to do it with arguments, make it a tool; if it is a document someone might want to read, make it a resource.

Following one session, message by message

The next morning Priya said, “Let’s speak MCP by hand. No Claude Desktop, no libraries on the client side. Just us and a server.”

They used the small crumb server from the next lesson’s lab: it has an order_status tool, a check_allergens tool, a menu resource and a prompt. Arjun wrote a short Python script that starts the server as a child process and sends it one line of text at a time. Here is every message, exactly as it went over the wire.

One thing to know first. MCP’s specification is revised every so often, and each revision is named by its date. The lab’s server is built with version 1 of the Python SDK, which speaks the 2025-11-25 revision, the one most hosts and tutorials have used. That revision starts every session with a handshake, which is what you are about to see. The newest revision, 2026-07-28, removes the handshake; you will see how, and why, straight after the trace.

Message 1: the client introduces itself (a request).

{"jsonrpc": "2.0", "id": 1, "method": "initialize",
 "params": {"protocolVersion": "2025-11-25",
            "capabilities": {},
            "clientInfo": {"name": "arjun-by-hand", "version": "0.1"}}}

“I speak the 2025-11-25 version of MCP. I’m offering no client capabilities of my own. My name is arjun-by-hand.” Note the "id": 1: that is the booking reference.

Message 2: the server introduces itself (a response).

{"jsonrpc": "2.0", "id": 1,
 "result": {"protocolVersion": "2025-11-25",
            "capabilities": {"experimental": {},
                             "prompts": {"listChanged": false},
                             "resources": {"subscribe": false, "listChanged": false},
                             "tools": {"listChanged": false}},
            "serverInfo": {"name": "crumb", "version": "1.30.0"}}}

Same id: 1, so the client knows this answers its first request. The server agrees on the version and lists what it offers: prompts, resources and tools. The listChanged: false flags say it will not send notices when those lists change, and subscribe: false says clients cannot subscribe to updates of a resource. This exchange is capability negotiation: from now on, neither side will use a feature the other did not declare.

Message 3: the client confirms (a notification).

{"jsonrpc": "2.0", "method": "notifications/initialized"}

No id. A message without an id is a notification: an announcement that expects no reply. “Handshake done, let’s work.”

Message 4: the client calls a tool.

{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "order_status", "arguments": {"order_id": "A1001"}}}

(A real host would first send tools/list to learn what tools exist and pass their descriptions to the model. We skip it here because we already know the tool’s name.)

Message 5: the server answers.

{"jsonrpc": "2.0", "id": 2,
 "result": {"content": [{"type": "text", "text": "Order A1001: Out for delivery (ETA 6:30 pm)."}],
            "structuredContent": {"result": "Order A1001: Out for delivery (ETA 6:30 pm)."},
            "isError": false}}

content is what goes back to the model, as a list of blocks (text here; images and other types are allowed). structuredContent is the same answer in a machine-readable form, for hosts that want it.

Message 6: a tool call that goes wrong. Arjun deliberately left out the order ID:

{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
 "params": {"name": "order_status", "arguments": {}}}
{"jsonrpc": "2.0", "id": 3,
 "result": {"content": [{"type": "text",
            "text": "Error executing tool order_status: 1 validation error for order_statusArguments\norder_id\n  Field required ..."}],
            "isError": true}}

Look carefully: this is still a result, with isError: true. The call worked; the tool failed. That error text goes back to the model, which can read “order_id: Field required”, realise its mistake, and try again with the ID. That is deliberate. Tool errors are information for the model.

Message 7: a message that makes no sense. Arjun sent a method that does not exist, "bake/cake":

{"jsonrpc": "2.0", "id": 4, "error": {"code": -32602, "message": "Invalid request parameters", "data": ""}}

Now the reply has an error field instead of a result. That is a protocol error: the conversation itself broke a rule. It goes to the client program, not to the model.

Here is the whole session as a sequence diagram:

sequenceDiagram
  participant H as Host app
  participant C as MCP client
  participant S as MCP server
  H->>C: Start server "crumb"
  C->>S: initialize (version, client capabilities)
  S-->>C: result (version, server capabilities)
  C->>S: notifications/initialized
  C->>S: tools/list
  S-->>C: tools with names, descriptions, schemas
  C-->>H: tool definitions for the model
  H->>C: model wants order_status A1001
  C->>S: tools/call order_status
  S-->>C: result: Out for delivery
  C-->>H: tool result for the model

Note

Pause and think. A missing argument came back as a result with isError: true, but an unknown method came back as an error. Why have two different kinds of failure?

Show the answer

They are meant for different readers. A tool error (“order not found”, “missing order_id”) is useful information for the model, which can correct itself or explain the problem to the user, so it travels as a normal result. A protocol error (a malformed message, an unknown method) means the client program has a bug or a version mismatch; the model cannot fix that, so it is reported to the code, not the conversation.

Opening the hood: JSON-RPC

The format of those messages is JSON-RPC 2.0, a small, long-established standard for “remote procedure calls” (asking another program to run a function) written as JSON. It has only three message shapes:

  • A request has jsonrpc, an id, a method and usually params. It expects exactly one response.
  • A response repeats the request’s id and carries either a result or an error, never both.
  • A notification has a method but no id, and gets no response.

Because every response carries its request’s id, a client can send several requests without waiting, and match up the answers as they arrive, in any order. MCP did not invent this format. It borrowed a proven one, which is part of why MCP spread quickly: there was little new to learn.

The version in the handshake matters too. The client proposes the newest version it speaks. If the server supports it, the server echoes it back. If not, the server replies with a version it does support, and the client decides whether it can work with that or should disconnect. When Arjun sent a made-up version, 1999-01-01, the server replied with 2025-11-25, its own newest. MCP’s versions are dates because the specification is revised every so often; the dates make it obvious which is newer.

The newest revision: no handshake at all

The handshake has a cost. The server must remember each client’s agreed version and capabilities for as long as the connection lasts. That is easy for a local stdio server with one client. It is awkward for a remote server running as ten copies behind a load balancer (a router that spreads requests across several copies of a service), where request 2 may land on a different copy from request 1, which never saw the handshake.

So the 2026-07-28 revision made MCP stateless: every request carries everything needed to handle it on its own. There is no initialize and no notifications/initialized. Instead, each request includes the protocol version, the client’s capabilities and (usually) its name inside a _meta field:

{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "order_status", "arguments": {"order_id": "A1001"},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {},
                      "io.modelcontextprotocol/clientInfo": {"name": "arjun-by-hand", "version": "0.2"}}}}

A client that wants to know what a server offers before calling anything sends a server/discover request, which every server on this revision must answer with its supported versions and capabilities. That reply can be cached, so it does not need repeating for every request. If a server does not support the version a request names, it rejects it with an error listing the versions it does support, and the client retries.

Capability negotiation has not disappeared; it has moved from “once, at the start” to “on every request”. In hotel terms: instead of introductions at the start of a long phone call, every booking form now carries the hotel’s name and needs at the top, so any clerk at the taxi company can handle any form.

You will meet both styles for a while. Version 2 of the Python SDK speaks the new revision, and when a newer client meets an older server, it tries server/discover first, then falls back to the old handshake. Arjun saw exactly that when he pointed a newer client at the lab server: a warning in the server’s log about an unknown server/discover request, then a normal session.

Two ways to carry the messages: transports

A transport is how the messages physically travel. MCP defines two standard ones.

stdio: a local pipe

With stdio (standard input/output), the host starts the server as a child process on the same computer. Messages from client to server are written to the server’s standard input; messages back are written to its standard output. Each message is one line of JSON. The server’s standard error stream is free for logs.

This is what Claude Desktop does with a command in its config, and what Arjun’s hand-written script did. It is simple, fast and private: nothing listens on the network, and the server lives and dies with the host.

Streamable HTTP: over a network

With Streamable HTTP, the server runs on its own, as a web service, and clients connect to one URL, like http://127.0.0.1:8000/mcp. Each message from the client is an HTTP POST. The server can answer with a single JSON response, or open a stream of server-sent events (SSE, a standard way for a web server to push several messages down one response) for long-running work, progress updates or several messages at once.

When Arjun switched the server to HTTP and sent the same initialize with curl, the reply looked like this:

HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 77c49f15722243228d86e7165e600703

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25", ...}}

The same JSON-RPC message, wrapped in an event stream, plus an mcp-session-id header. In the 2025 revisions, the client sends that id back on every later request, so the server knows which conversation each message belongs to. (On the stateless 2026-07-28 revision, every request carries its own version and capabilities, so a server no longer has to keep session state to understand a request.)

sequenceDiagram
  participant C as Client
  participant S as Server at /mcp
  C->>S: POST initialize (2025 revision)
  S-->>C: result plus Mcp-Session-Id header
  C->>S: POST notifications/initialized
  S-->>C: 202 Accepted
  C->>S: POST tools/call (with session id)
  S-->>C: event stream: progress, then result

Streamable HTTP is what you use for remote servers: a server shared by the whole Crumb team, or one offered by a company like Crumb’s delivery partner. Because anyone on the network could reach it, it needs real authentication; the specification uses OAuth for this. It replaced an earlier “HTTP with SSE” transport in 2025, so older tutorials may show a different setup.

stdio Streamable HTTP
Where the server runs On your machine, started by the host Anywhere: your machine, a server, the cloud
Who can connect Only the host that started it Many clients at once
Authentication Not needed: it runs as you Needed: OAuth or similar
Typical use Personal tools, local files, development Shared team servers, company-provided services
Main pitfall Anything printed to stdout corrupts the protocol Security, sessions, deployment

The real code: speaking MCP by hand

You can reproduce the whole session yourself once you have the lab’s server.py. This script uses nothing but Python’s standard library on the client side.

"""Speak MCP by hand: start the server and exchange raw JSON-RPC lines."""
import json
import subprocess
import sys

server = subprocess.Popen(
    [sys.executable, "server.py"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
)


def send(message: dict) -> None:
    server.stdin.write(json.dumps(message) + "\n")  # one JSON object per line
    server.stdin.flush()


def receive() -> dict:
    return json.loads(server.stdout.readline())


send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {},
    "clientInfo": {"name": "arjun-by-hand", "version": "0.1"},
}})
print(json.dumps(receive(), indent=2))

send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
      "params": {"name": "order_status", "arguments": {"order_id": "A1001"}}})
print(json.dumps(receive(), indent=2))

server.terminate()

Run it with uv run raw.py in the lab folder (the server needs the mcp package installed there). You should see the two responses from the trace above. Notice that receive reads one line from the server’s stdout and assumes it is JSON. Hold on to that thought.

To try the HTTP transport, change the last line of server.py to mcp.run(transport="streamable-http"). The server starts a small web server at http://127.0.0.1:8000/mcp, bound to your own machine only, and you can connect to it with the MCP Inspector from the lab.

Solving Arjun’s mystery

Now Arjun knew what to look for. Claude Desktop keeps a log file per MCP server (on a Mac, under ~/Library/Logs/Claude). In the Google Sheets server’s log, right after launch, he found a JSON parsing error that quoted the line it could not understand: Connected to Google Sheets!. To be sure, he pointed the official Python MCP client at the same server, and it reported the same thing in its own words:

Failed to parse JSONRPC message from server
... Invalid JSON: expected value at line 1 column 1 ... input_value='Connected to Google Sheets!'

The server’s author had added a friendly print("Connected to Google Sheets!") at startup. In a terminal, that is harmless. Over stdio, standard output is the protocol channel. The client read that line expecting JSON-RPC, got plain English, and gave up on the connection.

Why had the author never noticed? Python holds printed text in a buffer when output goes to a pipe, so the stray line can arrive late or not at all, depending on when it is flushed. The bug works on one machine and breaks on another.

The fix was one line, sending the message to standard error instead, print("Connected to Google Sheets!", file=sys.stderr), and he opened a pull request on the project. In the meantime he wrote his own small server for the stock sheet, which you will do in the lab.

Warning

In a stdio MCP server, never write anything except protocol messages to standard output: no print(), no logging configured to stdout, no progress bars from libraries. Send logs to standard error (print(..., file=sys.stderr) or Python’s logging module, which writes to stderr by default). One stray line can silently break every client.

Where it breaks

  • Slow tools. A tool that takes 40 seconds may hit the client’s timeout. Long work should send progress notifications, or start a job and return quickly with a way to check on it.
  • HTTP servers exposed carelessly. A server listening on all network interfaces without authentication lets anyone on the network call your tools. Bind to 127.0.0.1 locally (FastMCP’s default) and add OAuth before sharing.
  • Sessions behind load balancers. On the 2025 revisions, a session id issued by one copy of a server may arrive at another. Use a shared session store or sticky routing, or move to the stateless 2026-07-28 revision, designed to remove this problem.
  • Mixed versions. For a while, hosts, SDKs and servers will speak different revisions. Most newer clients fall back to the older handshake, but check the versions on both sides, and read the error, before reinstalling things.

Try it yourself

Using the raw script, send a tools/list request (id 5) after the handshake and print the result. Then answer:

  1. What does the server send as the description of order_status, and where did that text come from?
  2. What would happen if you sent tools/list before initialize?
Show the answer
  1. The description is the function’s docstring, including the “Args:” section, and the inputSchema comes from the type hints: order_id is a required string. FastMCP builds both (plus an outputSchema) from your function. This is exactly what the model reads when deciding whether to use the tool.
  2. On the 2025 revisions, which the lab server speaks, there is no agreed version or capabilities yet, so a well-behaved server rejects the request or ignores it: every session must begin with initialize and notifications/initialized. On the stateless 2026-07-28 revision the same request would work, because it would carry its own version and capabilities in _meta.

Common mistakes

  • Treating MCP as magic. It is JSON messages. When something fails, read the logs.
  • Confusing tool errors with protocol errors. Return tool problems as results with isError, so the model can recover; protocol errors are for code.
  • Making everything a tool. Read-only documents are often better as resources, and user-triggered templates as prompts.

Recap

  • The host is the app with the model and the rules; it runs one client per server, and servers are isolated from each other.
  • Servers offer tools (model-controlled), resources (application-controlled) and prompts (user-controlled); clients can offer sampling, elicitation and roots.
  • Every message is JSON-RPC 2.0: requests with ids, responses that echo them with a result or an error, and notifications with no id.
  • Both sides agree on version and capabilities (capability negotiation): in the 2025 revisions with a handshake (initialize, the response, notifications/initialized); in the stateless 2026-07-28 revision with version details on every request and an optional server/discover.
  • stdio runs a local child process over stdin and stdout; Streamable HTTP serves remote clients at one URL with sessions and optional streaming.

At lunch, Arjun explained the mystery to Meera, who had heard him grumbling the night before. “Imagine a hotel concierge on the phone to a taxi company. Every call follows a script: say hello, say what you offer, then requests with booking numbers. The Google Sheets server picked up the phone and, before the script started, shouted ‘Connected!’ into it. The concierge thought the line was broken and hung up.”

Meera laughed. “So you fixed it by telling it to whisper?” “To the logs, yes,” he said. “Standard error.” That afternoon Priya handed him a task: build Crumb’s own small MCP server, test it properly, and connect it to Meera’s Claude Desktop and the team’s Claude Code. That is the lab.

Go deeper

Check your understanding

3 quick questions
1. On the 2025-11-25 revision of MCP (the one the lab's SDK speaks), what must happen before a client can call tools/call?
2. A stdio MCP server prints "Connected!" to standard output at startup. What happens?
3. A tool call with a missing argument comes back as a result with isError: true, not as a JSON-RPC error. Why?