HIROSE PAPER MFG. CO., LTD.

Employees' Blog

What Does an MCP Server Actually Listen For?
Part 2 of 6: putting MCP's Streamable HTTP transport to the test, header by header

Published on: 2026.08.07 Last updated: 2026.08.07
An illustration of a small service counter set on an open map, with envelopes passing back and forth across it, and the words 'Speak the Server's Language' worked into the scene

This is part 2 of a six-part series called “Building an MCP Client in React.” Part 1 laid out the map: MCP (Model Context Protocol) connects a host, a client, and a server over JSON-RPC 2.0, and the 2026-07-28 revision made explicit a split between a legacy era that establishes a session through a one-time handshake and a modern era that states its protocol version on every request. The app we’re building throughout this series turns a natural-language question into SQL, runs it against a database, and renders the result as a table. Inside the host — the app itself — sits a small connector called the MCP client, and it talks to a “gateway to the database”: an MCP server that hands back table definitions and query results. Part 1 stopped at that map. This part gets into what “talking” actually looks like on the wire.

Of the two standard transports, the one you’d actually use from a browser is Streamable HTTP. We’re leaving stdio (the mode where the client talks to a subprocess it launched, over that subprocess’s standard input and output) out of scope for this part, and instead walking through what a single HTTP exchange actually looks like, backed by requests we sent ourselves and logged.

Setup: A Minimal Server and What We Tested Against

Everything below comes from Node.js v22.12.0, the official TypeScript SDK (@modelcontextprotocol/sdk) at version 1.30.0, run on 2026-08-06. As established in part 1, this SDK implements protocol versions only up to 2025-11-25 — it doesn’t speak the 2026-07-28 modern era at all. This part is about the shape of the traffic this SDK actually accepts today, not the modern-era spec text.

The server is the same minimal setup as part 1: a single echo tool, nothing else. We built it stateless — no session ID issued — with an Express route shaped like this.

// Stateless setup. A fresh transport is built per request (reusing one throws).
app.all('/mcp', async (req, res, next) => {
  const mcp = buildMcp();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on('close', () => { transport.close(); mcp.close(); });
  await mcp.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

Every case below sends the same request, a tools/call.

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello"}}}

We used Node’s node:http directly instead of the browser’s fetch. fetch normalizes headers like Accept and Host on its own, which makes it impossible to send a deliberately malformed header — a small but practical thing to know if you ever want to poke at a transport’s behavior by hand.

One POST, Two Possible Replies

The Streamable HTTP spec states that a server exposes exactly one thing: a single HTTP endpoint that accepts POST.

The server exposes a single HTTP endpoint (the MCP endpoint) that accepts POST.

The client sends every JSON-RPC request or notification as its own HTTP POST.

In other words, every JSON-RPC request or notification is sent as its own independent HTTP POST to that one endpoint. This is the mechanism part 1 referred to as “a single endpoint you talk to over HTTP POST.”

There are two shapes a reply can take. The spec lets a server answer either with a single JSON object (Content-Type: application/json) or with an SSE (Server-Sent Events) stream (Content-Type: text/event-stream), and requires the client to be able to handle both. Which one a server chooses is entirely up to it, and it can vary from request to request. In our tests, a tools/call always came back as an SSE stream (see the logs below). Whether you get a single JSON object or a stream is an implementation detail — a client has to be built to read either one.

A diagram of a single POST request branching into two possible reply paths, labeled 'POST', 'application/json', and 'text/event-stream'
Figure 1: One POST splits into two possible replies — a single JSON object or an SSE stream

Accept: Say Both Content Types, or You Don’t Get Read

The spec also puts a requirement on the client side.

The client MUST include an Accept header listing both application/json and text/event-stream as supported content types.

Test it directly, and this requirement turns out to be exactly as strict as it reads.

Accept sentResult
(no header)406
application/json406
text/event-stream406
*/*406
application/json, text/event-stream200

All four failing cases returned the same body (pasted verbatim).

--- response status ---
406 Not Acceptable
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}

The trap here is Accept: */*. It reads as “I’ll take anything,” so you’d expect it to sail through — but in our tests it didn’t. This implementation doesn’t appear to do ordinary content negotiation, where */* matches anything; instead it looks like it’s checking whether the two literal strings application/json and text/event-stream are both present somewhere in the header value (we didn’t trace it down to the source to confirm this). If you’re writing a client against Streamable HTTP, the only safe move is to list both types in Accept, explicitly, every time.

An illustration of a ticket counter waiting for two magic words, 'application/json' and 'text/event-stream', while a visitor who only offers '*/*' is turned away with a '406' sign
Figure 2: The counter won’t proceed until it hears both magic words

No Content-Type, No Reading Your Body at All

Accept has a counterpart on the way in: Content-Type. The spec requires the POST body to be a JSON-RPC request or notification, and this implementation gates that with Content-Type: application/json. Leaving the header off, or sending something else like text/plain, both got the same result: 415 Unsupported Media Type.

406 and 415 mean different things. 406 says “we can’t agree on what format to reply in”; 415 says “we can’t process the format of what you sent.” This implementation decides which of the two errors to return purely from the headers, before it ever looks at the body — so even a perfectly valid JSON-RPC payload gets rejected before anyone reads it, if the headers aren’t right.

MCP-Protocol-Version: Required by the Spec, Optional in Practice

The Streamable HTTP spec defines this header as follows.

Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.

The same section carves out an exception for the transition period, though.

A server that supports clients implementing protocol versions earlier than 2025-06-18 (which did not define the MCP-Protocol-Version header) MAY treat a request that omits the header as protocol version 2025-03-26. A server that does not support such clients MUST reject a request without the header per Server Validation.

In plain terms: a server that wants to support clients built before 2025-06-18 (from back when MCP-Protocol-Version didn’t exist yet) may treat a missing header as version 2025-03-26. A server that doesn’t need to support those older clients must reject a header-less request per its own validation rules. Here’s what we actually got.

Value sentResult
(no header)200
2025-11-25200
2026-07-28400
1900-01-01400

The 400 body (for 1900-01-01, pasted verbatim).

--- response status ---
400 Bad Request
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 1900-01-01 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}

A request with no header at all sailing through as 200 isn’t an accident of a permissive implementation. The spec revision this SDK implements — up through 2025-11-25 — includes this rule:

For backwards compatibility, if the server does not receive an MCP-Protocol-Version header, and has no other way to identify the version – for example, by relying on the protocol version negotiated during initialization – the server SHOULD assume protocol version 2025-03-26.

That’s a SHOULD, not a MUST — a loose recommendation that a server without a version header, and no other way to infer one, should just assume 2025-03-26. The 2026-07-28 spec tightens this: the fallback becomes a narrow MAY, and the default expectation flips to a MUST that rejects header-less requests under Server Validation. Because the SDK we tested doesn’t implement 2026-07-28 (confirmed in part 1), a header-less request going through isn’t a spec violation — it’s just an implementation still following the older revision’s SHOULD. Once again, “where the spec currently stands” and “what you can actually write against the SDK today” need to be kept separate.

Mcp-Method and Mcp-Name: The Header Doubles the Body, and a HeaderMismatch We Never Saw

One of the mechanisms 2026-07-28 adds to Streamable HTTP is duplication: certain fields from the JSON-RPC body get mirrored into HTTP headers as well. The spec defines this mapping.

HeaderBody field it mirrorsRequired when
Mcp-MethodmethodEvery request
Mcp-Nameparams.name or params.uritools/call / resources/read / prompts/get

The point is to let intermediaries — load balancers, gateways — route a request by its kind without having to parse the body. Since the same information now travels two paths at once, the header and the body, the spec also defines what happens when the two disagree.

Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding values in the request body. Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32020 (HeaderMismatch) if any validation fails.

Error code -32020, named HeaderMismatch. This is what the spec says should happen, though — not what the SDK we tested actually did. We deliberately mismatched the two: Mcp-Method: tools/call alongside an Mcp-Name that didn’t match the body’s params.name (echo).

Mcp-Method: tools/call
Mcp-Name: not-the-real-name
--- response status ---
200 OK
--- response body ---
event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}

It came back 200, and the tool ran normally. No -32020. The SDK doesn’t appear to recognize these headers at all, which is presumably why a mismatch between them and the body never gets checked in the first place. That’s consistent with the fact established in part 1: this SDK doesn’t implement 2026-07-28. HeaderMismatch‘s real behavior — say, which value wins when multiple intermediaries are in the path, or how it interacts with a Base64-encoded Mcp-Param-{Name} — is something we’ll need a 2026-07-28-compatible implementation in hand to actually check.

A diagram showing the HTTP headers 'Mcp-Method' and 'Mcp-Name' carrying the same values as the JSON-RPC body fields 'method' and 'params.name', with a 'HeaderMismatch' stamp appearing when they disagree
Figure 3: Mcp-Method and Mcp-Name carry the same values as the body, twice

Origin vs. Host: The Spec’s MUST and What Actually Gets Checked

The spec has a security requirement stated as a single hard sentence.

Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks.

If the Origin header is present and invalid, servers MUST respond with HTTP 403 Forbidden.

DNS rebinding is a family of attacks where a page on some external site, running in the victim’s browser, ends up sending requests to a server on localhost (in our case, the MCP server) without the victim ever meaning to. The spec’s answer to this is: validate the Origin header.

We turned on the SDK’s DNS-rebinding protection and then sent varying Origin and Host values against it.

Header sentResult
Origin: http://evil.example.com200 (tool runs)
Origin: http://localhost:5173200
Host: evil.example.com403

Swapping in a suspicious Origin came back 200, tool call and all. Swapping Host instead got rejected with 403.

--- response status ---
403 Forbidden
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Host: evil.example.com"},"id":null}

Checking the distributed source confirmed it: the DNS-rebinding middleware this SDK ships only validates the Host header. There’s no code path that looks at Origin at all.

This needs to be read carefully, without overstating it either way. A classic DNS-rebinding attack works by having an attacker-controlled domain’s DNS record later resolve to 127.0.0.1. In that scenario, the attacker’s domain name still shows up in the Host header the browser sends, so checking Host catches most of the same attack. In that sense, this isn’t “no protection at all.” But the spec’s literal requirement is validation of the Origin header (a MUST), and read strictly, the implementation we tested doesn’t satisfy it. The spec’s MUST and what the implementation actually checks are two different things — that’s what testing this directly showed.

There’s a separate constraint on top of this, coming from the browser side. Even if the server itself doesn’t validate Origin, that doesn’t automatically mean a browser can hit it directly. We sent the same shape of preflight request (OPTIONS) a browser would send.

OPTIONS /mcp
  Origin: http://localhost:5173
  Access-Control-Request-Method: POST
  Access-Control-Request-Headers: content-type,accept

405 Method Not Allowed
allow: GET, POST, DELETE
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}

A POST using Content-Type: application/json doesn’t qualify as a CORS “simple request,” so a browser is required to send this OPTIONS preflight before it’ll send the real request. Since that preflight fails with 405, a browser can’t call this server directly from a different origin. The 200 response we got earlier didn’t carry an access-control-allow-origin header either.

x-powered-by: Express
cache-control: no-cache, no-transform
connection: keep-alive
content-type: text/event-stream
x-accel-buffering: no
transfer-encoding: chunked

So even though the server itself isn’t checking Origin, the browser’s own CORS mechanism is a separate line of defense that blocks direct cross-origin access. Server-side Origin validation and browser-side CORS are two independent layers of defense, and the absence of one doesn’t imply the absence of the other — worth keeping those two facts distinct rather than conflating them. This test was run from a Node.js HTTP client talking directly to the server; we haven’t checked what an actual browser shows when CORS rejects a request (the console error text, for instance). We’ll cover the browser-side view in part 4.

A diagram contrasting an 'Origin' badge that gets waved through with a '200', against a 'Host' badge that gets checked closely and rejected with a '403', with 'evil.example.com' as the example value
Figure 4: What the server actually checks is Host, not Origin

The Raw Shape of an SSE Reply

We’ve said “it came back as SSE” a few times now — worth actually looking at what that stream contains. The Server-Sent Events spec (WHATWG HTML) defines the event stream as a simple line-based format. event: names the event type (defaulting to message), data: carries the payload, and a blank line separates events.

Here’s what we actually got back (the reply to a tools/call, pasted verbatim).

event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}

The contents of data: is the JSON-RPC response itself, nothing more. The HTTP headers that came with it were these.

content-type: text/event-stream
cache-control: no-cache, no-transform
x-accel-buffering: no
transfer-encoding: chunked

x-accel-buffering: no is a header the spec recommends as a SHOULD.

When initiating an SSE stream, servers SHOULD include the X-Accel-Buffering: no header in the HTTP response.

Reverse proxies like nginx buffer responses by default, holding data back until a chunk builds up before forwarding it. SSE is supposed to deliver each event the moment it happens, so this header tells the proxy to skip that buffering. Without it, SSE loses its immediacy and can end up behaving like slow polling instead.

The spec also recommends that a long-lived stream — the kind something like subscriptions/listen would open — send periodic comment lines (starting with :) as a keepalive. That’s territory we didn’t test here (we don’t have a server implementing subscriptions/listen on hand), so we’re introducing it only as spec text; we’ll pick up the actual measurements in part 5.

A diagram of the raw shape of an SSE response, showing a line reading 'event: message', a line reading 'data: {…}', and the blank line that separates them
Figure 5: The raw shape of an SSE reply — event:, data:, and a blank line

Notifications Get 202, and a Batch Can Come Back in the Wrong Order

JSON-RPC has a message kind called a “notification” — a request with no id. POST one, and in our tests it came back with 202 Accepted, no body.

If the server accepts it, the server MUST return HTTP status code 202 Accepted with no body.

That matches the spec exactly.

There’s another feature JSON-RPC 2.0 itself provides: batching, where you bundle several Request objects into a single array.

To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.

And on the order the responses come back in, the JSON-RPC 2.0 spec is explicit.

The Response objects being returned from a batch call MAY be returned in any order within the Array.

In other words, the Response objects returned from a batch call may come back in any order within the array — and the spec goes on to say a client should match responses by id, not by arrival order. The Streamable HTTP spec’s own wording describes the POST body as a single JSON-RPC request or notification and doesn’t explicitly say an array is allowed there. We sent one anyway, to see what would happen.

Bundling a tools/call with id:1 and a tools/list with id:2 into one array got a 200 back, with both responses arriving over a single SSE stream (line breaks added below for readability; the payload strings themselves are unchanged).

event: message
data: {"result":{"tools":[...]},"jsonrpc":"2.0","id":2}

event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}

The id:2 response arrived first, id:1 second. The order we sent them in and the order the replies came back in are reversed. This is behavior the JSON-RPC 2.0 spec explicitly allows, not a surprising bug. Still, the fact that sending an array against a transport spec written around single messages works at all, and the fact that the response order actually flipped in practice, are both worth keeping as practical notes for anyone writing a client. If you’re ever sending things in a batch-like way, match responses by id, not by arrival order — that assumption should hold from the start, not be something you discover the hard way.

A diagram showing two requests sent in the order 'id:1' then 'id:2', with the replies arriving in the reverse order — 'id:2' first, then 'id:1'
Figure 6: Batched replies don’t always arrive in the order you sent them

Leftovers from the Legacy Era: GET and DELETE Still Answer

Part 1 touched on what the 2026-07-28 revision listed as changes.

Removal of the GET stream endpoint.

Removal of protocol-level sessions.

The OPTIONS /mcp response from the SDK we tested reports allow: GET, POST, DELETE. Sure enough, GET returned 200 and opened a stream that didn’t terminate even after waiting four seconds (with an empty body the whole time). DELETE was also accepted with 200.

Up through 2025-11-25, the spec defined GET as a way to open a separate, server-initiated message stream, and DELETE as a way to explicitly terminate a session. 2026-07-28 removed both from the spec — but the SDK we tested implements only up to 2025-11-25, so naturally, GET and DELETE are both still alive here. When a newer spec revision closes a door, the old behavior keeps running exactly as before, right up until the implementation actually catches up to that revision.

An illustration of two old doors labeled 'GET' and 'DELETE', which the latest spec revision removed but which the implementation still leaves ajar
Figure 7: The spec closed these doors; the implementation left them open

Mapping It Back to the Running Example

Let’s put all of this back onto the running example from the opening.

The MCP client living inside the host sends every request to the database’s MCP server — a Resource read for table definitions, a Tool call to run SQL — as its own independent POST. The server checks that Accept lists both content types first, confirms Content-Type is correct, and only then actually reads the body. The reply can come back as a single JSON object or as an SSE stream, and the client side has to be built to handle either one. Trying to hit this server directly from a browser gets stopped by the CORS preflight before it even gets that far, which means the React side needs a different design decision here. That design decision is part 4’s topic.

What’s Coming Next

Here’s where the threads we didn’t pull on this time get picked up.

Left for laterWhere
How the server side designs its inputSchema and outputSchemaPart 3
Real CORS rejection as seen from a browser, reading SSE, cancellationPart 4
Long-lived subscriptions/listen streams, notifications/progress, the Tasks extensionPart 5
HeaderMismatch‘s real behavior, legacy/modern dual-era compatibility, authorization designPart 6

Wrapping Up

Streamable HTTP is a transport built by stacking a fair number of rules on top of a simple base: POST to one HTTP endpoint. Testing it directly, some of those MUSTs lined up exactly with what the implementation did, and some didn’t. The requirement to list both content types in Accept is strict to the letter — even */* gets rejected with 406. MCP-Protocol-Version is a MUST in the spec, but the SDK we tested let a header-less request through, following the older revision’s SHOULD instead. The Mcp-Method / Mcp-Name duplication and HeaderMismatch that 2026-07-28 added exist as spec text, but this SDK doesn’t check for either yet. The Origin validation MUST isn’t satisfied — what the implementation actually checks is Host — though jumping straight to “that’s dangerous” would be premature, since the browser’s own CORS is a separate defense doing real work here. The raw shape of SSE is simple, but we also ran into a real case of JSON-RPC 2.0’s allowed behavior — a batch’s responses arriving in the reverse of the order they were sent. Keeping the spec’s language separate from the language you can actually write today was the theme running through part 1, and it held true again here. Next time, we dig into what the server side is actually designing, centered on inputSchema and outputSchema.

Articles in This Series

Read from Part 1 and the series runs as one line, from mapping the spec to code that runs in a browser. Each part also stands on its own, so jump around from here whenever you want to.

Primary Sources

  • Streamable HTTP (2026-07-28) — the single-endpoint requirement, Accept / Content-Type rules, MCP-Protocol-Version, Mcp-Method / Mcp-Name and HeaderMismatch, Origin validation, SSE header recommendations, removal of GET/DELETE
  • Streamable HTTP (2025-11-25) — the SHOULD-level fallback for a missing MCP-Protocol-Version, the older GET-stream and session-management rules
  • JSON-RPC 2.0 Specification — batch requests and the rule that response order within a batch is unspecified
  • Server-Sent Events (WHATWG HTML) — the line-based event stream format, event: / data:, and the blank-line separator
Pixcel Art of Aki. holdin a cat.

About the Author

Aki Matsumura

Joined HIROSE PAPER MFG. CO., LTD. in November 2024.

Brings a diverse professional background spanning retail, welfare services, and food service before transitioning into system development.

Currently serves as an in-house systems engineer, responsible for internal database development and system improvement initiatives across the company.

View posts by this author