MCP Architecture: Clients, Servers, Transports
In article 01: What Is MCP? you installed a server and watched Claude use it. That works until it doesn’t. Your server starts but never shows up in the host. A tool call hangs. The logs say “initialize failed” and mean nothing to you.
To debug any of that, you need to know what the pieces actually say to each other. This article is the wire-level mental model: who connects to whom, what messages flow, and how a connection starts and ends.
One client, one server
Article 01 named three pieces: the host (the AI app), the client (inside the host), and the server (the program you write). The detail that matters for everything else is how clients and servers pair up.
A host can run many servers at once. But it does not multiplex them through a single connection. The host spins up one client per server, and each client holds exactly one dedicated connection to one server.
flowchart TB
HOST["Host: Claude Desktop"]
subgraph clients ["Clients live inside the host"]
CL1["Client A"]
CL2["Client B"]
end
SV1["Filesystem server"]
SV2["GitHub server"]
HOST --> CL1
HOST --> CL2
CL1 <-->|"1:1 connection"| SV1
CL2 <-->|"1:1 connection"| SV2
This 1:1 rule is why a broken GitHub server never takes down your filesystem server: they are separate processes on separate connections. It is also why “is my server even connected?” is the first question to ask when something misbehaves. Each connection is independent and either up or down on its own.
Everything is JSON-RPC
Clients and servers talk in JSON-RPC 2.0, a small, boring, well-specified format. Boring is the point. There is nothing AI-specific about the envelope, so any language with a JSON library can speak it.
There are exactly three kinds of message:
- Request: has an
id, expects a response. “Call this tool.” “List your tools.” - Response: carries the same
id, returns aresultor anerror. - Notification: no
id, no response expected. Fire and forget.
A tool call on the wire looks like this:
// Client -> Server: a request
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "create_issue",
"arguments": { "title": "Bug in checkout", "body": "Repro steps..." }
}
}
// Server -> Client: the matching response
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{ "type": "text", "text": "Created issue #42" }]
}
}
The id is how a client matches a response to the request it sent, which matters because responses can come back out of order. The methods you will see most are tools/list, tools/call, resources/list, and resources/read. They map directly onto the primitives from article 01.
Why JSON-RPC and not REST?
REST assumes a request/response world over HTTP. MCP needs something that works the same way over a local pipe (stdio) as over a network, and that lets the server push messages to the client without being asked, for example to say “my list of tools just changed.”
JSON-RPC is transport-agnostic and has first-class notifications (messages with no response). That makes it a clean fit for a long-lived, two-way connection where either side can speak first. REST would have forced awkward polling for the server-initiated cases.
The connection lifecycle
A connection is not “open the pipe and start calling tools.” There is a handshake first, and skipping it is the cause of most “it won’t connect” bugs.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: initialize (protocol version, my capabilities)
S->>C: initialize result (server capabilities)
C->>S: notifications/initialized
Note over C,S: Connection is now live
C->>S: tools/list
S->>C: list of tools
C->>S: tools/call
S->>C: result
Note over C,S: ...normal operation...
C->>S: close transport
Three steps to reach a working connection:
initialize(request): the client announces the protocol version it speaks and what it supports. The server replies with its own version and capabilities.notifications/initialized(notification): the client confirms it is ready. Only now is the connection live.- Normal operation: list and call tools, read resources, and so on.
If your server never appears in the host, the failure is almost always in step 1 or 2: a version mismatch, a crash before the server answered initialize, or the server writing junk to the pipe before the handshake completed.
Capabilities: each side declares what it can do
During initialize, both sides send a capabilities object. This is how a client learns whether a server offers tools, resources, or prompts, and whether it supports niceties like notifying the client when its tool list changes.
// Part of the server's initialize result
{
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true }
}
}
The rule is simple: do not call what was not advertised. A well-behaved client checks capabilities before sending resources/list. When you build a server, you only declare the capabilities you actually implement, otherwise clients will call methods you do not handle.
Transports: stdio vs HTTP
Article 01 introduced the two transports. Here is the deeper version, because the choice shapes how you build, run, and debug a server.
| stdio | Streamable HTTP | |
|---|---|---|
| Where the server runs | Local, same machine as the host | Remote, reachable over the network |
| How it starts | Host launches it as a subprocess | Already running; client connects to a URL |
| Message channel | stdin and stdout of the process | HTTP requests, with server-sent events for pushes |
| Auth | Inherits your local machine’s trust | You implement it (tokens, OAuth) |
| Best for | Personal tools, desktop apps, your first server | Shared servers, hosted products, multi-user |
The trap with stdio is the most common bug new server authors hit: stdout is the message channel. If you print() a debug line to stdout, you have corrupted the JSON-RPC stream and the client will drop the connection. Log to stderr instead, which the host captures separately.
Why is stdout off-limits for logging on stdio servers?
On a stdio transport, the client reads JSON-RPC messages straight off your server’s stdout, one per line. It expects every line to be valid protocol.
The moment you write print("got here"), that string lands in the same stream between two real messages. The client tries to parse “got here” as JSON-RPC, fails, and usually tears down the connection. The server looks “crashed” even though your logic was fine.
stderr is a separate stream the host does not parse, so it is the safe place for logs. Every MCP SDK defaults its logger to stderr for exactly this reason.
Start with stdio. It needs no networking, no auth, and no deployment, so you can focus on the actual logic. Move to HTTP only when more than one person, or more than one machine, needs the server.
Common beginner mistakes
- Logging to stdout on stdio: any stray
printcorrupts the message stream. Log to stderr. - Skipping the handshake: calling
tools/listbeforeinitializedcompletes. The connection is not live until step 2. - Advertising capabilities you do not implement: declare only what your server actually handles, or clients will call dead methods.
- Assuming responses arrive in order: match on the request
id, never on arrival order. - One server, many concerns: cramming unrelated tools into a single server. Separate connections are independent; use that.
Questions you will face in production
“My server runs fine in the terminal but the host says it failed to connect. Why?”
Almost always a stdout contamination or a crash during initialize. Run the server by hand, send it a raw initialize request, and watch what comes back on stdout. If you see anything that is not a single clean JSON-RPC response, that is your bug. Check that all logging goes to stderr.
“Should I run one big server or several small ones?” Several small ones, split by concern (files, GitHub, internal API). Connections are 1:1 and isolated, so a crash or a slow call in one does not touch the others, and users can enable only what they need.
“stdio or HTTP for an internal tool my team shares?” HTTP. stdio assumes the server lives on the same machine as the host and launches as a subprocess. The moment the server is shared across people or machines, you need a long-running HTTP server with real auth.
What to remember
- A host runs one client per server, each on its own 1:1 connection
- Messages are JSON-RPC 2.0: requests (have an
id), responses (match theid), notifications (noid) - A connection only goes live after
initializeand theinitializednotification - Both sides declare capabilities; do not call what was not advertised
- stdio for local and first servers, HTTP for shared and remote ones
- On stdio, stdout is sacred: log to stderr or you break the protocol
What to study next
You now have the architecture in your head. The natural next step is to build a server and watch this lifecycle happen for real, which is where the rest of this curriculum goes.
In the meantime, two things make the model concrete: re-read the article 01 walkthrough of a GitHub conversation and trace each arrow back to a JSON-RPC message, and open the official spec’s lifecycle page to see the exact field names.
Further reading
- Model Context Protocol: Architecture. The official description of clients, servers, and the connection model.
- MCP spec: Lifecycle. The exact
initializeand capability-negotiation steps, with field names. - MCP spec: Transports. stdio and streamable HTTP, defined precisely.
- JSON-RPC 2.0 specification. The message format MCP is built on. Short and readable.
Where this article comes from. This is a synthesis of the MCP specification and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.