Chapter 16: Transports in Detail – stdio and Streamable HTTP
A transport defines how MCP messages travel between client and server – not what they mean. The meaning (tools, resources, input requests …) is the same on every transport. The specification defines two standard transports: stdio for local servers and Streamable HTTP for servers on the network. This chapter explains both, shows what matters when running Streamable HTTP in production, and how to test a server against it.
As of specification
2026-07-28: MCP has become stateless. Theinitializehandshake, sessions withMcp-Session-Id, the GET stream, and resuming streams viaLast-Event-IDare gone. Instead, every request carries its own metadata, Streamable HTTP mirrors it in mandatory headers, and long-lived notifications run oversubscriptions/listen. What to expect from older clients and servers is covered in section 4.
1. Stateless: Every Request Stands on Its Own
Up to 2025-11-25, every connection began with a handshake: the client sent initialize, both sides negotiated version and capabilities, and over HTTP the server returned a session ID that the client then had to send with every request. The server remembered per session what had been negotiated.
Since 2026-07-28, that no longer exists. Instead, every single request carries everything the server needs to know in _meta:
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
}A client that wants to know in advance which versions and capabilities a server has calls server/discover – every server must offer it.
Why this is a win: When no request depends on a previous one, any server instance can handle any request. Load balancers no longer need "sticky sessions", servers can restart or scale at any time, and serverless platforms fit without workarounds.
And what if a server does need state? For example, a shopping cart that grows over several calls. Then the server creates a handle itself and returns it as an ordinary result – for instance, create_cart returns {"cart_id": "c_8f3a…"}. Follow-up calls pass the cart_id as a perfectly ordinary tool argument. The state then lives in a database that all instances can reach, and is bound to the signed-in user – not to a connection.
2. stdio: The Local Transport
With stdio, the client launches the server as a subprocess. The rules are short:
- Each message is one line of JSON-RPC (without embedded newlines): the client writes to the server's
stdin, the server responds viastdout. - Nothing else may appear on
stdoutexcept valid MCP messages. A singlefmt.Println("debug")breaks the connection. Logs belong onstderr. - The client can cancel a running request with
notifications/cancelled. - Shutdown: the client closes
stdin; the server should then exit promptly on its own. If it does not respond, the client kills it. - If the server crashes, the client restarts it and simply resends the open requests – thanks to statelessness, nothing is lost that is not already contained in the request.
The format – one JSON line per message over a reliable byte stream – also works unchanged over Unix sockets or TCP. Custom transports on such streams should adopt exactly this format.
3. Streamable HTTP: The Network Transport
3.1 One Endpoint, Every Message a POST
The server offers a single HTTP endpoint, for example https://mcp.my-company.com/mcp. Every client message is its own POST to this endpoint. For each request, the server responds in one of two ways:
application/json– a single JSON object with the result. The simplest case.text/event-stream– an SSE stream that belongs to this request only: first intermediate messages such asnotifications/progress, then the result at the end. After that, the stream closes.
The client must be able to handle both and announces this in the Accept header (application/json, text/event-stream). If the client sends a notification instead of a request, the server responds only with 202 Accepted.

What matters is what no longer works: the server does not send its own requests to the client on any stream. If it needs something from the client – a question to the user, an LLM response – it returns that as an input_required result, and the client retries the request (Chapter 21).
3.2 The Mandatory Headers
Load balancers, gateways, and monitoring should be able to route and evaluate requests without reading the JSON body. That is why the client mirrors the most important fields into HTTP headers:
| Header | Content | Required for |
|---|---|---|
MCP-Protocol-Version |
the protocol version from _meta |
every request |
Mcp-Method |
the JSON-RPC method, e.g. tools/call |
every request |
Mcp-Name |
tool or prompt name, or resource URI | tools/call, prompts/get, resources/read |
Mcp-Param-{Name} |
a tool argument the server has marked with x-mcp-header |
as needed |
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "get_weather", "arguments": { "location": "Seattle" }, "_meta": { … } } }Mcp-Param-… enables, for example, routing by region: if the server marks the argument region with "x-mcp-header": "Region", the client sends Mcp-Param-Region: us-west1, and the load balancer forwards the request to the matching data center.
The body always remains authoritative. If a header differs from it or a mandatory header is missing, the server must reject the request with 400 and the error -32020 (HeaderMismatch). Otherwise an attacker could fool the load balancer with a harmless-looking header while the server executes something else. Values that are not pure ASCII are wrapped by the client as =?base64?…?=; the server decodes them before comparing.
3.3 Notifications and Long-Lived Streams
There are two kinds of notifications from the server:
- Request-scoped – progress (
notifications/progress), log messages: they travel exclusively in the response stream of that very request. - Independent changes – "the tool list has changed", "this resource was updated": for these, the client opens a long-lived stream with a POST to
subscriptions/listenand states which kinds it wants to hear. The server confirms and from then on sends exactly those messages as long as the stream is open.
Two practical rules apply to such long-lived streams: the server should set the header X-Accel-Buffering: no so that reverse proxies such as nginx do not buffer the events. And during quiet periods it should regularly send an SSE comment line (:) so that proxies and clients do not close the connection for inactivity.
3.4 Cancellation and Dropped Connections
If the client wants to cancel a running request, it simply closes that request's response stream. Because every request has its own stream, this is unambiguous; the server must treat it as a cancellation.
The flip side: if a connection drops unintentionally, the running request is lost. Resuming via Last-Event-ID no longer exists – the client sends the request again, with a new ID. For work that takes minutes or hours, this is not a problem but a hint: such operations belong in a task (Chapter 19), whose taskId survives any dropped connection.
3.5 Security at the Endpoint
- Validate
Origin: A server must validate theOriginheader and respond with403for foreign origins. Otherwise a malicious web page can use DNS rebinding to reach a locally running MCP server from the user's browser. - Local means localhost only: A server for local use listens on
127.0.0.1, not on0.0.0.0. - Authentication: All other servers should protect their endpoints – how is described in Chapter 17.
3.6 Implementation with the go-sdk
The go-sdk (v1.8.0) ships with Streamable HTTP. Two options decide whether a server follows the rules of 2026-07-28:
package main
import (
"context"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type GreetIn struct {
Name string `json:"name" jsonschema:"Whom to greet"`
}
func greet(ctx context.Context, req *mcp.CallToolRequest, in GreetIn) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "Hello, " + in.Name + "!"}}}, nil, nil
}
func main() {
s := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "1.0.0"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "greet", Description: "Greets someone"}, greet)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return s },
&mcp.StreamableHTTPOptions{
Stateless: true, // no sessions: every request stands on its own
CrossOriginProtection: http.NewCrossOriginProtection(), // foreign Origin headers → 403
})
http.Handle("/mcp", handler)
// Listen on localhost only – protects against DNS rebinding attacks from the browser
log.Fatal(http.ListenAndServe("127.0.0.1:8931", nil))
}Stateless: trueturns sessions off. Only then does the handler offer version2026-07-28; it answers GET and DELETE with405and ignores anyMcp-Session-Idsent along.CrossOriginProtection(from the Go standard library since Go 1.25) rejects requests with a foreignOriginwith403.
Without these two options the server still runs – a client gets its response to tools/call – but it behaves like a server of the previous version: it does not offer 2026-07-28, accepts foreign origins, and issues sessions.
4. Older Clients and Servers
Many clients and servers still speak a previous version. The specification defines how both worlds get along:
A server for 2026-07-28 only that receives traffic from an older client responds as follows: to GET or DELETE with 405 Method Not Allowed; it ignores an Mcp-Session-Id and issues none itself; it also ignores a Last-Event-ID header.
A client that wants to serve old and new servers first tries a request in the new style. If a 400 comes back, it looks at the body: if it contains a known new error (such as Unsupported Protocol Version with the list of supported versions), the server speaks the new world – the client corrects the request. Only if the body is empty or unknown does it fall back to the old initialize handshake. For stdio, the specification recommends sending server/discover first.
The "HTTP+SSE" transport from 2024-11-05 – two endpoints, a permanent /sse stream for the server → client direction plus a separate POST address – has been superseded since 2025-03-26 and is officially deprecated. New implementations should no longer offer it. If you must serve very old clients, you can keep running the old endpoints alongside the new MCP endpoint.
5. HTTP/2 and Reverse Proxies
When MCP runs over the network, it pays to look at the layer below.
HTTP/1.1 quickly hits its limits: browsers and many clients open only about six concurrent connections per host. Every open subscriptions/listen stream and every streamed response occupies one of them – with several servers behind a proxy (see Chapter 7) they are quickly exhausted.
HTTP/2 solves this:
- Multiplexing: Any number of streams share one TCP connection without blocking each other.
- Header compression (HPACK): The mandatory headers (
MCP-Protocol-Version,Mcp-Method,Mcp-Name) and the bearer token repeat on every request – HPACK transmits them almost for free after the first time. - Long-lived connections: HTTP/2 keeps connections open more reliably than many individual connections.
Reverse proxy settings that are missing again and again in practice:
- Disable buffering for SSE (
X-Accel-Buffering: nofrom the server orproxy_buffering offin nginx). - Set read timeouts high enough for long-lived streams – and still not rely on them, but send keep-alive comments.
- Use the
Mcp-MethodandMcp-Nameheaders for routing, rate limits, and logs instead of parsing the body.
6. Testing with `mcp-tester`
mcp-tester http-check tests an endpoint against the transport rules of 2026-07-28: server/discover, response formats, every mandatory header (missing, mismatched, Base64), error codes, Origin validation, and the reaction to GET, DELETE, and Mcp-Session-Id. MUST violations make the command fail (exit code 1) – suitable for CI (Chapter 15).
For the server from section 3.6, it looks like this (abridged):
$ mcp-tester http-check -u http://127.0.0.1:8931/mcp
=== Streamable HTTP checks (spec 2026-07-28): http://127.0.0.1:8931/mcp ===
[PASS] server/discover supportedVersions [2026-07-28 2025-11-25 …]
[PASS] missing MCP-Protocol-Version header 400, -32020
[PASS] Mcp-Method differs from body 400, -32020
[PASS] unsupported protocol version 400, -32022
[PASS] tools/call without Mcp-Name 400, -32020
[FAIL] Base64-encoded Mcp-Name … the server does not decode =?base64?…?= values
[PASS] foreign Origin header 403
[PASS] GET on the MCP endpoint 405
[PASS] DELETE on the MCP endpoint 405
[PASS] Mcp-Session-Id is ignored no session id minted or echoedThe one remaining failure lies in the go-sdk itself: v1.8.0 does not decode Base64-wrapped Mcp-Name values. This has already been fixed (PR #1242), but will only ship in the next release. For comparison: without the two options from section 3.6, the same server fails three more checks and receives four warnings (foreign origin accepted, GET/DELETE not answered with 405, sessions not ignored).
Takeaways for Developers
If you are planning an MCP server for remote use (as of specification 2026-07-28):
- Use Streamable HTTP: one endpoint, every message a POST, response as JSON or as an SSE stream per request.
- Build the server stateless. If a workflow needs state, return your own handle as a tool argument and keep the state in a shared store.
- Check the mandatory headers against the body and reject mismatches with
-32020; use them in the proxy for routing and logs. - Validate the
Origin, bind local servers to127.0.0.1, and protect all others with OAuth 2.1 (Chapter 17). - Long-running work belongs in tasks, not in a stream that a proxy pause can break.
- Run
mcp-tester http-checkin CI.
← Chapter 15: Automation & CI/CD | Table of Contents | Next Chapter: Security & Authentication →
Copyright Michael Lechner – 2026-08-19, revised 2026-10-09 (specification 2026-07-28: stateless, mandatory headers, subscriptions/listen, Origin validation, tested go-sdk example)