This is the lesson that makes debugging possible. When an MCP server misbehaves — and your first one will — the difference between a ten-minute fix and a lost afternoon is whether you can picture where the message is and what should happen to it next.
Three roles, two transports, one handshake. That is the whole architecture.
The three roles
Host
The application the user actually interacts with, and the only component that talks to the model. Claude Desktop is a host. Claude Code is a host. An IDE assistant is a host. An agent you build yourself is a host.
The host owns everything interesting: the conversation, the model calls, the decision about which servers to connect to, and — critically — the permission model. When a tool is about to do something destructive, the host is what asks the user. It is the trust boundary.
Client
A connector living inside the host, maintaining a session with exactly one server. One client, one server, always. A host connected to four servers runs four clients.
That one-to-one rule is deliberate: it keeps sessions isolated so one server cannot see another’s traffic, and it makes a failed connection a contained failure rather than a cascading one.
You will rarely write a client by hand — the host’s framework or SDK provides it.
Server
The program exposing capabilities. This is what you write. A server wraps a database, an API, a filesystem, a SaaS product, or an internal service, and presents it through MCP’s standard primitives.
Servers are intentionally dumb about AI. Your server does not know what model is being used, does not see the conversation, and does not make decisions about when it should be called. It receives “call query_orders with these arguments,” runs it, returns a result. That narrowness is a feature — it makes servers testable in complete isolation, which is the foundation of Lesson 9.
How a request travels
Trace a single user request through the whole system. The user, in Claude Desktop, types: “How many orders shipped yesterday?”
- Host gathers capabilities. At startup it asked each connected server for its tool list. Your
orders-dbserver replied withquery_ordersand its schema. - Host calls the model with the conversation and all tools from all servers, flattened into one list.
- Model requests a tool —
query_orderswith{"status": "shipped", "date": "2026-07-31"}— using the ordinary tool-use mechanism from Lesson 2. - Host routes it. It knows which server owns that tool name and hands the call to that client.
- Client sends a JSON-RPC request over the transport to the server.
- Server executes. Your code runs the SQL, formats the answer, returns it.
- Result travels back through client to host.
- Host sends it to the model as a tool result.
- Model answers: “1,847 orders shipped yesterday.”
Notice that steps 2, 3, 8, and 9 are exactly Lesson 2. MCP occupies steps 4 through 7 — routing and transport. That is genuinely all it adds.
The wire format
MCP speaks JSON-RPC 2.0. A tool call looks like this:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "query_orders",
"arguments": { "status": "shipped", "date": "2026-07-31" }
}
}
And the response:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{ "type": "text", "text": "1847" }]
}
}
The id correlates request and response, which is what allows several calls to be in flight at once. Results carry a content array rather than a bare string because a tool can return text, images, or embedded resources.
Errors are ordinary JSON-RPC errors:
{
"jsonrpc": "2.0",
"id": 7,
"error": { "code": -32602, "message": "Invalid params: 'date' must be ISO-8601" }
}
You will not usually construct these by hand — the SDKs do it — but recognizing them in a log is the difference between reading a trace and guessing at one.
Transports
stdio
The host launches your server as a subprocess and communicates over standard input and output. Newline-delimited JSON in, newline-delimited JSON out.
This is the default for local servers and it has real advantages: no ports, no network configuration, no authentication needed (the process boundary is the boundary), and lifecycle handled for you — the host starts the server when it needs it and kills it on exit.
The one rule that catches everyone: stdout belongs to the protocol. A stray print() in your server injects garbage into the JSON stream and the connection dies with an unhelpful parse error. Log to stderr, always. This is the single most common cause of “my server just doesn’t work” and it costs people hours. Write it on a sticky note now.
Streamable HTTP
The server runs as an independent HTTP service and clients connect over the network, with Server-Sent Events for server-to-client streaming.
Use this when the server must be remote, shared across users, or centrally deployed and updated. The cost is that you now own everything a public service owns: authentication, authorization, TLS, rate limiting, and abuse handling. Lesson 12 covers that properly.
Choosing
Start with stdio. It is simpler in every dimension and it is correct for the majority of servers, which are personal or team tools running on the user’s own machine. Move to HTTP when you have a specific reason: centralized state, shared credentials, or a service you operate for others.
The handshake
Every session opens the same way. Understanding it turns a class of confusing failures into obvious ones.
1. Client sends initialize, declaring its protocol version and what it supports.
2. Server responds with its own version and a capabilities object declaring what it offers:
{
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true }
},
"serverInfo": { "name": "orders-db", "version": "1.2.0" }
}
3. Client sends initialized and the session is live.
The capabilities exchange is why some features work with some servers and not others. A client will not attempt resources/subscribe against a server that did not advertise it. If a feature seems mysteriously unavailable, check what the server actually declared — this is usually the answer.
Version mismatch is the other common failure here. The protocol is dated and evolving; a client and server on incompatible versions fail at the handshake, before any of your code runs. If nothing you wrote seems to be executing, suspect this.
What the server never sees
Worth stating explicitly, because it surprises people and it shapes how you design servers.
Your server does not see the conversation. It does not know what the user asked, what the model is reasoning about, or what other servers exist. It receives a method call with arguments and returns a result.
This has a direct design consequence: tools must be self-contained. A tool cannot rely on the model having “already mentioned” something. Every piece of information the tool needs has to be in its parameters. If you find yourself wanting a tool that depends on prior context, redesign it to take that context as an explicit argument.
A picture worth keeping
┌─────────────────────────────────────────┐
│ HOST (Claude Desktop / IDE / your app)│
│ │
│ conversation ── model calls │
│ permissions ── routing │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │client 1│ │client 2│ │client 3│ │
│ └───┬────┘ └───┬────┘ └───┬────┘ │
└──────┼───────────┼───────────┼──────────┘
│ stdio │ stdio │ HTTP
┌───▼────┐ ┌───▼────┐ ┌───▼─────┐
│ files │ │ git │ │ orders │
│ server │ │ server │ │ server │
└────────┘ └────────┘ └────┬────┘
│
┌─────▼─────┐
│ Postgres │
└───────────┘
When something breaks, walk this diagram and ask where the message stopped. Did the server start at all? Did the handshake complete? Did the tool list arrive? Did the call reach your function? Did your function return valid content? Each of those is a different fix, and knowing which one you are in is most of the work.
Next
Concepts are done. From here it is hands-on: an empty directory, the Python SDK, and a working server you can connect to and call.
Lessons 5 onward publish weekly. Subscribe to get each one when it lands.