The wire: JSON-RPC, and the two transports
Everything MCP does — every discovery, every tool call, every notification — is JSON-RPC 2.0 messages crossing a transport. This lesson reads the actual bytes. The payoff is practical: once you can read frames, no MCP bug is opaque, because you can always drop below the SDK and look.
Why JSON-RPC and not REST
REST models resources you fetch; MCP needs a conversation — long-lived, stateful, and (crucially) bidirectional, because servers send progress notifications and, in lesson 8, requests of their own. JSON-RPC 2.0 gives exactly that with almost no ceremony: a tiny envelope, request/response correlation by id, and one-way notifications, all transport-agnostic. It is the same choice LSP made, for the same reasons.
The three message shapes
A request carries jsonrpc, an id, a method, and params. Anything with an id demands a reply.
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}A response echoes the id and carries exactly one of result or error. The id is what lets several requests be in flight at once — replies match by id, not by arrival order.
A notification has a method and params but no id — fire-and-forget, no reply expected or permitted. Progress updates, log messages, and "my tool list changed" all travel this way.
That is the entire grammar. Every frame you will ever see in an MCP session is one of these three.
A real exchange
These frames are captured from a live session with the Paper Trail server — a raw client I wrote sending bytes, FastMCP 3.4.5 answering. → is client-to-server, ← the reply:
→ {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "order_status", "arguments": {"order_id": "PT-1041"}}}
← {"jsonrpc": "2.0", "id": 3, "result": {
"content": [{"type": "text",
"text": "{\"status\":\"shipped\",\"carrier\":\"Delhivery\",\"eta\":\"2026-08-05\"}"}],
"structuredContent": {"status": "shipped", "carrier": "Delhivery", "eta": "2026-08-05"},
"isError": false}}Notice the result's anatomy: a content array (text a model can read), a structuredContent object (typed data a program can use without re-parsing), and isError. Now the same tool called with an order that does not exist:
← {"jsonrpc": "2.0", "id": 4, "result": {
"content": [{"type": "text",
"text": "Error calling tool 'order_status': no such order: PT-9999"}],
"isError": true}}Read that carefully, because it encodes a distinction people miss for months: a tool failure is a successful protocol exchange. The JSON-RPC layer returned a result, not an error — the protocol worked; the tool failed, flagged by isError: true, with the explanation as text the model gets to read and react to. JSON-RPC error objects are reserved for protocol-level breakage: malformed frames, unknown methods, a nonexistent tool name. The design is deliberate — a model that can read "no such order: PT-9999" can ask the user for the right id, whereas a transport error would just kill the turn.
Transport one: stdio
For local servers, the host spawns the server as a subprocess and the messages flow over its standard streams — one JSON object per line, client-to-server on stdin, server-to-client on stdout. No network, no ports, no TLS; process isolation is the security boundary, and latency is a pipe write.
The contract has a sharp edge: stdout belongs to the protocol. Anything else the server writes there — a banner, a stray debug line from a dependency — lands in the middle of the frame stream. Diagnostics go to stderr, which hosts capture into logs. Lesson 9 measures how modern SDKs shield you here and where the shield stops.
Transport two: streamable HTTP
Remote servers speak streamable HTTP: the client POSTs each message to a single endpoint, and the response either returns JSON directly or upgrades to a server-sent event stream when the server needs to push — progress during a slow call, notifications, its own requests. One endpoint, ordinary infrastructure, resumable streams, and standard HTTP auth in front.
You will also meet HTTP+SSE, the original remote transport with a separate always-open event stream. It is deprecated — superseded in the March 2025 spec revision — and survives only in older servers and tutorials. Recognise it; build on streamable HTTP.
Choosing is mostly decided for you: your machine, your files, one user → stdio; shared service, many users, real auth → streamable HTTP. The protocol above is byte-identical, which is why every lesson from here works on either.
Try this: re-read the error frame above and say precisely why isError: true arrives inside a result. If you can explain that to a colleague, the wire holds no more surprises.