MCP Protocol Internals · Lesson 1

The Handshake

How an MCP session is born over Streamable HTTP — every byte, from a real local trace.

The whole protocol is simpler than it looks once you see it on the wire. In this lesson you will drive the opening of a real MCP session by hand with curl, against a local reference server, and be able to name every header, status code, and field. This is the foundation everything else (listing and calling tools, then auth) sits on top of.

The one-sentence mental model

MCP is just JSON-RPC messages passed over a transport. Streamable HTTP is one such transport: the client POSTs a JSON-RPC message to a single URL, and the server answers with either one JSON object or a stream of them. That's the entire trick.spec

1 · The three messages that open a session

Before any real work, MCP requires a fixed handshake.spec It is exactly three messages:

  1. Client → Server: an initialize request — "here's the protocol version I speak and what I can do."
  2. Server → Client: the initialize response — "agreed on that version; here's what I can do, and your session id."
  3. Client → Server: a notifications/initialized notification — "ack, I'm ready." No reply expected.

A JSON-RPC request has an id and expects a reply. A notification has a method but no id, so by definition it gets no response — that's why step 3 returns a bare 202.

Until step 3 lands, neither side should send normal traffic. Skip the handshake and the server rejects you — we'll prove that below.

2 · Step 1 on the wire — initialize

Here is the actual request we sent, with the transport-critical headers called out:

POST /mcp HTTP/1.1 Content-Type: application/json Accept: application/json, text/event-stream ← MUST list BOTH { "jsonrpc": "2.0", "id": 1, ← it's a request → expects a reply "method": "initialize", "params": { "protocolVersion": "2025-11-25", ← latest the client speaks "capabilities": {}, ← client advertises nothing extra "clientInfo": { "name": "curl-explorer", "version": "0.1.0" } } }
Why the dual Accept header matters

The client MUST send Accept: application/json, text/event-stream. It is telling the server: "I can handle a plain JSON reply or a Server-Sent-Events stream — your choice." Forget it and a strict server refuses you. This single header is the pivot of the whole Streamable HTTP design.spec

3 · Step 2 on the wire — the server's reply

Our local server chose to answer not with plain JSON but with an SSE stream. Look at the response headers, then the body:

HTTP/1.1 200 OK content-type: text/event-stream ← server picked the STREAM option mcp-session-id: 45f79374-a164-4ce0-be2a-bb658e8c3756 ← remember this! Transfer-Encoding: chunked ── SSE frames (each event is id:/data: lines) ── id: bc3b0ef1-… data: ← empty priming event (reconnect anchor) event: message id: d8f9e1cc-… data: {"result":{"protocolVersion":"2025-11-25", "capabilities":{"tools":{"listChanged":true},"resources":{…},"prompts":{…}, "logging":{},"completions":{},"tasks":{…}}, "serverInfo":{"name":"mcp-servers/everything","title":"Everything Reference Server","version":"2.0.0"}, "instructions":"# Everything Server – Server Instructions …"}, "jsonrpc":"2.0","id":1} ← same id:1 → answers our request

Three things just happened, and each is worth naming:

SSE in 20 seconds

text/event-stream is a plain-text streaming format: lines like event:, data:, and id:, with a blank line ending each event. One HTTP response can therefore carry many MCP messages over time. Here it carried just one real message (plus an empty priming frame), but the same channel is how a server later streams progress and notifications. The JSON-RPC payload is always the value after data: .

4 · Step 3 on the wire — initialized

Now the client acks. Note the two headers every post-handshake request must carry: the session id and the negotiated protocol version.

POST /mcp HTTP/1.1 Accept: application/json, text/event-stream MCP-Protocol-Version: 2025-11-25 ← required on every later request Mcp-Session-Id: 45f79374-a164-4ce0-be2a-bb658e8c3756 ← echo what the server minted { "jsonrpc": "2.0", "method": "notifications/initialized" } ← no id → no reply ── server answers ── HTTP/1.1 202 Accepted ← empty body, by spec

A 202 with no body is the correct, expected answer to any notification. The server accepted it; there is nothing to return because notifications carry no id. The session is now open for business.

5 · Proof the order matters

Send a real request without a session id and the server rejects it — a clean demonstration that state, not just authentication, is being enforced:

POST /mcp (no Mcp-Session-Id header) { "jsonrpc":"2.0","id":9,"method":"tools/list" } HTTP/1.1 400 Bad Request { "jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}
Spec says MUST — reality may differ

The spec orders servers to validate the Origin header and return 403 on a foreign origin, to block DNS-rebinding attacks against local servers.spec When we sent Origin: http://evil.example.com, this build happily returned 200 and a full session. Lesson: "MUST" in a spec is a requirement on implementers, not a guarantee about the server in front of you. Always verify, never assume the protection is on. We'll return to this in the security lessons.

🔧 Try it yourself (≈ 4 minutes)

The local server is already running on http://127.0.0.1:3001/mcp. Drive the handshake by hand. From this workspace:

# if the server isn't up: bash assets/mcp-curl.sh up # step 1+2+3 in one helper (prints the server's result, stores the session id): bash assets/mcp-curl.sh init # peek at the raw response headers it captured — find mcp-session-id yourself: cat /tmp/mcp-init-headers.txt

Then answer, from what you see: which content-type did the server choose, and what is the session id? Ask me anything that doesn't line up.

Check yourself

Why must the client's Accept header list both application/json and text/event-stream?
The server replied to notifications/initialized with 202 and no body. Why no body?
After the handshake, which two HTTP headers must the client send on every request?
Primary source — read this next

MCP Specification 2025-11-25 — Base / Transports. The single most important page for this course: it defines Streamable HTTP end to end. Read the "Sending Messages to the Server" and "Session Management" sections — you've now seen on the wire everything they describe.