So far the course has said "MCP is JSON over HTTP" and moved on. That is true of the servers in this series, and it is not the whole story. How the JSON gets from one side to the other is called the transport, and there are two of them.
This page is the one most introductions skip, and it explains a surprising number of the errors you will actually hit.
Part 1 · The two transports
stdio — a local subprocess
The host launches your server as a child process and talks to it over standard input and standard output.
One JSON-RPC message per line. No network, no ports, no auth.
Used by: local dev tools, filesystem servers, anything on your own machine.
Streamable HTTP — a remote endpoint
Your server is an ordinary web service at a URL. The host sends an HTTP POST per message.
Used by: anything hosted, anything shared, and everything in this course.
They carry exactly the same JSON-RPC messages. Nothing above the transport
changes — the same tools/list request, the same tool definitions, the same
results. That separation is the point of having a named transport layer at
all.
Which one you want
| If your server… | Use |
|---|---|
| reads local files, or wraps a local CLI | stdio |
| needs to be reachable by more than one person | Streamable HTTP |
| is deployed anywhere | Streamable HTTP |
| needs no auth because only you can run it | stdio |
All five projects in this course use Streamable HTTP, because they are deployed to Vercel and callable from anywhere.
Part 2 · Streamable HTTP, precisely
The rules are short enough to list in full.
The server exposes one endpoint that accepts POST. Not a family of
endpoints — one URL, e.g. https://example.com/mcp.
Every message is its own POST. There is no persistent connection to maintain, no handshake to keep alive, no socket to reconnect.
The body is a single JSON-RPC request or notification. The client never sends JSON-RPC responses.
The reply is either JSON or an SSE stream, and the server chooses. This is the bit that catches people:
If the body is a JSON-RPC request, the server MUST return either
Content-Type: application/json(a single JSON object) orContent-Type: text/event-stream(an SSE response stream). The client MUST support both.
A notification gets 202 Accepted with no body. Notifications have no
id and expect no reply.
Why a server would choose SSE
Because a single POST can carry progress before it carries an answer.
POST tools/call ──────────────────────────────►
◄── SSE: notifications/progress (33%)
◄── SSE: notifications/progress (67%)
◄── SSE: the actual JSON-RPC response
stream closesA long-running tool can report as it goes, on the response stream of the request it belongs to. A fast tool just answers with plain JSON.
Two operational details worth knowing before production
X-Accel-Buffering: no. Reverse proxies like nginx buffer responses by
default, which accumulates your SSE events and delivers them in a lump —
destroying the entire point of streaming. The spec tells servers to send this
header when opening a stream.
Keep-alive comments. On a long-lived stream, emit an SSE comment line
(: followed by a newline) periodically. Any line starting with a colon is a
comment carrying no data, and it stops intermediaries and idle timeouts from
closing a quiet connection.
Part 3 · The security rules almost nobody implements
These are MUST and SHOULD requirements in the specification, and they exist
because of a specific, real attack.
The specification's three defences:
Validate the Origin header — MUST
Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding attacks. If theOriginheader is present and invalid, servers MUST respond with HTTP 403 Forbidden.The rebound request carries the attacker's origin, and your server refuses it. This is the actual fix; the other two are depth.
Bind to 127.0.0.1, not 0.0.0.0 — SHOULD
When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0).
Binding to
0.0.0.0puts your unauthenticated dev server on every interface, including the coffee shop wifi.Authenticate anyway — SHOULD
Servers SHOULD implement proper authentication for all connections.
Project #2 does this with
withMcpAuthand a shared token — and then discovers that locking a server is a decision with consequences for whoever wants to use it next.
Part 4 · The headers the modern era requires
The 2026-07-28 revision mirrors some body fields into HTTP headers, so that load balancers and gateways can route and rate-limit without parsing the JSON body.
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, WA" }, … } }| Header | Mirrors | Required for |
|---|---|---|
MCP-Protocol-Version | _meta protocol version | every request |
Mcp-Method | method | every request |
Mcp-Name | params.name or params.uri | tools/call, resources/read, prompts/get |
And they are checked against the body
This is the interesting part, and the reason for error code -32020:
Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body. This prevents potential security vulnerabilities when different components in the network rely on different sources of truth.
If you hand-write a client and get a -32020, you have almost certainly
forgotten Mcp-Name on a tools/call, or changed the tool name in one place
and not the other.
Part 5 · What the modern revision took away
If you have read older MCP material, three things you may be expecting are
gone as of 2026-07-28:
| Removed | Was | Now |
|---|---|---|
| The GET stream | A standalone SSE stream, opened with GET, for server-initiated messages | Gone. Use subscriptions/listen, whose response is the stream. |
| Protocol-level sessions | Mcp-Session-Id header, terminated with DELETE | Gone. Requests are self-contained. |
| Server-initiated requests | Servers could send their own JSON-RPC requests down an SSE stream | Gone — replaced by multi round-trip requests. |
A server that only speaks the modern revision should answer GET or DELETE
on its endpoint with 405 Method Not Allowed, ignore any Mcp-Session-Id, and
ignore Last-Event-ID — streams are no longer resumable.
What you now know
- stdio for local subprocesses, Streamable HTTP for anything reachable. Same messages either way.
- One endpoint, one POST per message, and the reply may be JSON or SSE —
which is why
Acceptmust list both, and why omitting one gives a bare406. - Closing the SSE stream is cancellation.
Originvalidation is a MUST, and it exists because of DNS rebinding against unauthenticated localhost servers.- The modern era mirrors
MCP-Protocol-Version,Mcp-MethodandMcp-Nameinto headers, and-32020means they disagree with the body. - Sessions, the GET stream and server-initiated requests were all removed in
2026-07-28.