Lesson 02 The Protocol: Clients, Servers, Transports
Theme:

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)
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": { "a": 2, "b": 3 }
  }
}
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      { "type": "text", "text": "5" }
    ],
    "isError": false
  }
}

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 spawn / dial Initialize capability swap Operate list / call / sample Close graceful or abort client → server: list / call server → client: sampling / elicitation

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.

Idempotency on retry: Tools should be idempotent or refuse to repeat on retry — the client can't tell whether a transient timeout means the tool ran or didn't.

Common pitfalls

Three protocol-level mistakes to avoid:
  1. Sending requests before initialize completes. Until the client has sent notifications/initialized, the server is not obliged to handle anything else — and well-behaved servers will reject premature tools/list or tools/call with an error.
  2. Ignoring server-advertised capabilities. Calling sampling/createMessage when the client never advertised sampling support, or asking for resources/read against a server that didn't advertise resources, is a protocol violation. Always gate calls on the negotiated capability set.
  3. Mis-framing on stdio. Extra whitespace, partial lines, or accidental print() calls to stdout from inside your server code can break dumb line-oriented parsers on the client side. Reserve stdout for JSON-RPC only and log to stderr.

References