The Protocol: Clients, Servers, Transports
Lesson 02 · MCP Servers · ~10 min read
JSON-RPC primer
MCP is, at its core, JSON-RPC 2.0 with a small dictionary of method names layered on
top. JSON-RPC is a deliberately minimal RPC format: every message is a single JSON object containing
a method name, a parameters bag, and (for requests) a correlation id. There are exactly
three shapes — request, response, and notification — and that's the entire surface area.
The natural question is: why not REST? REST gives you one logical endpoint per resource, makes the
client the active party, and assumes short-lived request/response pairs. MCP needs something
different. The client must call into the server (tools/call), the server must call back
into the client (sampling/createMessage, elicitation/create), and either
side must be able to push notifications mid-session. JSON-RPC over a single bidirectional transport
delivers all three for free, with minimal framing overhead. A REST design would have needed at least
three independent endpoint families plus its own pub-sub channel; JSON-RPC collapses that to a single
message stream.
Two more practical wins: JSON-RPC is stream-friendly (responses can arrive in any order, so long-running calls don't block short ones), and it's trivially debuggable — every byte on the wire is human-readable JSON, which you can capture in a log file and replay by eye.
The MCP request/response/notification triad
Every MCP message is one of three JSON-RPC shapes. A request has a method, optional
params, and a unique id — the sender expects a matching response. A response
echoes the same id and carries either a result field (success) or an
error field (failure with a code and message). A notification looks like
a request but has no id: it is fire-and-forget, the recipient must not reply,
and the sender doesn't track it.
The clearest way to see the request/response pair is side-by-side. Here is a single
tools/call round-trip for a hypothetical add tool:
| Request (client → server) | Response (server → client) |
|---|---|
|
|
Notifications carry state changes neither side needs to confirm. The server emits
notifications/tools/list_changed when its tool catalog mutates, and the client emits
notifications/initialized right after the handshake completes. Because there is no
id, there is also no failure path — if a notification is lost, the protocol does not
detect it. That's intentional: notifications are hints, not commands.
Connection lifecycle
Every MCP session moves through four phases in strict order: Connect → Initialize → Operate → Close. Skipping or reordering phases is the single most common protocol bug.
Connect is transport-specific: the client either spawns the server as a subprocess (stdio) or opens an HTTP connection to a known URL (Streamable HTTP). At this point neither side has spoken MCP yet — only the transport is wired up.
Initialize is where the protocol begins. The client sends an initialize
request containing its protocol version and the capabilities it supports (e.g. sampling,
roots, elicitation). The server replies with its own capabilities
(tools, resources, prompts, logging) and a
server-info block. The client then sends a notifications/initialized notification to
confirm — only after this can normal operation begin.
Operate is the long-lived steady state. The client issues tools/list,
tools/call, resources/read, prompts/get, and similar
requests; the server may issue sampling/createMessage or elicitation/create
back into the client. Either side may emit notifications: notifications/tools/list_changed,
notifications/resources/updated, log messages, progress updates.
Close ends the session. Ideally the client sends a clean shutdown and closes the transport, but either side may also abort — the transport drops, in-flight requests are abandoned, and the other side simply observes the disconnect.
Capability negotiation
The initialize exchange is more than a version check — it is a capability contract.
Each side advertises only what it will support for the rest of the session, and the other side must
respect that advertisement. The server lists its primitives (does it expose tools? resources?
prompts? does it emit logs?); the client lists which back-channels it can service.
The most consequential capability is sampling. If a server advertises
sampling in its initialize response, it's signalling that it expects to call
back into the client's LLM mid-session — for example, to summarize a long document it just fetched.
A client that hasn't advertised matching sampling support must refuse those calls. This
explicit handshake is what lets new capabilities ship without breaking older clients: nothing happens
unless both sides opted in.
Transports
The same JSON-RPC message flow runs over multiple transports. MCP defines three; you will use the first one for every lesson in this course, and the last one in production.
stdio
The client spawns the server as a subprocess and speaks JSON-RPC over its stdin and
stdout. Framing is newline-delimited JSON: one JSON object per line,
with no length prefix and no other punctuation. The client owns the server's lifecycle — when the
client process exits, the server is killed too. There is no auth (the OS process boundary is
the auth) and no network surface. This is by far the simplest transport and the one you should reach
for during local development; if your server runs on the same machine as the client, stdio is
almost always the right answer.
HTTP + SSE (legacy)
Pre-2025, the remote transport used two endpoints: one HTTP POST endpoint for client-to-server requests, and a parallel Server-Sent Events stream for server-to-client messages. This worked but introduced an awkward split — the server had to correlate the two halves itself, and proxies that didn't understand SSE would silently break things. You will see this in older servers and tutorials, but you should not build new servers against it.
Streamable HTTP
The current remote transport is Streamable HTTP: a single HTTP endpoint that may respond with either a normal JSON body or an SSE stream, decided by the server per request. This is much friendlier to load balancers and corporate proxies, and it removes the bookkeeping of matching two endpoints together. As of 2026 it is the default for any new remote MCP server, and the lessons in Module 3 build directly on it.
Errors and retry semantics
MCP errors are standard JSON-RPC error objects — a numeric code, a human-readable
message, and optional data. JSON-RPC reserves codes -32700
through -32603 for protocol-level problems (parse errors, invalid requests, method not
found, invalid params, internal errors); MCP layers application-level codes on top, returned inside
the same error object. Importantly, when a tools/call fails because the
tool itself errored (rather than the protocol), the response is still a normal
result with isError: true — the JSON-RPC error field is
reserved for protocol failures, not tool failures.
Common pitfalls
- Sending requests before
initializecompletes. Until the client has sentnotifications/initialized, the server is not obliged to handle anything else — and well-behaved servers will reject prematuretools/listortools/callwith an error. - Ignoring server-advertised capabilities. Calling
sampling/createMessagewhen the client never advertisedsamplingsupport, or asking forresources/readagainst a server that didn't advertiseresources, is a protocol violation. Always gate calls on the negotiated capability set. - Mis-framing on stdio. Extra whitespace, partial lines, or accidental
print()calls tostdoutfrom inside your server code can break dumb line-oriented parsers on the client side. Reservestdoutfor JSON-RPC only and log tostderr.
References
- MCP specification — lifecycle section: the canonical description of
initialize, capability negotiation, and shutdown. - JSON-RPC 2.0 specification: the underlying RPC format MCP is built on — short and worth reading end to end.
- MCP Inspector: the official tool for poking at servers, watching the wire traffic, and debugging handshake issues.