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”.
Mcp-Session-Id Header before 2026-07-28Last-Event-ID in Practice before 2026-07-28subscriptions/listen 2026-07-28The 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.
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.
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://.
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.
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.
\n (LF). No embedded newlines — if your JSON is pretty-printed, you've corrupted the stream.print() call in your server will desynchronise the client's parser.2025-06-18, JSON-RPC batches (a top-level array) are no longer permitted on any transport, including stdio.{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"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).
The host's config typically maps a name to a command line and an environment. Claude Desktop's claude_desktop_config.json is representative:
{
"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"]
}
}
}
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.
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.
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.
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"}
/sse meant losing every message between the disconnect and the reconnect.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.
| Method | Body | Server response | Used for |
|---|---|---|---|
POST /mcp | One JSON-RPC request or notification | 200 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 session | Clean session shutdown. 2026-07-28: no sessions; 405. |
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 /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":{...}}
Accept header is mandatoryClients 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).
Mcp-Session-Id Header before 2026-07-28This 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.
404 Not Found on requests carrying that ID. The client MUST then re-initialize.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.
Last-Event-ID in Practice before 2026-07-28Stream 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.
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.
subscriptions/listen 2026-07-28The 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.
| Mechanism | Before 2026-07-28 | 2026-07-28 |
|---|---|---|
| Session | Mcp-Session-Id from initialize, ended by DELETE | None. Explicit handles in tool arguments; header ignored, DELETE → 405 |
| Unsolicited server→client channel | GET /mcp SSE stream | POST 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 stream | Embedded in a result with resultType: "input_required"; the client retries (deck 03) |
| Dropped stream | Reconnect with Last-Event-ID, server replays | Request is lost; re-issue with a new ID. Closing the stream cancels the request |
| Liveness | ping | Removed |
| Routing headers | MCP-Protocol-Version | MCP-Protocol-Version + Mcp-Method + Mcp-Name, which must match the body (else 400, -32020 HeaderMismatch) |
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"}}
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).
Three headers carry all the security weight at the transport boundary:
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <token> | Remote servers | OAuth 2.1 access token. Validated per-request against the issuer's metadata. Audience MUST match this server's URL (RFC 8707). |
MCP-Protocol-Version | All 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. |
Origin | Localhost servers binding HTTP | Validates 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. |
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.
// 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();
});
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).
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.
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"}}}
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
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}}
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"}
DELETE /mcp HTTP/1.1
Mcp-Session-Id: 1d31a3f4-...
HTTP/1.1 204 No Content
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.
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}}
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
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).
uvx / npx / a binary in the user's PATHIf you ship an HTTP+SSE server today, the migration is incremental:
POST /messages + GET /sse endpoints alive for one major version of your client./mcp endpoint that speaks Streamable HTTP.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.