Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO LXXXIX 2026-05-11 · 8 MIN · LONG-FORM

Transport II, Streamable HTTP: One Endpoint, Two Directions

A single HTTP path carries the whole protocol, a session id threads state across requests, and a dropped stream resumes where it left off

Diagram · folio lxxxix
sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server /mcp
  C->>S: POST initialize
  S->>C: 200 + Mcp-Session-Id, result over SSE
  C--)S: POST notifications/initialized
  S--)C: 202 Accepted (no body)
  C->>S: POST tools/call (Mcp-Session-Id)
  S->>C: 200 result over SSE (event / id / data)
  C->>S: GET (Mcp-Session-Id, Accept: text/event-stream)
  S-->>C: standby stream, held open for server pushes
  C--)S: DELETE (Mcp-Session-Id)
  S--)C: 204 No Content

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 revision 2025-11-25, with the official Go SDK at v1.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.

Action
Status
Request → what the server returns
Open a session
200
POST initialize → result over SSE, plus a fresh Mcp-Session-Id header
Call a tool
200
POST + Mcp-Session-Id → result over SSE
Send a notification
202
POST with no id → 202 Accepted, empty body, nothing to return
Receive server pushes
200
GET + Mcp-Session-Id → SSE stream held open
End the session
204
DELETE + Mcp-Session-Id204 No Content
Foreign Origin / Host
403
any request with a non-localhost Host via localhost is refused
Stale session
404
POST with a deleted id → 404, the client must re-initialize

Three 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.

← back to all notes