Skip to content
Chapter 4 of 8 · TransportsThe streamable HTTP transport

15 min read

The streamable HTTP transport

On this page

Under stdio the conversation and the process are the same object: one pipe, one reader, one writer, and nothing in between. Streamable HTTP keeps the JSON-RPC messages exactly as they were and replaces everything around them. The messages now travel as HTTP request and response bodies, the conversation outlives any particular TCP connection, and the two parties are separated by whatever an operations team put in the path.

Three properties carry all of that. There is one endpoint rather than a route per method, so the method being invoked is a field in the body. The server decides per request whether to answer immediately or to hold a stream open and write to it over time. And the server issues a session identifier during initialize that every later request repeats, because a conversation that spans many connections needs a name.

One path, three verbs

Everything a client sends goes to a single URL. There is one endpoint — conventionally /mcp, though the path is the deployment's choice — and the method carries the meaning.

A POST carries client-to-server JSON-RPC. That covers initialize, every tools/call, every resources/read, the client's own notifications, and its responses to requests the server made of it. A GET to the same URL opens a server-sent events (SSE) stream — an ordinary HTTP response body that the server holds open and writes into over time, each message arriving as a data: line and the whole thing carried by any intermediary that understands plain HTTP — on which the server can push messages that no POST asked for: a notifications/tools/list_changed when the tool set changes underneath a long-lived session, or a server-initiated sampling/createMessage request. A server that has nothing to push may answer that GET with 405 Method Not Allowed, and a client is expected to treat that as normal rather than as a fault. A DELETE asks the server to terminate the session and release whatever it was holding.

What the server does with a POST depends on what is in the body, and this is the piece that most surprises people coming from a REST background, where the response shape is a property of the route.

The POST body containsThe server answers withStatus and content type
only responses and notificationsno body at all202 Accepted
one or more requests, answered immediatelya single JSON-RPC message200, application/json
one or more requests, answered over timean SSE stream, closed when the last response is written200, text/event-stream
a request against an unknown sessionnothing useful404, and the client must re-initialize

The third row is what the name of the transport refers to. The response body for a tools/call is allowed to stay open, and while it is open the server can write progress notifications, log messages, and requests of its own back to the client on that same stream, finishing with the response that the POST was waiting for. A short call takes the second row and is over in one round trip; a 40-second repository index takes the third and reports on itself while it works. The handler code is the same in both cases, since the framework decides which mode to use based on whether the handler emitted anything before it returned.

The four rows partition the possibilities rather than listing the common ones, and checking that is what makes the shape learnable instead of memorable. Take a POST naming a session the server knows. Either the body contains a request — something expecting an answer — or it contains only responses and notifications, which expect nothing; in the second case there is no answer to send, so an acknowledgement with no body is the whole of the correct reply. If there is a request, either the handler finished without emitting anything along the way, in which case a single JSON object is the entire answer, or it emitted something first, in which case the answer had already begun and can only be a stream. Add the case where the session is unknown and there is nowhere to route the request at all, and the possibilities are exhausted. There is no fifth row waiting to be discovered.

Because that choice belongs to the server and is made per request, a client POSTing a request does not know in advance which shape the answer will take. It therefore has to declare that it can handle both, and a server that sees an Accept header naming only one of them is entitled to reject the request before it reaches any handler.

Every link in that is checkable, and laying them out is worth the space because the resulting refusal is otherwise the least explicable thing about a first deployment. The server cannot know which answer shape it will use until it has read the body and, in the streaming case, until the handler has run far enough either to emit something or to return. The Accept header is read at the top of the request, before any of that has happened. So at the moment the server has to decide whether this client can be served at all, the only thing it knows about its own answer is that it could be either kind. A client accepting only application/json is therefore unserviceable in advance: if the call turns out to need a stream there will be no legal way to deliver it, and by then the work will have been done. Refusing at the door is the one option that fails cheaply, which is why the refusal arrives before a handler and why it arrives on a request that is otherwise perfectly well formed.

The identifier that arrives with initialize

The initialize exchange does one thing over HTTP that it did not have to do over stdio, which is to establish a name for the conversation. Over a pipe, the conversation and the process are the same object and neither needs an identifier. Over HTTP, TCP connections come and go beneath a session that outlives them, so the server issues a token on the initialize response and the client repeats it on everything after.

The step worth not skipping is why an identifier is needed at all, and it is a statement about mappings rather than about HTTP. Over a pipe there is one conversation, carried on one connection, held by one pair of processes, and those three are born and die together — so "which conversation is this message part of" has exactly one possible answer and asking it is pointless. Over HTTP all three come apart, in both directions. One session spans many TCP connections, because connections are closed by keep-alive timeouts, by proxies and by a laptop moving between networks while the conversation continues. And one connection can carry requests belonging to different sessions, because a client holding two sessions to the same host will reuse the connection for both. A many-to-many relation cannot be recovered from the connection the bytes arrived on, so the answer has to travel inside the message, and the header is where it travels.

http
POST /mcp HTTP/1.1
Host: repo-index.internal
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
 "protocolVersion":"2025-11-25","capabilities":{},
 "clientInfo":{"name":"editor","version":"1.4.0"}}}
http
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 7f3a1c02-9d44-4e6b-8a10-3b5c9e2f81aa

{"jsonrpc":"2.0","id":1,"result":{
 "protocolVersion":"2025-11-25",
 "capabilities":{"tools":{"listChanged":true}},
 "serverInfo":{"name":"repo-index","version":"2.3.1"}}}

Every subsequent request carries Mcp-Session-Id: 7f3a1c02-… and, after initialization, an MCP-Protocol-Version header naming the version the two sides agreed on, so that an intermediary or a multi-version server can route without parsing the body. If the server has forgotten the session — it expired, or the replica that issued it is gone — it answers 404, and the correct client behaviour is to start over with a fresh initialize rather than to retry. That 404 is a normal event in a deployment that restarts, not an error condition, and clients that treat it as fatal produce support tickets that read "it works until it doesn't".

The instinct that makes a client treat that 404 as fatal is imported from somewhere it is correct, which is why it survives review. In an ordinary HTTP API a 404 says the thing you asked for does not exist, and asking again for the same thing will not change that, so surfacing an error is the honest client behaviour. Here the missing thing is not the resource but the conversation, and a conversation is something a client can simply obtain another of. The nearer reading is "your ticket expired" rather than "no such page": the request was well formed, the session it belonged to is gone, and a fresh initialize costs one round trip and replaces it.

The identifier is also the reason a shared deployment cannot be treated as a stateless request-response service, since a session established against one process carries state that another process does not have. That consequence is large enough to be the subject of the sixth chapter of this topic, on session affinity across replicas.

Ending a session is a DELETE to the same endpoint carrying the same header, which tells the server to release the workspace, close the pooled connections and forget the event buffer. Clients are expected to send it when the user closes the tool, and a good many do not, so a server that only frees session state on DELETE will accumulate abandoned sessions until it runs out of memory. Every implementation therefore needs an idle expiry as well, typically in the range of 5 to 30 minutes since the last request on that session, and the server is allowed to expire a session whenever it likes as long as it answers subsequent requests for it with 404.

The GET stream deserves a note because it is the piece most often skipped. A server that only answers questions has nothing to push and can decline the GET entirely; a server that maintains subscriptions, changes its tool list at runtime, or wants to ask the client to run a model inference on its behalf needs the channel, because those messages originate at the server and have no POST to ride back on. The stream opened by a GET is long-lived by design, which puts it directly in the path of every intermediary timeout, and it is where resumption is used most.

Reading the same call both ways

Standing the same server up over both transports and putting the two exchanges next to each other makes clear how much is unchanged. Over stdio, a tools/call and its result are two lines on two pipes:

text
→ stdin   {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_repo","arguments":{"query":"parseConfig"}}}
← stdout  {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"8 hits…"}]}}

Over streamable HTTP the same two messages travel as a request body and a stream body:

http
POST /mcp HTTP/1.1
Mcp-Session-Id: 7f3a1c02-9d44-4e6b-8a10-3b5c9e2f81aa
MCP-Protocol-Version: 2025-11-25
Accept: application/json, text/event-stream
Content-Type: application/json

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{
 "name":"search_repo","arguments":{"query":"parseConfig","progress":true}}}
text
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

id: 1
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":4,"progress":12,"total":84}}

id: 2
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":4,"progress":57,"total":84}}

id: 3
data: {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"8 hits…"}]}}

The JSON is identical in both, down to the id field that correlates request and response. What HTTP adds around it is an envelope: headers that identify the session and the protocol version, an SSE framing in which each message becomes one data: line preceded by an id: line, and a connection that can be interrupted by anything between the two parties. The interruption is the part that has no counterpart over a pipe, because a pipe between a parent and its child has no intermediaries and fails only when one of the two processes dies.

Sequence: Client → Server (POST /mcp — initialize); Server → Client (200 + Mcp-Session-Id); Client → Server (POST /mcp — notifications/initialized); Server → Client (202 Accepted); Client → Server (POST /mcp — tools/call (id=4)); Server → Client (200 text/event-stream); Server → Client (id:1 progress 12/84); Server → Client (connection dropped); Client → Server (GET /mcp — Last-Event-ID: 1); Server → Client (id:2 progress 57/84); Server → Client (id:3 result id=4).

Resuming instead of starting over

The id: lines in that stream are not decoration. Server-sent events carry a last-event-id mechanism, and MCP uses it as the resumption primitive: when a stream dies, the client reconnects with a Last-Event-ID header naming the final event it received, and the server replays what it would have sent on that stream after that point.

http
GET /mcp HTTP/1.1
Mcp-Session-Id: 7f3a1c02-9d44-4e6b-8a10-3b5c9e2f81aa
Accept: text/event-stream
Last-Event-ID: 1

For this to work the server has to keep a per-stream buffer of emitted events, keyed by event id, for at least as long as it is willing to accept a reconnect. Event ids must be unique within a session and must be assigned in the order the events were produced, so the server can locate the resume point without ambiguity. Redis with a short TTL — a time to live, after which the store discards the buffer by itself — is the usual store in a replicated deployment; an in-memory ring buffer is enough for a single process. The alternative, which is what a client does when the server offers no ids, is to abandon the in-flight call, re-issue it, and pay for the work twice — acceptable for a 200 ms search and expensive for a 40-second index, which is exactly the call most likely to be interrupted.

Resumption applies per stream. Replaying event 2 onto a reconnected GET does not replay messages that belonged to some other POST's response stream, and the specification is explicit that a server must not send messages from one stream on a different one. This is why a client that lost the stream carrying its tools/call response can recover the tail of that specific call rather than a merged history of everything the session ever did.

Both of the requirements on event ids fall straight out of what the client is asking for. Last-Event-ID: 1 means "send me everything you would have sent on this stream after event one", and answering that needs two things the server had to arrange before anything went wrong. It has to locate event one, which is impossible if two events share an id — hence uniqueness within the session. And it has to know what "after" means, which is impossible if ids were handed out in any order other than the order the events were produced — hence assignment in production order. Neither is a formality. An implementation that keys its buffer by a hash of each message's content satisfies neither requirement, and it will happily resume by replaying whatever else happened to hash the same.

The reason to spend a buffer and a Redis key on any of this is a compound argument, and both halves point the same way. The cost of not resuming is the work done twice, which is invisible on a 200 ms search and doubles the wait on a 40-second index. The chance of needing to resume is the chance the connection is interrupted while the call is open, and that rises with how long it stays open — a stream held for 40 seconds is exposed to every idle timeout, redeploy and network change inside that window, while one open for 200 ms is exposed to almost none of them. Cost and probability rise together rather than trading off, so the calls where resumption is most work to support are precisely the calls where it pays.

Where REST habits mislead

Streamable HTTP is not a REST API, and treating it as one produces confusion in both directions. There are no resource paths, no verbs mapped to CRUD, and no per-tool routes; POST /mcp is the whole surface, and the method being invoked is a field in the JSON body. Load balancer rules, web application firewall (WAF) signatures and API gateway policies written against paths therefore see one endpoint doing everything, which matters when someone asks for per-tool rate limiting and discovers that it has to happen inside the server.

A related consequence is that a POST is not safely retryable the way a GET would be. The body may carry a tools/call that sends an email or writes a file, so a client that reissues the POST after a network error can execute the tool twice. Recovery from a broken stream is a reconnect with Last-Event-ID for precisely this reason, and tools whose effects are not idempotent — running one twice does not leave the world in the state that running it once did — should carry their own idempotency key in the arguments, a caller-supplied string the server remembers so that a repeat of the same key returns the first result instead of doing the work again.

WebSockets, and the transport this replaced

It is also not WebSockets, though the streaming makes people reach for the comparison. The connection is a plain HTTP response body held open, so it travels through ordinary proxies, content delivery networks and corporate middleboxes that would refuse an upgrade handshake, and it inherits ordinary HTTP semantics for authentication headers, redirects and status codes. The cost of that compatibility is that the server-to-client direction is a one-way stream rather than a duplex channel — every client-to-server message is a fresh POST, even in the middle of a long call.

Finally, it is not the earlier HTTP transport it replaced. The 2024-11-05 revision defined a two-endpoint arrangement in which the client first opened a long-lived GET /sse and the server replied with a separate URL to POST messages to. That design made the stream mandatory and load-bearing: nothing could be sent until it was established, losing it lost the session, and every intermediary had to keep two correlated connections alive. Streamable HTTP collapses that into one endpoint where the stream is optional, per-response, and resumable, which is what allows a stateless server to answer with plain JSON and never open a stream at all.

The shape is therefore small enough to hold in your head: one URL, a POST that may be answered two ways, an optional GET for messages the server originates, a DELETE that ends the session, one header naming the session and another naming the negotiated version, and event ids on any stream so a reconnect can pick up where it stopped. Nothing in it is exotic, and nothing in it is REST.

Everything described here also assumes the two parties are talking directly. In a real deployment they are not: there is an ingress controller terminating TLS at the edge of the cluster, probably a service mesh sidecar — a proxy container that intercepts every connection into and out of the pod — possibly a CDN, and each of them holds an opinion about how long a response body may stay open without producing bytes. That opinion is usually 60 seconds, and it is why long calls in a freshly deployed server fail at a suspiciously round number.

Transports — read the current version at https://daviddominguez.dev/learn/en/transports/the-streamable-http-transport

Back to top