Streamable HTTP is the MCP transport for servers reached over a network, behind auth, serving many clients at once. One HTTP endpoint carries the whole protocol in both directions: the client POSTs requests, and the server answers on the same connection, either with a single JSON response or with a stream it can push more messages through. This post builds it with the SDK and reads the real HTTP exchange with curl, headers and status codes included.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
The stdio post ran a server as a local subprocess. That does not work across a network, so MCP defines a second transport. It is called Streamable HTTP, and it replaced an earlier two-endpoint HTTP+SSE design with a single endpoint that handles both directions. The client always sends Accept: application/json, text/event-stream, because the server is allowed to answer either way: a plain JSON object for a quick reply, or a server-sent events stream when it has more than one message to deliver.
§The Server
The same word_count server from the first post, moved onto HTTP, is a handler and a mux. Nothing about the tool changes; only the transport does:
server := mcp.NewServer(&mcp.Implementation{Name: "wire-demo-http", Version: "v0.1.0"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "word_count", Description: "..."}, wordCount)
// One handler serves the whole protocol. getServer can return the same
// server for every session, or look one up per request.
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return server
}, nil)
mux := http.NewServeMux()
mux.Handle("/mcp", handler)
http.ListenAndServe("127.0.0.1:8080", mux)
NewStreamableHTTPHandler is the whole transport. Its getServer function runs per request, returning the server that should handle a session, which is where a multi-tenant deployment would route to different servers. Return nil and the handler answers 400.
§The Handshake Over HTTP
POST an initialize to /mcp and read the full response, headers and all:
$ curl -i -X POST http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize", ...}'
HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Session-Id: 3U2T3343PWLMVYFWNON7JNLWK6
Transfer-Encoding: chunked
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"tools":{"listChanged":true}},"protocolVersion":"2025-11-25","serverInfo":{"name":"wire-demo-http","version":"v0.1.0"}}}
The result is the same initialize reply from the handshake post, but the HTTP envelope around it carries two new things. The Content-Type is text/event-stream, so the body is SSE-framed: an event: message line and a data: line holding the JSON-RPC message. This server answers over a stream; a server may instead return Content-Type: application/json with the bare object, and a client that sent the right Accept header handles both.
The second new thing is the header that makes the rest of the session work: Mcp-Session-Id. The server minted it on initialize, and the client must send it back on every subsequent request.
§Session State in a Stateless Protocol
HTTP requests are independent. An MCP session is not, as the handshake post proved. The Mcp-Session-Id header is the bridge: it ties a sequence of separate HTTP requests into one stateful session. The cheat sheet below is the whole transport, every row taken from a real curl against the server above.
POST initialize → result over SSE, plus a fresh Mcp-Session-Id headerPOST + Mcp-Session-Id → result over SSEPOST with no id → 202 Accepted, empty body, nothing to returnGET + Mcp-Session-Id → SSE stream held openDELETE + Mcp-Session-Id → 204 No ContentHost via localhost is refusedPOST with a deleted id → 404, the client must re-initializeThree of those rows are worth dwelling on. A notification has no id, so there is nothing for the server to return, and it answers 202 Accepted with an empty body, confirmed:
$ curl ... -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP 202, body bytes: 0
A DELETE with the session id ends the session and returns 204 No Content. After that, reusing the id is a 404, the server’s way of telling the client the session is gone and a fresh initialize is required:
DELETE /mcp (Mcp-Session-Id: 3U2T...) -> HTTP 204
POST /mcp (Mcp-Session-Id: 3U2T...) -> HTTP 404
One more header rides along after initialization. Every request once the version is settled must carry MCP-Protocol-Version: 2025-11-25, so the server knows which revision to speak even though each HTTP request stands alone. A request that sends no session id at all does not error outright on this server; it lands in a brand-new, uninitialized session and fails the same way a pre-handshake call does, the "invalid during session initialization" error from the handshake post. The spec also lets a server require the session id and answer 400 when it is missing.
§The Server Push Direction
The POST direction covers requests and their replies. The server-initiated direction, the sampling and elicitation calls from the protocol-versus-API post, needs a channel the server can write to whenever it wants, not only in response to a POST. That channel is a GET. The client opens it with Accept: text/event-stream and leaves it open:
$ curl -i --max-time 2 -X GET http://127.0.0.1:8080/mcp \
-H 'Accept: text/event-stream' -H 'Mcp-Session-Id: 3U2T...'
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
Transfer-Encoding: chunked
The stream opens and holds. Nothing came down it in the two seconds before the client cut the connection, because this server had nothing to push, but it is the path a sampling/createMessage from the server would travel. The bidirectional session that stdio gets for free from two pipes, HTTP rebuilds from a POST channel and a long-lived GET channel.
§Resumability
Networks drop long-lived streams. Streamable HTTP is built to survive it. With an event store configured, the server tags every SSE event with an id, and the tool-call response now looks like this:
event: prime
id: 6VAM6UEUTSH2LZLGNBREREXETS_0
data:
event: message
id: 6VAM6UEUTSH2LZLGNBREREXETS_1
data: {"jsonrpc":"2.0","id":2,"result":{ ...word_count... }}
Each id is unique within the session and encodes which stream it belongs to and its position in it. When a stream drops, the client reconnects with a GET carrying Last-Event-ID: 6VAM...REXETS_1, and the server replays only the events after that one, so no message is lost and none is delivered twice. Turning this on is one field on the handler:
handler := mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{
// Events get an SSE id; a client can reconnect with Last-Event-ID to replay.
EventStore: mcp.NewMemoryEventStore(nil),
})
The MemoryEventStore keeps events in memory, which is enough for one process. A deployment behind a load balancer would back the EventStore interface with something shared, so a client can resume against whichever instance it reconnects to.
§Origin and the Localhost Trap
A local HTTP server has a problem stdio never does: a web page in the browser can reach 127.0.0.1. Through DNS rebinding, a malicious site can point a hostname it controls at the loopback address and POST to a local MCP server in the background. The defense is to validate the Host and Origin, and the SDK does it by default. A request arriving on localhost with a foreign Host header is refused:
$ curl ... -H 'Host: evil.example.com' -d '{...initialize...}'
HTTP 403
The server bound to 127.0.0.1, so it is not exposed on the network, and the 403 blocks the rebinding path on top of that. Turning this protection off is a named option, DisableLocalhostProtection, which is the right way around: the safe behavior is the default, and disabling it is a deliberate, greppable choice.
§The Client
The SDK client mirrors the stdio client from the last post, swapping the transport:
transport := &mcp.StreamableClientTransport{Endpoint: "http://127.0.0.1:8080/mcp"}
session, err := client.Connect(ctx, transport, nil)
Everything in this post, the session id on every request, the protocol-version header, opening the GET stream, reconnecting with Last-Event-ID, is handled inside that transport. The curl walkthrough is what it does on the wire, one HTTP request at a time.
When txn2/mcp-data-platform is deployed as a shared service rather than a local subprocess, this is the transport it uses: one HTTP endpoint, a session id per client, and the server-push stream that carries its Trino query-progress updates back as the work runs.
§What’s Next
Two transports down, and the rest of the series is about what travels over them. The next post starts on the primitives, beginning with the one most servers exist to provide. Tools, Part 1: Discovery and the inputSchema Contract reads exactly what the agent receives when it lists a server’s tools, how the JSON Schema that constrains every call is built from a Go type, and why the annotations on a tool are hints the client must not trust.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.