Covers: Streamable HTTP (revision 2026-07-28); One slow call, three ways.
Streamable HTTP (revision 2026-07-28)
The server exposes one URL, the MCP endpoint, for example https://example.com/mcp, and it accepts POST. That is the whole surface in the new revision: no GET stream, no sessions.
Every JSON-RPC message from the client is its own POST. The client must send an Accept header listing both application/json and text/event-stream. The body is one request or one notification (never a response, because servers no longer send requests). The server answers:
Body was
Server returns
a notification it accepts
202 Accepted, no body
a notification it cannot accept
an HTTP error status (for example 400)
a request
eitherContent-Type: application/json (one object) orContent-Type: text/event-stream (an SSE stream scoped to this request). The client must handle both.
My example server answers with JSON unless it has something to say first (progress notifications) or the request is subscriptions/listen; then it opens a stream. A short call stays a plain JSON response.
Server-Sent Events, briefly
An SSE stream is text: lines of field: value, a blank line ends an event, and a line starting with a colon is a comment with no data. The example includes a small parser, and a test feeds it the same stream cut at every possible byte (90 cut points) and checks that it always yields the same three events, because TCP can split data anywhere. In this revision:
The stream may carry notifications that relate to the request (such as progress) and ends with the final response; the server should then close it.
The server must not send independent requests on the stream.
Servers should send X-Accel-Buffering: no so that nginx-style proxies do not hold events back until a buffer fills, which would make a healthy stream look hung. (Whether your own proxy needs this or other settings is something to test; see Hard 2.)
Long-lived streams should emit a comment line now and then as a keep-alive, so that idle timeouts in intermediaries do not close them. Clients must ignore comments. In the example, a stream with a 50 ms keep-alive delivered 7 comments and 0 events to the client in 0.4 s.
Resumable streams with Last-Event-ID are not supported in this revision. Section 6 returns to what that means.
Headers that mirror the body
Intermediaries (load balancers, gateways, observability tools) want to route and inspect requests without parsing the body. So the transport copies some body fields into headers:
Header
Source
Required
MCP-Protocol-Version
_meta protocol version
on every POST
Mcp-Method
the JSON-RPC method
on all requests
Mcp-Name
params.name or params.uri
on tools/call, resources/read, prompts/get
Mcp-Param-{Name}
a tool argument the server marked with x-mcp-header in its input schema
when the server designates it
A server can mark a parameter (for example region) with "x-mcp-header": "Region" and the client then sends Mcp-Param-Region: north. This lets a gateway shard by region without reading JSON. The rules are strict: the parameter must be a string, integer or boolean, reachable only through properties (not through arrays, oneOf, $ref and so on), with unique, token-safe names. A client that finds a tool breaking those rules must exclude it from its list. The example does this and a test checks that a bad tool is dropped while the good ones remain.
Header values must be plain visible ASCII. Anything else (non-ASCII, leading or trailing spaces, a newline) is sent as Base64 inside a sentinel: =?base64?...?=. A value that merely looks like the sentinel is encoded too, so there is no ambiguity. The example reproduces all five rows of the specification's table of examples:
Original value
Header value
us-west1
us-west1
Hello, 世界
=?base64?SGVsbG8sIOS4lueVjA==?=
padded (with spaces)
=?base64?IHBhZGRlZCA=?=
line1 newline line2
=?base64?bGluZTEKbGluZTI=?=
=?base64?literal?=
=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=
The server must check that header and body agree
Copying a field into a header creates two sources of truth. If a gateway decides from the header and the server acts on the body, someone can make them differ. The specification therefore requires a server that reads the body to reject any request whose headers do not match, with HTTP 400 and JSON-RPC error -32020 (HeaderMismatch). Intermediaries that enforce policy on the headers should also check that the protocol-version header says the version where this validation exists.
A spoofed header, with and without validation
The example builds the attack for real. A small gateway allows only the tool search, deciding from Mcp-Name alone. An honest call to place_hold is blocked (HTTP 403). An attacker then sends Mcp-Name: search with a body that calls place_hold:
Server
Spoofed request
Holds created
does not compare headers with the body
HTTP 200
1
compares, as the specification requires
HTTP 400, error -32020
0
This is the same lesson as module 1: a control is only as good as the information it decides from. The gateway was correct; the server made it bypassable.
Cancellation and status codes
On HTTP, closing the response stream is the cancel signal: the server must treat a client disconnect as cancellation, stop work, and send nothing more. No notifications/cancelled is expected. The example's server watches the socket while the tool runs, so a client that closes the connection stops a 40-step job after 6 steps, the same as on stdio.
The status codes in the pages I read:
Situation
Status and body
notification accepted
202, no body
missing or mismatched mirrored header
400 and error -32020
unsupported protocol version
400 and an UnsupportedProtocolVersionError listing what the server supports
unknown method
404 and a JSON-RPC error -32601
bad Origin
403
GET or DELETE (old shapes)
405
I also had to choose two things the text I read does not cover: 406 for a missing Accept types list, and 200 with a JSON-RPC error for failures like an unknown tool (-32602). Both are marked in the code.
Why the 404 body matters
A 404 can mean two different things: "this modern server does not have that method" or "this is an old HTTP+SSE server that has no such URL". The specification tells servers to put a JSON-RPC -32601 error in the body of the first kind, and tells clients to look at the body before falling back. A client that falls back on every 400 or 404 will mistake a modern server's real errors for an old server.
One slow call, three ways
Progress, cancellation and a dropped connection on three transports
stdio
Streamable HTTP, 2026-07-28
HTTP, 2025-11-25
Progress
notification lines on stdout, matched by the progress token
SSE events on this request's stream
SSE events, each with an event id
Cancel
notifications/cancelled
close the stream
CancelledNotification (disconnecting is not cancelling)
Dropped connection
the pipe closes: restart the process
stream ends: send the request again
GET with Last-Event-ID: the server replays what you missed
Progress works like this (it is general knowledge, from the progress utility, which I did not read for this module): the client puts a progress token in the request's _meta; the server may then send notifications/progress messages with that token, a progress number, an optional total and an optional message. A server must not send progress for a request that did not carry a token, and the example's tool checks that.