Streamable HTTP vs SSE: moving a remote MCP server to the current transport
If your MCP server has an /sse URL that clients open with a GET, it speaks the HTTP+SSE transport from the 2024-11-05 revision. The spec replaced it with Streamable HTTP in 2025-03-26. Then the current revision, 2026-07-28, changed Streamable HTTP again: no handshake, no sessions, no GET stream. So moving to the current transport can mean two moves, and you may want older clients to keep working through both.
This guide compares the three shapes, follows the spec's backward compatibility rules for serving old clients, and ends with curl checks. Replace your-server.example.com with your host.
Three shapes of remote MCP
| HTTP+SSE (2024-11-05) | Streamable HTTP (2025-03-26 to 2025-11-25) | Streamable HTTP (2026-07-28) | |
|---|---|---|---|
| Endpoints | A GET stream, plus a POST URL the server sends in an endpoint event |
One MCP endpoint for POST and GET | One MCP endpoint for POST |
| Where replies arrive | As message events on the GET stream |
In the response to the POST: JSON or an SSE stream | In the response to the POST: JSON or an SSE stream for that request |
| Handshake | initialize |
initialize |
None: every request carries _meta, and servers must implement server/discover |
| Session | Not defined; replies need the client's open GET stream | Optional Mcp-Session-Id, set at initialize |
None |
| GET | Opens the stream | Opens an optional extra stream, or gets 405 |
Not part of the transport; 405 |
| Server-to-client requests | On the stream | On SSE streams | Returned inside results; the client retries with the answers |
| Resuming a dropped stream | Not defined | Last-Event-ID |
Not supported; the client sends the request again |
The biggest difference is where replies go. With HTTP+SSE, the reply to a POST travels on a different, long-lived GET connection, so whichever process receives the POST has to reach the process holding that client's stream. Streamable HTTP puts the reply in the response to the POST itself. Under 2026-07-28, with no session at all, any instance can answer any request.
What 2026-07-28 changed
If you already moved to Streamable HTTP in 2025, these are the changes that touch the transport:
- No
initializeand nonotifications/initialized. Every request carriesio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilitiesin_meta, and should carryio.modelcontextprotocol/clientInfo. - No sessions. The
Mcp-Session-Idheader is gone. State that spans calls lives in explicit handles that the client passes back as tool arguments. - No GET stream. Change notifications arrive on the response stream of a
subscriptions/listenPOST. - No resuming.
Last-Event-IDand SSE event IDs are gone. A broken stream loses that request, and the client sends it again with a new ID. - Closing a request's response stream cancels that request.
- New required headers:
MCP-Protocol-VersionandMcp-Methodon every POST, plusMcp-Nameontools/call,resources/readandprompts/get. A missing header, or one that disagrees with the body, gets400with error-32020(HeaderMismatch). - An unsupported version gets
400with-32022(UnsupportedProtocolVersion) and a list of the versions the server supports. An unknown method gets404with-32601. - HTTP+SSE, deprecated since 2025-03-26, is now formally Deprecated under the spec's feature lifecycle policy. New implementations should not adopt it, existing ones should migrate, and a later revision may remove it.
Keeping old clients working
The spec's backward compatibility section, present since 2025-03-26 and repeated in 2026-07-28, tells servers one thing: keep hosting the old SSE and POST endpoints alongside the new MCP endpoint. You can merge the old POST endpoint into the new MCP endpoint, but the spec warns that this may add complexity you don't need. Separate paths are simpler: leave /sse and its POST URL as they are, and add /mcp.
It helps to know how a client that supports both transports works out which one you speak, because your server has to send it the right signals:
- It POSTs to the URL the user gave it.
- If that works, it uses Streamable HTTP.
- If the POST fails with
400,404or405, and the body is not a recognised modern JSON-RPC error, it sends a GET to the same URL and waits for anendpointevent. The 2025-03-26 and 2025-06-18 texts say any 4xx status; 2025-11-25 names these three; 2026-07-28 adds the check on the body.
Two things follow:
- Users who saved your old
/sseURL will have clients POST to it. That POST must fail with a 4xx such as405, not a200, a redirect or an HTML page, so that the client falls back to GET. - On the new endpoint, errors must carry a real JSON-RPC error body. Under 2026-07-28, an empty
400or404looks like a legacy server, and a client that supports both eras will go looking forinitialize, or for an SSE stream.
If you also have 2025-era Streamable HTTP clients:
- One endpoint can serve both eras. A request that carries the new
_metafields is served statelessly. Aninitializerequest selects the legacy behaviour of the version it negotiates, scoped to that session. - A request with no
MCP-Protocol-Versionheader comes from a client older than 2025-06-18. If you support those clients, you may treat such requests as2025-03-26. If you don't, you must reject them with400. - If you drop the older eras, the spec says how a server that speaks only 2026-07-28 should treat their traffic: answer GET and DELETE with
405; ignore anyMcp-Session-Id, and never mint or echo one; ignoreLast-Event-ID; and when an old client sendsinitialize, name your supported versions in the error. Old clients have no way to move forward on their own, so that message may be the only clue their user sees.
Where migrations go wrong: GET
- Under 2025-03-26 to 2025-11-25, a GET on the MCP endpoint must either open an SSE stream or return
405. A200with an HTML page is neither, and neither is a404. - Under 2026-07-28 there is nothing to GET. Return
405, and move change notifications tosubscriptions/listen. - Keep the old stream off the new path. If a GET on
/mcpopens a legacy stream with anendpointevent, a client that falls back for any reason will settle on the old transport for all later traffic. - Long-lived streams meet proxy idle timeouts. The 2026-07-28 text encourages periodic SSE comment lines (lines that start with
:) on long-lived streams, and anX-Accel-Buffering: noheader so that proxies such as nginx don't buffer events. If you keep the old GET stream, it passes through the same proxies.
Where migrations go wrong: sessions
- If you issue
Mcp-Session-Idto 2025-era clients, every instance behind your load balancer must be able to find the session. A session kept in one process's memory becomes "not found" as soon as a request lands on another instance. - A session you have ended, or no longer know, must get
404. That status is what tells the client to send a freshinitialize. A400or a500leaves it stuck. - Under 2026-07-28, don't mint a session for modern requests, and don't require one. A tool that needs state across calls should return a handle, such as a basket ID, that the model passes back as an argument. Check the caller's authorization against that handle on every call.
- Under 2026-07-28, list results such as
tools/listno longer vary per connection. With no session, there is nothing for them to vary by. - Header names are case-insensitive. The 2025-11-25 text writes
MCP-Session-Idand earlier texts writeMcp-Session-Id; match either.
Verify the migration with curl
Set your endpoints once:
NEW=https://your-server.example.com/mcp
OLD=https://your-server.example.com/sse
1. A modern request works first time, with no session header.
curl -sS -D - "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},
"io.modelcontextprotocol/clientCapabilities":{}}}}' \
| grep -iE '^HTTP/|^content-type|^mcp-session-id|supportedVersions'
Expect 200, a content type of application/json or text/event-stream, a supportedVersions list that includes 2026-07-28, and no mcp-session-id line.
2. GET and DELETE get 405 (if you serve only 2026-07-28).
curl -sS -o /dev/null -w 'GET %{http_code}\n' -H 'Accept: text/event-stream' "$NEW"
curl -sS -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE "$NEW"
3. An unknown method gets 404 and a body a client can recognise (-32601).
curl -sS -i "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: no/such_method' \
-d '{"jsonrpc":"2.0","id":2,"method":"no/such_method","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
4. An old client's initialize gets a useful answer. A server that serves both eras returns an InitializeResult, and maybe an Mcp-Session-Id. A server that speaks only 2026-07-28 returns 400, ideally naming the versions it supports.
curl -sS -i "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl-test","version":"0.1"}}}'
5. If you kept the old transport, it still behaves like one. The old URL refuses a POST, and a GET opens a stream whose first event is endpoint.
curl -sS -o /dev/null -w 'POST %{http_code}\n' "$OLD" -H 'Content-Type: application/json' -d '{}'
curl -sS -N --max-time 5 -H 'Accept: text/event-stream' "$OLD" | head -n 3
Expect a 4xx for the POST, then event: endpoint and a data: line with the POST URL. The --max-time flag closes the open stream after five seconds, so curl ending with a timeout message is expected here.
Run all five after the deploy that ships the migration, and again after any change to your proxy or load balancer, since GET handling and session routing can live there too.
See what an agent sees
A migration is finished when outside clients can use it, not when your own client can. Paste your address at mcpcheckup.com to see what an agent would see. It connects from outside, the way an agent does, and reports each check in one of four states: verified, failed, observed risk, or not checked with a stated reason. You never get a single score.