MCP Transports — stdio vs Streamable HTTP vs SSE Migration
stdio is for the local machine. Streamable HTTP (2025-03-26) is the remote standard, and the old HTTP+SSE transport is deprecated and being removed in mid-2026. Picking the wrong transport costs you a migration. Picking the right one gets you a remote-hostable MCP server with session continuity and DNS-rebinding protection.
Learning Objectives
- Pick between stdio and Streamable HTTP based on deployment shape (local vs remote, single-process vs fleet).
- Implement the Streamable HTTP single-endpoint pattern: POST for requests, GET for session stream.
- Enforce `Origin` validation and session-id semantics to defeat DNS-rebinding.
- Migrate a legacy HTTP+SSE server to Streamable HTTP before the mid-2026 removal deadlines.
The Problem
The first MCP remote transport (2024-11) was HTTP+SSE: two endpoints, one for the client's POSTs and one Server-Sent-Events channel for the server-to-client stream. It worked, but it was clumsy. Each session needed two endpoints, some CDNs broke caching in front of it, and it depended on long-lived SSE connections that some WAFs terminate aggressively.
The 2025-03-26 spec replaced it with Streamable HTTP: one endpoint, POST for client requests, GET for establishing a session stream, and a shared Mcp-Session-Id header tying them together. Servers built or migrated since then use Streamable HTTP, and the old SSE mode is being deprecated. Atlassian Rovo removed it June 30, 2026, Keboola April 1, 2026, and most remaining enterprise servers are due by the end of 2026.
stdio still matters for local servers. Claude Desktop, VS Code, and other IDE-shaped clients spawn servers over stdio. The mental model I use is stdio for "this machine" and Streamable HTTP for "over the network", with no crossover between them.
The Concept
stdio
- Child-process transport. The client spawns the server and talks to it over stdin/stdout.
- One JSON object per line, newline-delimited.
- No session id. The process identity is the session.
- No auth needed, because the child inherits the parent's trust boundary.
- Don't use it for remote servers. You would need SSH or socat to tunnel, and at that point Streamable HTTP is the better tool.
Streamable HTTP
A single endpoint, /mcp (or any path), supports three HTTP methods:
- POST /mcp. The client sends a JSON-RPC message. The server replies with either a single JSON response or an SSE stream of one or more responses, which is useful for batched responses and notifications related to that request.
- GET /mcp. The client opens a long-lived SSE channel. The server uses it for server-to-client requests (sampling, notifications, elicitation).
- DELETE /mcp. The client explicitly terminates the session.
Sessions are identified by the Mcp-Session-Id header. The server sets it on the first response and the client echoes it on each subsequent request. Session ids must be cryptographically random (128+ bits), and client-chosen ids are rejected for safety.
Single endpoint vs two
Two-endpoint mode from the old spec is still callable in 2026, and the spec declares it "legacy compatible". New servers should still be single-endpoint. The official SDKs emit single-endpoint, so reach for the legacy mode only when talking to an unmigrated remote.
Origin validation and DNS-rebinding
Browsers are not MCP clients (today), but an attacker can craft a webpage that convinces a browser to POST to localhost:1234/mcp, where the user's local MCP server listens. If the server does not check Origin, the browser's same-origin policy will not save it, because Origin: http://evil.com is valid cross-origin.
The 2025-11-25 spec requires servers to reject requests whose Origin is not on an allowlist. The allowlist typically contains the MCP client host (https://claude.ai, vscode-webview://*) and localhost variants for local UIs.
Session id lifecycle
- Client sends first request without
Mcp-Session-Id. - Server assigns a random id and sets
Mcp-Session-Idon the response header. - Client echoes that header on all subsequent requests and on
GET /mcpfor the stream. - Session can be revoked by the server. The client sees 404 on subsequent requests and must re-initialize.
- Client can explicitly DELETE the session for clean shutdown.
Keepalive and reconnect
SSE connections drop. The client re-establishes by re-GETing with the same Mcp-Session-Id. The server must queue events missed during the outage (up to a reasonable window) and replay them via the last-event-id header the client echoes.
Phase 13 · 13 covers Tasks, which let long-running work survive even a full-session reconnect.
Backwards compatibility probe
A client that wants to support both old and new servers:
- POST to
/mcp. - If the response is
200 OKwith JSON or SSE, this is Streamable HTTP. - If the response is
200 OKwithContent-Type: text/event-streamAND aLocationheader pointing to a secondary endpoint, this is legacy HTTP+SSE. Follow theLocation.
Cloudflare, ngrok, and hosting
Production remote MCP servers in 2026 run on Cloudflare Workers (with their MCP Agents SDK), Vercel Functions, or containerized Node/Python. The constraint to check is whether your hosting supports long-lived HTTP connections for the SSE GET. Vercel's free tier caps at 10 seconds and is unsuitable. Cloudflare Workers support indefinite streams.
Gateway composition
When you front multiple MCP servers with a gateway (Phase 13 · 17), the gateway is a single Streamable HTTP endpoint that rewrites session ids and multiplexes upstream. Tools are merged at the gateway layer, so the client sees a single logical server.
Transport failure modes
- stdio SIGPIPE. Child process death mid-write raises SIGPIPE, and servers should exit cleanly. Clients should detect EOF and mark the session dead.
- HTTP 502 / 504. Cloudflare, nginx, and other proxies emit these on upstream failure. Streamable HTTP clients should retry once after a short backoff.
- SSE connection drop. TCP RST, proxy timeout, or a client network change closes the stream. The client reconnects with
Mcp-Session-Idand optionallast-event-idto resume. - Session revocation. The server invalidates a session id, the client sees 404 on the next request, and it must re-handshake.
- Clock skew. Resource-TTL calculations on the client diverge from the server. The client should treat server timestamps as authoritative.
When to bypass Streamable HTTP
Some enterprises deploy MCP servers behind gRPC or message-queue transports inside their own networks. This is non-standard, since MCP's spec does not formally define these. A gateway can expose a Streamable HTTP surface to MCP clients while using gRPC internally. Keep the external surface spec-compliant and let the gateway own the translation.
Use It
code/main.py implements a minimal Streamable HTTP endpoint using http.server (stdlib). It handles POST, GET, and DELETE on /mcp, sets Mcp-Session-Id on the first response, validates Origin, and rejects requests from non-allowlisted origins. The handler follows the dispatch shape from the Lesson 07 server, with a single ping tool.
What to look at:
- The POST handler reads the JSON-RPC body, dispatches, and writes a JSON response (the single-response variant; the SSE variant is structurally similar).
- The
Origincheck rejects the defaulthttp://evil.exampleprobe but acceptshttp://localhost. - Session ids are random 128-bit hex strings, and the server keeps per-session state in memory.
Ship It
You produce outputs/skill-mcp-transport-migrator.md. Given an HTTP+SSE (legacy) MCP server, the skill produces a migration plan to Streamable HTTP with session-id continuity, Origin checks, and backwards-compatible probe support.
I'd run python main.py --probe before reading the handler code. It starts the server on port 8017 and walks five steps over loopback: a POST from http://evil.example gets a 403, an initialize from http://localhost gets a session id, that id is echoed on tools/list, a DELETE returns 204, and a GET on the dead session returns 404. That covers the session lifecycle from the Concept section in a few lines of output.
The function worth reading closely is _resolve_session. Only initialize can mint a session, and any other method with a missing or unknown Mcp-Session-Id gets a 404. That gives the client one signal for revocation and one response to it, which is to re-initialize.
I'd also be clear about what the skeleton skips. The GET handler sends three progress events with ids and then returns, and nothing reads last-event-id, so there is no replay. POST here answers with a single JSON body rather than an SSE stream. Sessions live in a dict in process memory, so a restart drops all of them. Exercise 3 closes the replay gap, and I'd close it before putting anything like this behind a proxy.
Exercises
-
Run
code/main.py. POST aninitializefromcurland observe theMcp-Session-Idresponse header. POST a second request echoing the header and verify session continuity. -
Change the GET handler so the SSE stream stays open and sends one
notifications/progressevent every five seconds, instead of three events 0.05s apart. Reconnect by re-GETing with the same session id and confirm the server accepts it. -
Implement the
last-event-idreplay logic. On reconnect, replay any events generated since that id. -
Extend
Originvalidation to support a wildcard pattern (https://*.example.com) and confirm it acceptshttps://app.example.combut rejectshttps://evil.example.com.attacker.net. -
Take a legacy HTTP+SSE server from the official registry (there are several) and sketch the migration: what changes in endpoint handling, session id generation, and header semantics.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| stdio transport | "Local child process" | JSON-RPC over stdin/stdout, newline-delimited |
| Streamable HTTP | "The remote transport" | Single-endpoint POST + GET + optional SSE, 2025-03-26 spec |
| HTTP+SSE | "Legacy" | Two-endpoint model being removed in mid-2026 |
Mcp-Session-Id | "Session header" | Server-assigned random id echoed on every subsequent request |
Origin allowlist | "DNS-rebinding defense" | Reject requests whose Origin is not approved |
| Single endpoint | "One URL" | /mcp handles POST / GET / DELETE for all session operations |
last-event-id | "SSE replay" | Header used to resume a dropped stream without missing events |
| Backwards-compat probe | "Old vs new detection" | Client response-shape check that auto-selects transport |
| Long-lived HTTP | "SSE streaming" | Server pushes events for minutes or hours on one TCP connection |
| Session revocation | "Force re-init" | Server invalidates a session id; client must handshake again |
Further Reading
- MCP: Basic transports spec 2025-11-25: canonical reference for stdio and Streamable HTTP
- MCP: Basic transports spec 2025-03-26: the revision that introduced Streamable HTTP
- Cloudflare: MCP transport: Workers-hosted Streamable HTTP patterns
- AWS: MCP transport mechanisms: comparison across deployment shapes
- Atlassian: HTTP+SSE deprecation notice: concrete migration deadline example
Code
- main.py (python, 252 lines)