Index Ai Academy
Phase 9 · Model Context Protocol · Protocol foundations

Transports II: Streamable HTTP

One endpoint, required headers, JSON or event-stream replies.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. One endpoint, one POST per message
  2. Replies: status codes and two content types
  3. Calling the practice server over Streamable HTTP
  4. Security: Origin checks and localhost
  5. A page that tries to reach your local server
  6. How the transport changed
  7. 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": {}}}}
  • Accept must list both application/json and text/event-stream, because the server may answer either way.
  • MCP-Protocol-Version repeats the version from _meta.
  • Mcp-Method repeats the method. Mcp-Name repeats the tool, prompt or resource being used, and is required for tools/call, prompts/get and resources/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".

Quick check
What does this print?
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))
  1. 1 1
  2. 1 2
  3. 2 1
  4. 2 2
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.

Quick check
A request says Mcp-Name: get_campaign in its headers, but the body calls the tool delete_campaign. What should the server do?
  1. Run delete_campaign, because the body is what counts
  2. Run both
  3. Reject it with 400 and HeaderMismatch (-32020)
  4. Run get_campaign, because headers come first
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:

situationHTTP statusJSON-RPC error
headers don't match the body400-32020 HeaderMismatch
_meta is missing a required field400-32602
protocol version not supported400-32022, with the supported list
a needed client capability wasn't declared400-32021
unknown method404-32601
Origin header present but not allowed403(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.

Try it now

One URL and one POST per message. The reply is either JSON or a stream.

Quick check
A Streamable HTTP server receives a request for a method it doesn't know. What does it answer?
  1. 500 Internal Server Error
  2. 404 with JSON-RPC error -32601 (method not found)
  3. 200 with an empty result
  4. No response at all
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.

Worked example

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"])
It runs right here in your browser, with nothing to install.

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 Origin header on every request. If it's present and not on the allowlist, answer 403. Browsers always send Origin, 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.

Worked example

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"}))
In the lesson you can change this and run it.

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-Id header at initialize, and the client sent it on every request. There was also a GET stream for server messages, and resumable streams using Last-Event-ID.
  • Streamable HTTP, 2026-07-28 removed sessions, the GET stream and resumption. Long-lived change notifications now come from a subscriptions/listen request, covered later. A broken stream means the client re-sends the request with a new id.
Quick check
A modern client POSTs a notification to an MCP endpoint, and the server accepts it. What comes back?
  1. 204 with a JSON-RPC error
  2. 202 Accepted with no body
  3. An event stream
  4. 200 with a JSON-RPC result
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

Create a free account

Try a quiz question

Which header must list both application/json and text/event-stream?

  1. Accept
  2. Content-Type
  3. MCP-Protocol-Version
  4. Mcp-Method

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson