Model Context Protocol Series — Presentation 02

Transports & The Wire

stdio framing, the deprecated HTTP+SSE, modern Streamable HTTP, sessions, resumability via Last-Event-ID, the security headers your server must check, and one fully-annotated packet trace. Revision 2026-07-28 removed sessions, the GET stream and Last-Event-ID resumption; both eras are covered here, and handshake-era slides are tagged “before 2026-07-28”.

stdio HTTP+SSE (deprecated) Streamable HTTP Sessions Resumability Origin
stdio → Framing → HTTP+SSE → Streamable HTTP → Sessions → Resume → Auth
00

Topics We'll Cover

01

Why MCP Needs More Than One Transport

The protocol is transport-agnostic by design — the JSON-RPC envelopes don't care how they got there. But the practical deployments split cleanly into two worlds with different constraints, and the spec ships first-class transports for each.

Local servers — stdio

Spawned as a child process by the host. No network exposure. No auth. The host's user trust transfers directly. This is how 90% of community servers ship.

Examples: filesystem, git, sqlite, any server you uvx or npx from the command line.

Remote servers — Streamable HTTP

One server, many clients across the public internet. Needs auth (OAuth 2.1), session management, reconnection, multiplexing. Originally HTTP+SSE; superseded by Streamable HTTP in 2025-03-26.

Examples: a hosted Linear server, a SaaS observability server, anything you point at over https://.

Custom transports are allowed

The spec explicitly says implementations MAY add their own transports (named pipes, WebSockets, gRPC) provided they preserve JSON-RPC semantics, message ordering and the lifecycle. But anything custom-built breaks interop with mainstream hosts — in practice, ship one of the two standard transports.

02

stdio — The Default for Local Servers

The host launches the server as a subprocess and pipes stdin/stdout. That's it. Each side reads JSON-RPC messages from one stream and writes to the other.

Framing rules — line-delimited JSON

Two messages on stdio (each one literal line) before 2026-07-28
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
The first line on stdio in 2026-07-28 (no handshake) 2026-07-28
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

The framing rules are unchanged in 2026-07-28. What changed is the first message: there is no initialize; a client may open with any request carrying _meta, or with server/discover, which also tells it whether the subprocess is a legacy (handshake-only) server. The process is no longer a session: unrelated requests may interleave on it.

Source: MCP specification 2026-07-28 — stdio; Discovery; Overview: statelessness (accessed 2026-10-08).

How the host configures stdio servers

The host's config typically maps a name to a command line and an environment. Claude Desktop's claude_desktop_config.json is representative:

claude_desktop_config.json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"],
      "env": { "DEBUG": "1" }
    },
    "sqlite": {
      "command": "uvx",
      "args": ["mcp-server-sqlite", "--db-path", "/tmp/test.db"]
    }
  }
}
Common bug: blocked stdout

If your server writes to stdout faster than the host reads, the OS pipe buffer fills (typically 64 KiB on Linux) and your write() blocks forever. Servers that emit large resource bodies should chunk and yield. The MCP Inspector is invaluable here — it shows the stall as a hung request.

03

HTTP+SSE (Legacy) — What It Did and Why It Died

The original web transport, shipped with 2024-11-05 and deprecated in 2025-03-26. Worth understanding because (a) servers in the wild still use it and (b) it explains the design pressure that produced Streamable HTTP.

Two endpoints, two directions

Client
→
POST /messages
→
Server
Client
←
GET /sse (text/event-stream, long-lived)
←
Server

The first message on /sse was a synthetic endpoint event telling the client which URL to POST to. Every subsequent server-to-client message arrived as an SSE data: event on the same stream.

A snippet of the SSE stream
event: endpoint
data: /messages?session_id=8c8a7b...

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}

Why it died

04

Streamable HTTP — Single Endpoint, Two Modes

Introduced in 2025-03-26. One URL, typically /mcp or /, that accepts both POST and GET (before 2026-07-28; from 2026-07-28 only POST, see slide 06b). The server picks per request whether to respond with a single JSON body or to upgrade to an SSE stream.

MethodBodyServer responseUsed for
POST /mcpOne JSON-RPC request or notification200 with application/json (single response) or 200 with text/event-stream (streamed response + intermediate notifications) or 202 Accepted (notification-only, no body)Every client-initiated message
GET /mcp before 2026-07-28(none)200 with text/event-stream — pure server-to-client channel for unsolicited notifications. 405 Method Not Allowed if the server doesn't support server-initiated streams.Long-lived listening channel. 2026-07-28: removed; a modern-only server answers 405, and subscriptions/listen replaces it.
DELETE /mcp before 2026-07-28(none, with Mcp-Session-Id)204 No Content — terminates the sessionClean session shutdown. 2026-07-28: no sessions; 405.

Why two response modes for one POST?

The same call can need either shape. tools/list takes microseconds and returns a single JSON object. tools/call on a long-running operation may want to emit notifications/progress for two minutes before returning the final tool result. The server signals which mode by setting the Content-Type header on the response.

POST returning a streamed multi-message response (2025-06-18 shape) before 2026-07-28
POST /mcp HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbG...
MCP-Protocol-Version: 2025-06-18
Mcp-Session-Id: 1d31a3f4-...
Accept: application/json, text/event-stream
Content-Type: application/json

{"jsonrpc":"2.0","id":42,"method":"tools/call",
 "params":{"name":"long_query","arguments":{"sql":"..."}}}

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

event: message
id: 1
data: {"jsonrpc":"2.0","method":"notifications/progress",
       "params":{"progressToken":"abc","progress":0.25}}

event: message
id: 2
data: {"jsonrpc":"2.0","method":"notifications/progress",
       "params":{"progressToken":"abc","progress":0.75}}

event: message
id: 3
data: {"jsonrpc":"2.0","id":42,"result":{...}}
The Accept header is mandatory

Clients MUST send Accept: application/json, text/event-stream on every POST. Servers reject any other Accept with 400. This is what tells a stateless reverse-proxy that the response could be either shape and to disable buffering accordingly. (Still true in 2026-07-28.)

In 2026-07-28 the same POST drops Mcp-Session-Id and the SSE id: lines, and adds Mcp-Method: tools/call and Mcp-Name: long_query; the body carries _meta with the protocol version, which must match the MCP-Protocol-Version header.

Source: MCP specification 2026-07-28 — Streamable HTTP; Streamable HTTP: earlier revisions (accessed 2026-10-08).

05

Sessions and the Mcp-Session-Id Header before 2026-07-28

Removed in 2026-07-28

This slide describes protocol versions 2025-03-26 to 2025-11-25. Revision 2026-07-28 removed protocol-level sessions and the Mcp-Session-Id header: a modern-only server ignores the header and never mints one. State that must span calls becomes an explicit, server-minted handle passed as an ordinary tool argument. See slide 06b.

Sessions are optional but standardised. A server that wants to remember client state across requests opts in by returning Mcp-Session-Id on the initialize response.

step 1
POST /mcp { method: "initialize", ... }
step 2
200 OK Mcp-Session-Id: 1d31a3f4-c1d4-4e9c-9e10-...
step 3+
POST /mcp Mcp-Session-Id: 1d31a3f4-... { method: "tools/call", ... }
end
DELETE /mcp Mcp-Session-Id: 1d31a3f4-... → 204

Server-side state semantics

Stateless servers are allowed too

If your server has no per-session state (it's a thin shim over an idempotent HTTP API, say) you can simply not return Mcp-Session-Id at all. Every request is then standalone — the client just won't bother sending one back. This is the easiest deployment shape if your model fits.

06

Resumable Streams — Last-Event-ID in Practice before 2026-07-28

Removed in 2026-07-28

Stream resumption and redelivery (SSE event IDs and Last-Event-ID) were removed. A broken response stream loses the in-flight request; the client MUST re-issue it as a new request with a new ID, and closing the stream is itself the cancellation signal. (2025-11-25 had already made resumption optional for servers.)

SSE has had a built-in resumption mechanism since HTML5: when the browser reconnects to a stream, it sends the last id: it saw in the Last-Event-ID request header. The server then replays everything after that ID. MCP exploits this directly.

Client Server POST /mcp { "id":42, "tools/call" } 200 SSE — data: progress id: 1 data: progress id: 2 network drop after id:2 GET /mcp Last-Event-ID: 2 (reconnect) 200 SSE — data: progress id: 3 (replayed) data: result for id 42 id: 4 Server tracks per-session event log; replays anything after Last-Event-ID. Cap retention to bound memory.
Implementation note

Replay buffers don't need to be unbounded. A reasonable server keeps the last N events (say 1000) per session and replays from there; if Last-Event-ID is older than the buffer, return a fresh stream and let the client re-issue the affected request. The client SHOULD treat any in-flight request as “result unknown” on disconnect and decide whether to retry based on whether the tool was idempotent.

06b

Streamable HTTP in 2026-07-28 — Stateless POSTs and subscriptions/listen 2026-07-28

The transport keeps its name and its single endpoint, but loses everything that tied a client to one server instance. Every JSON-RPC message is its own POST; each request gets either one JSON body or an SSE stream scoped to that request. Any replica behind a plain load balancer can answer any request.

MechanismBefore 2026-07-282026-07-28
SessionMcp-Session-Id from initialize, ended by DELETENone. Explicit handles in tool arguments; header ignored, DELETE → 405
Unsolicited server→client channelGET /mcp SSE streamPOST subscriptions/listen: one long-lived response stream of opted-in change notifications; GET → 405
Server→client requests (sampling, elicitation, roots)JSON-RPC requests on an SSE streamEmbedded in a result with resultType: "input_required"; the client retries (deck 03)
Dropped streamReconnect with Last-Event-ID, server replaysRequest is lost; re-issue with a new ID. Closing the stream cancels the request
LivenesspingRemoved
Routing headersMCP-Protocol-VersionMCP-Protocol-Version + Mcp-Method + Mcp-Name, which must match the body (else 400, -32020 HeaderMismatch)
subscriptions/listen — the replacement for GET /mcp and resources/subscribe
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: subscriptions/listen

{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen","params":{
  "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
           "io.modelcontextprotocol/clientCapabilities":{}},
  "notifications":{"toolsListChanged":true,
                   "resourceSubscriptions":["file:///project/config.json"]}}}

HTTP/1.1 200 OK
Content-Type: text/event-stream

data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged",
       "params":{"_meta":{"io.modelcontextprotocol/subscriptionId":1},
                 "notifications":{"toolsListChanged":true,
                   "resourceSubscriptions":["file:///project/config.json"]}}}

data: {"jsonrpc":"2.0","method":"notifications/resources/updated",
       "params":{"_meta":{"io.modelcontextprotocol/subscriptionId":1},
                 "uri":"file:///project/config.json"}}
Why the spec went stateless

Sessions forced sticky routing or shared session stores, and server-initiated requests on SSE streams needed the same instance to receive the answer. With version and capabilities in every request's _meta, answers to server questions carried back on a retry, and change notifications on an explicit, request-scoped stream, a remote MCP server scales like any stateless HTTP API. The cost: a dropped stream now means re-sending the request, so long or non-idempotent tools need their own handles (or the tasks extension). Chapter 4 of Agent Protocols Explained animates resume-versus-resend.

Source: MCP specification 2026-07-28 — Streamable HTTP; Subscriptions; Key changes (accessed 2026-10-08).

07

Auth, Origin, and the DNS-Rebinding Trap

Three headers carry all the security weight at the transport boundary:

HeaderRequiredPurpose
Authorization: Bearer <token>Remote serversOAuth 2.1 access token. Validated per-request against the issuer's metadata. Audience MUST match this server's URL (RFC 8707).
MCP-Protocol-VersionAll HTTP requests after initialize (before 2026-07-28); every POST (2026-07-28)Before 2026-07-28: echoes the negotiated protocol version; a mismatch SHOULD be rejected with 400. 2026-07-28: MUST match the version in the body's _meta (else 400 + -32020); an unsupported version gets 400 + -32022 listing the supported ones.
OriginLocalhost servers binding HTTPValidates that the request came from a permitted origin — not a malicious page that lured a browser into resolving evil.com to 127.0.0.1.

The DNS-rebinding attack on local HTTP servers

A surprising number of MCP servers expose Streamable HTTP on http://127.0.0.1:PORT for development. Without an Origin check, any web page the user visits can issue fetch() calls against that loopback port using DNS rebinding (resolve attacker.com first to a public IP, then to 127.0.0.1). The result: a webpage drains your tokens.

The two checks every loopback HTTP server MUST do
// 1. Bind explicitly to loopback, not 0.0.0.0
server.listen(PORT, "127.0.0.1");

// 2. Reject requests with foreign Origin
const ALLOWED = new Set(["http://localhost", "app://obsidian.md", ...]);
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && !ALLOWED.has(new URL(origin).origin))
    return res.status(403).end();
  next();
});
Why the spec calls this out

The Origin header is one of the few HTTP headers that the browser refuses to let JavaScript override. That's why it's the right gate: it pins down the actual page that initiated the request, even after a DNS rebind. Deck 04 (Security) goes deeper on the wider OAuth threat model. The Origin rule is unchanged in 2026-07-28: an invalid Origin MUST get 403.

Source: MCP specification 2026-07-28 — Streamable HTTP: security and protocol version header (accessed 2026-10-08).

08

Wire Capture — A Real Session, Byte by Byte before 2026-07-28

Protocol version 2025-06-18. Annotated TCP-level trace of a Streamable HTTP session: handshake, a tool list, a streamed tool call, a server-initiated notification, and a clean shutdown.

[1] Initialize — client opens with a normal POST
POST /mcp HTTP/1.1
Host: search.acme.io
Authorization: Bearer eyJhbGciOi...
Accept: application/json, text/event-stream
Content-Type: application/json
Content-Length: 218

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"sampling":{},"elicitation":{}},
  "clientInfo":{"name":"claude-code","version":"2.1.0"}}}

HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 1d31a3f4-c1d4-4e9c-9e10-bd8c2c7d3001
Content-Length: 312

{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":true}},
  "serverInfo":{"name":"acme-search","version":"3.4.0"}}}
[2] notifications/initialized — client confirms, no body, 202
POST /mcp HTTP/1.1
Mcp-Session-Id: 1d31a3f4-...
MCP-Protocol-Version: 2025-06-18
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc":"2.0","method":"notifications/initialized"}

HTTP/1.1 202 Accepted
Content-Length: 0
[3] tools/call with progress — server upgrades to SSE
POST /mcp HTTP/1.1
Mcp-Session-Id: 1d31a3f4-...
MCP-Protocol-Version: 2025-06-18

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
  "name":"deep_search","arguments":{"q":"quark–gluon plasma","depth":3}}}

HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Session-Id: 1d31a3f4-...

event: message
id: 1
data: {"jsonrpc":"2.0","method":"notifications/progress",
       "params":{"progressToken":"x","progress":0.33,"total":1.0}}

event: message
id: 2
data: {"jsonrpc":"2.0","method":"notifications/progress",
       "params":{"progressToken":"x","progress":0.66}}

event: message
id: 3
data: {"jsonrpc":"2.0","id":2,"result":{
       "content":[{"type":"text","text":"42 sources..."}],
       "isError":false}}
[4] Listening channel — client opens GET for unsolicited events
GET /mcp HTTP/1.1
Mcp-Session-Id: 1d31a3f4-...
Accept: text/event-stream
Last-Event-ID: 0

HTTP/1.1 200 OK
Content-Type: text/event-stream

event: message
id: 17
data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}
[5] Clean shutdown
DELETE /mcp HTTP/1.1
Mcp-Session-Id: 1d31a3f4-...

HTTP/1.1 204 No Content
08b

Wire Capture in 2026-07-28 — No Session at All 2026-07-28

The same client and server under 2026-07-28. Steps [1], [2] and [5] of the previous slide have no equivalent; [4] becomes a subscriptions/listen POST (slide 06b). What is left is one self-contained POST per call, plus what a legacy client sees if it tries the old shape.

[1] tools/call — the first message on the wire
POST /mcp HTTP/1.1
Host: search.example.com
Authorization: Bearer eyJhbGciOi...
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
  "name":"search","arguments":{"q":"mcp"},
  "_meta":{"progressToken":"p1",
           "io.modelcontextprotocol/protocolVersion":"2026-07-28",
           "io.modelcontextprotocol/clientInfo":{"name":"example-host","version":"1.0.0"},
           "io.modelcontextprotocol/clientCapabilities":{}}}}

HTTP/1.1 200 OK
Content-Type: text/event-stream

data: {"jsonrpc":"2.0","method":"notifications/progress",
       "params":{"progressToken":"p1","progress":0.5}}

data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete",
       "content":[{"type":"text","text":"42 sources..."}],"isError":false}}
[2] A legacy client tries the old shape against a 2026-07-28-only server
GET /mcp HTTP/1.1                     → 405 Method Not Allowed
DELETE /mcp HTTP/1.1                  → 405 Method Not Allowed
POST /mcp  (initialize, no Mcp-Method header)
                                      → 400 Bad Request (required headers missing;
                                        SHOULD name the supported versions)
Mcp-Session-Id / Last-Event-ID headers → ignored
Reading the capture

No SSE id: lines (nothing to resume), no session header, and progress still flows on the request's own stream. Hosts, tokens and tool names are illustrative. Dual-era servers (for example the official Python SDK 2.x) instead fall back to the slide-08 behaviour for a legacy client.

Source: MCP specification 2026-07-28 — Streamable HTTP: backward compatibility; Versioning: compatibility matrix; Progress (accessed 2026-10-08).

09

Choosing a Transport in 2026

Use stdio when…

  • The server runs locally beside the host
  • You don't want to deal with auth at all
  • Distribution is via uvx / npx / a binary in the user's PATH
  • Resource lifetime equals the host's session

Use Streamable HTTP when…

  • The server is hosted as a service
  • One server instance backs many users
  • You need OAuth (you almost always do)
  • Long-running tools need to push progress
  • You want resumable sessions across reconnects before 2026-07-28

Migrating off HTTP+SSE

If you ship an HTTP+SSE server today, the migration is incremental:

  1. Keep the old POST /messages + GET /sse endpoints alive for one major version of your client.
  2. Add the unified /mcp endpoint that speaks Streamable HTTP.
  3. Advertise the new endpoint via your service-discovery channel (homepage, well-known, README).
  4. Drop the old endpoints once usage falls below your retention threshold.
Take-aways

stdio is JSON-on-newlines. Don't print to stdout. Don't batch. Don't embed newlines.

Streamable HTTP is one URL, two response shapes. The server picks per request between a flat JSON response and an SSE stream.

Sessions are an opt-in header and the right place to anchor protocol version, capabilities, OAuth principal and per-stream replay buffers. before 2026-07-28

Resumability is free if you tag SSE events with monotonic IDs and keep a bounded replay buffer. before 2026-07-28

2026-07-28 is stateless. 2026-07-28 No sessions, no GET stream, no resumption, no ping: one POST per message, _meta in every request, Mcp-Method/Mcp-Name headers, subscriptions/listen for change notifications, and re-issue on a broken stream.

Origin and audience are the two checks that stop the easy attacks — covered in detail in deck 04.