Menu

Streamable HTTP vs SSE: moving a remote MCP server to the current transport

2026-10-20MCP Checkup

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 initialize and no notifications/initialized. Every request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in _meta, and should carry io.modelcontextprotocol/clientInfo.
  • No sessions. The Mcp-Session-Id header 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/listen POST.
  • No resuming. Last-Event-ID and 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-Version and Mcp-Method on every POST, plus Mcp-Name on tools/call, resources/read and prompts/get. A missing header, or one that disagrees with the body, gets 400 with error -32020 (HeaderMismatch).
  • An unsupported version gets 400 with -32022 (UnsupportedProtocolVersion) and a list of the versions the server supports. An unknown method gets 404 with -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:

  1. It POSTs to the URL the user gave it.
  2. If that works, it uses Streamable HTTP.
  3. If the POST fails with 400, 404 or 405, and the body is not a recognised modern JSON-RPC error, it sends a GET to the same URL and waits for an endpoint event. 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 /sse URL will have clients POST to it. That POST must fail with a 4xx such as 405, not a 200, 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 400 or 404 looks like a legacy server, and a client that supports both eras will go looking for initialize, 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 _meta fields is served statelessly. An initialize request selects the legacy behaviour of the version it negotiates, scoped to that session.
  • A request with no MCP-Protocol-Version header comes from a client older than 2025-06-18. If you support those clients, you may treat such requests as 2025-03-26. If you don't, you must reject them with 400.
  • 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 any Mcp-Session-Id, and never mint or echo one; ignore Last-Event-ID; and when an old client sends initialize, 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. A 200 with an HTML page is neither, and neither is a 404.
  • Under 2026-07-28 there is nothing to GET. Return 405, and move change notifications to subscriptions/listen.
  • Keep the old stream off the new path. If a GET on /mcp opens a legacy stream with an endpoint event, 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 an X-Accel-Buffering: no header 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-Id to 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 fresh initialize. A 400 or a 500 leaves 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/list no 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-Id and earlier texts write Mcp-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.