Transports II: Streamable HTTP
One endpoint, required headers, JSON or event-stream replies.
Create a free account Try part of it here
Why it matters
Remote MCP servers, the ones a company runs for its customers, speak Streamable HTTP. It's the transport behind "connect your account" in AI apps, and it's what you'll deploy when you ship a server for others. It uses everything from Phase 8: HTTP methods and status codes, headers, server-sent events, CORS-style origin checks and, in the next lessons, OAuth.
What you'll be able to do
- Send MCP requests over Streamable HTTP with the required headers
- Explain why MCP mirrors body fields into headers, and enforce that they match
- Map protocol outcomes onto HTTP status codes, and read JSON or event-stream replies
- Protect a local HTTP server against DNS rebinding with Origin checks
What it covers
- One endpoint, one POST per message
- Replies: status codes and two content types
- Calling the practice server over Streamable HTTP
- Security: Origin checks and localhost
- A page that tries to reach your local server
- How the transport changed
- Things a remote server can't assume
One endpoint, one POST per message
Think of a company with one mailing address. Every letter goes to the same place, in its own envelope. A Streamable HTTP server has a single URL, the MCP endpoint, such as https://shop.example/mcp. Every JSON-RPC message from the client is its own HTTP POST to that URL. The required headers:
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_campaign
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "get_campaign", "arguments": {"id": "cmp_103"},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}}}}
Acceptmust list bothapplication/jsonandtext/event-stream, because the server may answer either way.MCP-Protocol-Versionrepeats the version from_meta.Mcp-Methodrepeats the method.Mcp-Namerepeats the tool, prompt or resource being used, and is required fortools/call,prompts/getandresources/read.
Why repeat things in headers?
A mailroom sorts by what's written on the envelope, without opening anything. Load balancers, API gateways and monitoring tools sit in front of a server in the same way. They can read headers cheaply, without parsing JSON bodies. With Mcp-Method and Mcp-Name, a gateway can rate-limit one expensive tool, route tools/call to a bigger machine pool, or chart calls per tool.
A copy can disagree with the original, so the server must check. If a header doesn't match the body, the answer is 400 with the HeaderMismatch error (code -32020). Otherwise a gateway could approve "get_campaign" while the server runs "delete_campaign".
calls = ["tools/list", "tools/call"] url = "https://shop.example/mcp" posts = [(url, c) for c in calls] print(len({u for u, _ in posts}), len(posts))
Show the answer
1 2
The set of URLs holds just 1, and there are 2 posts, so it prints 1 2. It's one URL, with a separate POST for each message.
Mcp-Name: get_campaign in its headers, but the body calls the tool delete_campaign. What should the server do?Show the answer
Reject it with 400 and HeaderMismatch (-32020)
A mismatch could trick a gateway that filters on headers. The server must refuse it rather than pick one.
Replies: status codes and two content types
For a request, the server answers with one of two content types, and clients must handle both. It's the difference between one letter and a parcel's tracking updates, which end with "delivered":
Content-Type: application/json: one JSON-RPC response, the simple case.Content-Type: text/event-stream: a stream of server-sent events for this one request. Notifications about it, such as progress updates, come first, and the final response ends the stream:
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t1","progress":50,"total":100}}
data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","content":[{"type":"text","text":"done"}]}}
X-Accel-Buffering: no asks proxies such as nginx not to hold events back in a buffer. For a notification POST, the server answers 202 Accepted with no body.
The spec ties several protocol failures to HTTP statuses, so even code that only reads the status can react sensibly:
| situation | HTTP status | JSON-RPC error |
|---|---|---|
| headers don't match the body | 400 | -32020 HeaderMismatch |
_meta is missing a required field | 400 | -32602 |
| protocol version not supported | 400 | -32022, with the supported list |
| a needed client capability wasn't declared | 400 | -32021 |
| unknown method | 404 | -32601 |
Origin header present but not allowed | 403 | (optional body) |
Over Streamable HTTP, closing the event stream cancels the request, the way hanging up ends a call. Each request has its own stream, so a disconnect is unambiguous, and no cancel message is needed.
One URL and one POST per message. The reply is either JSON or a stream.
Show the answer
404 with JSON-RPC error -32601 (method not found)
The JSON-RPC error tells the client what went wrong; the HTTP status lets gateways see it too.
Calling the practice server over Streamable HTTP
The practice server speaks the 2026-07-28 revision when it sees the modern metadata. It always answers with plain JSON.
import requests URL = "https://api.gradient.test/mcp" META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": {"name": "lesson-client", "version": "1.0"}} def call(method, params=None, name=None, id=1): headers = {"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method} if name is not None: headers["Mcp-Name"] = name body = {"jsonrpc": "2.0", "id": id, "method": method, "params": {**(params or {}), "_meta": META}} r = requests.post(URL, json=body, headers=headers, timeout=10) return r.status_code, r.json() status, reply = call("tools/call", {"name": "get_campaign", "arguments": {"id": "cmp_103"}}, name="get_campaign") print(status, reply["result"]["structuredContent"]["name"]) status, reply = call("tools/call", {"name": "get_campaign", "arguments": {"id": "cmp_103"}}, name="list_campaigns") print(status, reply["error"]["code"], reply["error"]["message"][:40]) status, reply = call("tools/delete") print(status, reply["error"])
The first call succeeds. The second sends a wrong Mcp-Name, so the server refuses it with 400 and -32020. The third names a method that doesn't exist: 404 with -32601. Because each request is independent, there was no setup step at all.
Security: Origin checks and localhost
A server running on your own machine at http://127.0.0.1:8000/mcp seems unreachable from the internet. 127.0.0.1 is the address a computer uses for itself. But a web page you visit runs inside your browser, on your machine. It's a visitor already inside your house, within reach of your intercom. Through a trick called DNS rebinding, where a hostile site changes what its own name points to, the page can make your browser send requests to your local server. So:
- Servers must check the
Originheader on every request. If it's present and not on the allowlist, answer 403. Browsers always sendOrigin, and command-line clients usually don't. - Local servers should listen only on
127.0.0.1, not on every network interface (0.0.0.0). - Remote servers need real authentication: OAuth, covered in a later lesson.
In plain words: "only on my machine" doesn't mean "safe from websites". Check where each request says it came from.
A page that tries to reach your local server
A browser attaches an Origin header to requests a page makes, and page scripts can't fake it. A malicious website can aim at a server on your own machine, so the server must check.
ALLOWED = {"http://localhost:3000", "https://app.shop.example"}
def check_origin(headers):
origin = headers.get("Origin")
if origin is None:
return 200, "no Origin: not a browser page"
if origin in ALLOWED:
return 200, "allowed"
return 403, f"refused {origin}"
print(check_origin({}))
print(check_origin({"Origin": "https://app.shop.example"}))
print(check_origin({"Origin": "https://evil.example"}))The last request is what an attack looks like: you visit a bad site and its script calls your local server. Binding to 127.0.0.1 alone doesn't stop it, because the request comes from your own browser, on your own machine.
How the transport changed
You'll meet older servers, so it helps to know the history. Deprecated below means still around, but on its way out.
- HTTP+SSE (2024-11-05) used two endpoints: one for POSTs, and one long-lived event stream for all replies. It's deprecated.
- Streamable HTTP, legacy revisions (2025-03-26 to 2025-11-25) used one endpoint but kept sessions. The server issued an
Mcp-Session-Idheader atinitialize, and the client sent it on every request. There was also a GET stream for server messages, and resumable streams usingLast-Event-ID. - Streamable HTTP, 2026-07-28 removed sessions, the GET stream and resumption. Long-lived change notifications now come from a
subscriptions/listenrequest, covered later. A broken stream means the client re-sends the request with a new id.
Show the answer
202 Accepted with no body
Notifications get no JSON-RPC reply, so the HTTP answer is 202.
Things a remote server can't assume
Check headers against the body. An envelope marked "invoice" can hold anything. The Mcp-Method header is there so that load balancers can route without reading the body. But a client can send a header that disagrees with the body. If you authorize on the header and act on the body, an attacker gets past your checks. Compare them, and reject a mismatch.
127.0.0.1 doesn't keep web pages out. A page in your browser can send requests to your own machine. That's why Origin checks matter, even for a server nobody else can reach.
Handle both reply types. A client that only reads JSON fails the first time a server answers with an event stream.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
Which header must list both application/json and text/event-stream?