MCPサーバーに、どう話しかければ聞いてもらえるのか
連載「ReactでMCPクライアントを作る」第2回 — Streamable HTTPを実際に叩いて確かめる
MCPサーバーへ何かを頼むとき、ただJSONを送るだけでは話を聞いてもらえません。この記事は、全6回の連載「ReactでMCPクライアントを作る」の第2回です。第1回では、MCP(Model Context Protocol)がホスト・クライアント・サーバーという三者をJSON-RPC 2.0でつなぐ仕組みであること、そして2026-07-28のリビジョンでセッションをハンドシェイクで張る legacy era から、リクエストごとに名乗る modern era へ転換が明文化されたことを確認しました。
この連載で組み立てるのは、自然言語の問い合わせをSQLに変換し、データベースへ問い合わせて表を返す小さなアプリです。ホストであるアプリ本体の内部にはMCPクライアントという接続部品があり、それがテーブル定義や実行結果を返す「データベースへの窓口」、つまりMCPサーバーと話をします。第1回はその全体像を描くところで終わりましたが、「話す」という行為そのものの中身にはまだ踏み込んでいません。今回はそこに踏み込みます。
標準トランスポートのうち、ブラウザから使うことになるのはStreamable HTTPです。stdio(クライアントが起動したサブプロセスの標準入出力を使う方式)は今回の対象外とし、1回のHTTPのやり取りが具体的にどんな形をしているかを、実際にリクエストを送って確かめていきます。仕様の文面どおりに動くのか、それとも実装は違う顔を見せるのでしょうか。
目次
実測の前提 — 環境と最小構成のサーバー
以降の実測結果は、Node.js v22.12.0、公式TypeScript SDK(@modelcontextprotocol/sdk)のバージョン1.30.0、実行日2026-08-06の環境によるものです。第1回で確認したとおり、このSDKが実装しているプロトコルバージョンは2025-11-25までで、2026-07-28の modern era の仕様には対応していません。そのため今回は、このSDKが実際に受け付ける通信の形そのものを対象にします。
サーバーは第1回と同じく、echoツールを1つだけ持つ最小構成です。セッションを発行しないステートレスな構成で立て、Expressのルートは次の形にしました。
// ステートレス構成。transport はリクエストごとに作り直す(使い回すと例外になる)
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);
});
送るリクエストはどのケースでも共通で、次のtools/callです。
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello"}}}
検証にはブラウザのfetchではなく、Node.jsのnode:httpを直接使いました。fetchはAcceptやHostのようなヘッダーを実装側で正規化してしまい、意図的に壊れたヘッダーを送れないからです。手元でトランスポートの挙動を試すときの、ちょっとしたコツです。
1本のPOSTに対して、単一JSONかSSEストリームかの分岐
Streamable HTTPの仕様は、サーバーが提供するのは「POSTを受け付ける単一のHTTPエンドポイント」だと定めています。
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.
日本語にすると、「サーバーはPOSTを受け付ける単一のHTTPエンドポイント(MCPエンドポイント)を公開する」「クライアントはすべてのJSON-RPCのリクエストや通知を、それぞれ独立したHTTP POSTとして送る」という意味です。第1回で触れた「1つのエンドポイントへのHTTP POSTでやり取りする方式」という説明は、まさにこの仕組みを指していました。
応答には2通りあります。仕様は、サーバーが単一のJSONオブジェクト(Content-Type: application/json)か、SSE(Server-Sent Events)ストリーム(Content-Type: text/event-stream)のどちらかで応答してよく、クライアントは両方に対応しなければならないと定めています。どちらで返すかはサーバー側の裁量で、リクエストごとに変わってもかまいません。
では、今回の実測ではどちらが返ってきたのでしょうか。tools/callを送ると、常にSSEストリームで返ってきました(このあとのログを参照してください)。単一JSONで返るかSSEで返るかは実装しだいなので、クライアント側は最初から両方読めるように作っておくのが安全です。
Acceptヘッダー: 両方言わないと本文を読んでもらえない
仕様はクライアント側にも義務を課しています。
The client MUST include an Accept header listing both application/json and text/event-stream as supported content types.
「クライアントはAcceptヘッダーに、application/jsonとtext/event-streamの両方を対応する形式として列挙しなければならない」という意味です。実際に試すと、この要件は言葉どおりに厳格でした。
送った Accept | 結果 |
|---|---|
| (ヘッダなし) | 406 |
application/json | 406 |
text/event-stream | 406 |
*/* | 406 |
application/json, text/event-stream | 200 |
4パターンとも、返ってくる本文は同じでした(貼り付けたまま)。
--- 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}
ここでの落とし穴はAccept: */*です。「なんでも受け取れます」という意味のワイルドカードなので、通りそうに見えるかもしれません。ところが実測では通りませんでした。
この実装は一般的なコンテンツネゴシエーション(*/*が任意の型にマッチするという解釈)ではなく、application/jsonとtext/event-streamという2つの文字列がそれぞれ含まれているかどうかを検査しているものと読めます(ソースコードまでは追っていません)。Streamable HTTPを叩くクライアントを書くときは、Acceptに2つの型を明示的に、両方とも書く。これが唯一安全な書き方です。
Content-Typeが無いと、そもそも本文を読んでもらえない
Acceptと対になっているのがContent-Typeです。仕様はPOSTボディがJSON-RPCのリクエストか通知であることを求めており、実装のほうは、これをContent-Type: application/jsonで見分けています。ヘッダーが無い場合と、text/plainのように違う値を送った場合のどちらも、実測では415 Unsupported Media Typeが返りました。
では、406と415は何が違うのでしょうか。406は「返せる形式について合意できない」、415は「送られてきた本文の形式を処理できない」という区別です。この実装ではボディを読む前にヘッダーだけでどちらのエラーになるかが決まるため、JSON-RPCとして正しいペイロードを送っていても、ヘッダーが揃っていなければ中身を見てもらう前に弾かれます。
MCP-Protocol-Versionヘッダー: 仕様は必須、実装は無くても通った
Streamable HTTPの仕様は、このヘッダーを次のように定めています。
Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.
ただし、同じ節の中には移行期のための例外も書かれています。
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.
「2025-06-18より前のバージョンを実装しているクライアント(MCP-Protocol-Versionヘッダーをまだ定義していなかった時代のクライアント)をサポートするサーバーは、ヘッダーを省略したリクエストを、プロトコルバージョン2025-03-26とみなしてよい。そうしたクライアントをサポートしないサーバーは、ヘッダーの無いリクエストをServer Validationの規定に従って拒否しなければならない」という意味です。実際に試した結果は次のとおりです。
| 送った値 | 結果 |
|---|---|
| (ヘッダなし) | 200 |
2025-11-25 | 200 |
2026-07-28 | 400 |
1900-01-01 | 400 |
400の本文(1900-01-01の場合、貼り付けたまま)。
--- 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}
ヘッダー無しのリクエストが200で通ったのは、偶然の緩さではありません。このSDKが実装している2025-11-25までの仕様には、次の規定があります。
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.
「後方互換性のため、サーバーがMCP-Protocol-Versionヘッダーを受け取らず、バージョンを識別する他の手段も無い場合(たとえば初期化時にネゴシエーションされたプロトコルバージョンに頼るなど)は、サーバーはプロトコルバージョンを2025-03-26とみなすべきである」という、SHOULDにとどまる緩やかな規定です。2026-07-28の仕様は、この省略時の扱いを狭いMAYへ絞り込み、原則としてはヘッダー無しのリクエストをServer Validationの規定に従ってMUSTで拒否する方向に変えました。
今回実測したSDKは2026-07-28を実装していない(第1回で確認したとおりです)ので、ヘッダー無しが通るのは仕様違反ではなく、実装がまだ古いリビジョンのSHOULD規定に従っているというだけです。ここでも、「仕様の現在地」と「今日SDKで書ける形」は分けて読んでおきたいところです。
Mcp-Method / Mcp-Name — ヘッダーとbodyの二重化とHeaderMismatch
2026-07-28がStreamable HTTPに追加した仕組みの一つが、JSON-RPCのbodyにある一部フィールドを、HTTPヘッダーへも重ねて載せるという二重化です。具体的な対応は仕様が次のように定めています。
| ヘッダー | 由来するbodyのフィールド | 必須になる条件 |
|---|---|---|
Mcp-Method | method | すべてのリクエスト |
Mcp-Name | params.name または params.uri | tools/call / resources/read / prompts/get |
目的は、ロードバランサやゲートウェイのような中継者が、bodyをパースしなくてもリクエストの種類を見てルーティングできるようにすることです。ただ、ヘッダーとbodyという2つの経路に同じ情報が乗ることになれば、両者が食い違う場合も当然出てきます。仕様はその扱いもきちんと定めています。
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.
エラーコード-32020、名称HeaderMismatchです。ただしこれは仕様が定める挙動であり、今回実測したSDKでの実際の応答ではありません。試しにMcp-Method: tools/callと、bodyのparams.name(echo)とはわざと食い違わせたMcp-Name: not-the-real-nameを付けてリクエストを送ってみました。
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}
200が返り、ツールは正常に実行されました。-32020は返ってきません。SDKがこれらのヘッダーの存在自体を認識していないため、ヘッダーとbodyの食い違いを検査する対象になっていないと考えられます。
これは第1回で確認した、SDKが2026-07-28に未対応であるという事実と整合します。HeaderMismatchの実際の挙動——たとえば複数の中継者を挟んだときにどちらの値が優先されるか、Base64エンコードされたMcp-Param-{Name}との組み合わせでどう扱われるか——は、2026-07-28に対応した実装が手元に来てから確かめるほかありません。
Mcp-Method / Mcp-Name は、bodyの値をヘッダーにも重ねて載せるOrigin検証とHost検証 — 仕様のMUSTと、実装が実際に見ている場所
仕様はセキュリティに関わる要件として、次の1文を掲げています。
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リバインディング攻撃とは、攻撃者が用意した外部のWebページから、被害者のブラウザ経由でローカルホスト上のサーバー(今回の例で言えばMCPサーバー)へ、知らぬ間にリクエストを送らせる攻撃の総称です。仕様はこれをOriginヘッダーの検証で防ぐよう求めていますが、実装は実際にそのとおり動いているのでしょうか。DNSリバインディング対策を有効にしたSDKの構成に対して、OriginとHostをそれぞれ変えて送ってみました。
| 送ったヘッダー | 結果 |
|---|---|
Origin: http://evil.example.com | 200(ツールが実行される) |
Origin: http://localhost:5173 | 200 |
Host: evil.example.com | 403 |
Originを怪しい値に差し替えても200で通り、ツールは実行されました。一方Hostを差し替えると403で弾かれます。
--- response status ---
403 Forbidden
--- response body ---
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Host: evil.example.com"},"id":null}
配布物のソースを確認すると、このSDKが提供するDNSリバインディング対策のミドルウェアはHostヘッダーの検証だけを行っており、Originヘッダーを見る実装は含まれていませんでした。だとすると、この実装は危険と判断してよいのでしょうか。ここは煽らずに、正確に見ておきましょう。
古典的なDNSリバインディング攻撃は、攻撃者が用意したドメインの名前解決を後から127.0.0.1へ切り替えることで成立します。そのシナリオでは、攻撃者のドメイン名がブラウザから送られるHostヘッダーにも乗るため、Hostの検証でも同じ攻撃の多くを防げます。その意味で「保護が無い」わけではありません。
ただし仕様が文言として要求しているのはOriginヘッダーの検証というMUSTであり、その字面どおりに読めば、実測した実装は仕様のMUSTを満たしていません。仕様のMUSTと、実装が実際に見ている場所は違う、というのが今回の実測でわかったことです。
もう一つ、これとは別にブラウザ側の制約があります。Originをサーバー側が検証していないとしても、ブラウザから直接この種のサーバーを叩けるとは限りません。試しに、ブラウザが送るのと同じ形のプリフライトリクエスト(OPTIONS)を送ってみました。
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}
Content-Type: application/jsonを使うPOSTは、CORSの分類上「単純リクエスト」に当たらないため、ブラウザは本番のリクエストを送る前に必ずこのOPTIONSプリフライトを送ります。それが405で落ちる以上、ブラウザから別オリジンでこのサーバーを直接叩くことはできません。実際、200で返る応答のヘッダーにもaccess-control-allow-originは含まれていませんでした。
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
つまり、Originの検証をサーバー自身は行っていなくても、ブラウザのCORSという別の仕組みが、別オリジンからの直接アクセスを止めています。サーバー側のOrigin検証とブラウザ側のCORSは、独立した二段構えの防御です。片方が無いからといって、もう片方まで無いわけではありません。
今回の実測はNode.jsのHTTPクライアントから直接叩いた結果であり、実際のブラウザがこのCORS拒否をどう表示するか(コンソールに出るエラーメッセージなど)までは確認していません。ブラウザ側から見た実装は第4回で扱います。
OriginではなくHostだったSSE応答の生の形
ここまで何度か「SSEで返ってきた」と書いてきましたが、実際には何が流れてくるのでしょうか。Server-Sent Eventsの仕様(WHATWG HTML)は、イベントストリームを行ベースの単純な形式で定義しています。event:でイベント種別(省略時はmessage)を、data:でペイロードを表し、空行がイベントの区切りになります。
実測したレスポンスは次の形でした(tools/callへの応答、貼り付けたまま)。
event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}
data:の中身がJSON-RPCのレスポンスそのものです。付随するHTTPヘッダーは次のとおりでした。
content-type: text/event-stream
cache-control: no-cache, no-transform
x-accel-buffering: no
transfer-encoding: chunked
x-accel-buffering: noは仕様がSHOULDとして推奨しているヘッダーです。
When initiating an SSE stream, servers SHOULD include the X-Accel-Buffering: no header in the HTTP response.
nginxのようなリバースプロキシは既定で応答をバッファリングし、ある程度たまってからまとめてクライアントへ送ろうとします。ところがSSEは、イベントが発生するたびにすぐ届いてほしい仕組みです。そこで、このヘッダーでプロキシにバッファリングを止めるよう伝えます。付け忘れるとSSEが即時性を失い、ただの遅いポーリングのように振る舞いかねません。
仕様には、subscriptions/listenのような長時間開いたままのストリームに対して、コロンで始まるコメント行(:)を定期的に送ってキープアライブにする、という推奨も書かれています。ただしこれは今回検証していない領域(subscriptions/listenを実装したサーバーが手元に無い)なので、仕様文の紹介にとどめ、実測の話は第5回に送ります。
event: と data: と空行通知は202、バッチは要求と逆順で返ってくる
JSON-RPCにはidを持たない「通知」というメッセージ種別があります。idを持たないリクエストをPOSTすると、実測では202 Acceptedが、本文なしで返りました。
If the server accepts it, the server MUST return HTTP status code 202 Accepted with no body.
仕様どおりの挙動です。
もう一つ、JSON-RPC 2.0自体が備えている機能として、複数のRequestオブジェクトを配列にまとめて送る「バッチ」があります。
To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.
そして応答の順序についても、JSON-RPC 2.0の仕様ははっきりと書いています。
The Response objects being returned from a batch call MAY be returned in any order within the Array.
「バッチ呼び出しから返されるResponseオブジェクトは、配列の中でどの順序で返ってきてもよい」という規定です。続けて、クライアント側は届いた順序ではなくidで対応関係を判定すべきだとも書かれています。
一方でStreamable HTTPの仕様の文面は、POSTボディを「単一のJSON-RPCリクエストまたは通知」と定めており、配列をそのまま送ることは明記していません。それでも、実際に配列を送るとどうなるのでしょうか。試してみました。
id:1のtools/callとid:2のtools/listを配列にまとめて送ると、200が返り、1本のSSEストリームに2件のレスポンスが乗って戻ってきました(見やすさのため改行を加えていますが、中身の文字列はそのままです)。
event: message
data: {"result":{"tools":[...]},"jsonrpc":"2.0","id":2}
event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}
先に届くのはid:2のレスポンスで、id:1は後でした。要求した順番と、応答が届く順番が逆になっています。これはJSON-RPC 2.0の仕様が明示的に許している挙動であり、驚くようなバグではありません。
ただ、Streamable HTTPの文面が本来は単一メッセージのPOSTを想定しているところに配列を送ってもここまで動いてしまうという事実、そして応答順が実際に反転したという事実は、クライアントを書くときの実務的な注意として残しておく価値があります。バッチのような送り方をする場面があるなら、届く順序ではなくidでレスポンスを突き合わせる、という前提を最初から崩さないことです。
legacy eraの名残 — まだ残っているGETとDELETE
第1回で、2026-07-28のリビジョンが挙げていた変更点に触れました。
Removal of the GET stream endpoint.
Removal of protocol-level sessions.
実測したSDKでOPTIONS /mcpの応答を見ると、allow: GET, POST, DELETEという値が返っていました。では、仕様がすでに取り除いたはずのこの2つは、実際にはどう動くのでしょうか。
GETは200でストリームが開き、4秒待っても終端しませんでした(本文は空のまま)。DELETEも200で受け付けられます。
2025-11-25までの仕様は、GETでサーバー起点のメッセージを受け取る独立したストリームを開けること、DELETEでセッションを明示的に終了できることを定めていました。2026-07-28はこの両方を仕様から取り除きましたが、今回実測したSDKは2025-11-25までの実装なので、当然ながらまだGETとDELETEが生きています。仕様が新しいリビジョンで扉を閉じても、実装がそこに追いつくまでは、古いふるまいがそのまま動き続けます。
もう一度、アプリの全体像へ
冒頭のアプリに戻りましょう。アプリ本体の中にいるMCPクライアントは、データベースの窓口であるMCPサーバーへ、テーブル定義を求めるResourceの読み取りと、SQLを実行するToolの呼び出しを、それぞれ独立したPOSTとして送ります。窓口はまずAcceptに両方の型が並んでいるかを見て、Content-Typeが正しいことを確かめ、そこでようやくボディの中身を読みます。返ってくるのは単一のJSONかもしれませんし、SSEストリームかもしれません。クライアント側は、そのどちらが来ても読める作りにしておきましょう。
ブラウザから直接この窓口を叩こうとしても、CORSのプリフライトが先に止めます。そのためReact側では別の設計判断が要りますが、それは第4回の話です。
この先扱うこと
今回で拾いきれなかった論点は、次のように各回へ送ります。
| 積み残した論点 | 送り先 |
|---|---|
サーバー側のinputSchemaとoutputSchemaの設計 | 第3回 |
| ブラウザから見た実際のCORS拒否、SSEの読み方、キャンセルの実装 | 第4回 |
subscriptions/listenの長時間ストリーム、notifications/progress、Tasks拡張 | 第5回 |
HeaderMismatchの実挙動、legacyとmodernのデュアルエラ互換、認可の設計 | 第6回 |
まとめ
Streamable HTTPは、1本のHTTPエンドポイントへのPOSTという単純な形の上に、いくつもの規定を積み重ねたトランスポートです。実際にリクエストを送って確かめると、仕様のMUSTがそのまま実装のふるまいと一致する部分もあれば、そうでない部分もありました。
Acceptに両方の型を列挙する要件は文字どおり厳格で、*/*さえも406で弾かれます。MCP-Protocol-Versionヘッダーは仕様のMUSTですが、実測したSDKは省略時に2025-11-25までの仕様のSHOULD規定に従って通しました。
2026-07-28が追加したMcp-Method / Mcp-Nameの二重化とHeaderMismatchは仕様の文言として存在しますが、このSDKはまだそれらを検査していません。Origin検証というMUSTは満たされておらず、実装が実際に見ているのはHostでした。ただしブラウザのCORSという別の防御が働くため、この事実だけを見て危険と判断するのは早計です。
SSEの生の形はシンプルですが、バッチの応答順序が要求順と逆になるという、JSON-RPC 2.0の仕様が許容する挙動にも実際に出会いました。仕様の言葉と、今日書けるコードの言葉を混同しないことは、第1回に続いてこの記事でも一貫したテーマでした。次回は、この土台の上でサーバー側が何を設計しているのか、inputSchemaやoutputSchemaを中心に掘り下げます。
この連載の記事
第1回から順に読むと、仕様の地図を描くところから、ブラウザで動くコードまでが一本の線でつながります。どの回からでも単体で読めるようには書いていますが、前後を行き来したくなったらここから飛んでください。
- 第1回: MCPクライアントは、結局どこにいるのか — ホスト・クライアント・サーバーの三者と、legacy/modernの断層
- 第2回: MCPサーバーに、どう話しかければ聞いてもらえるのか(この記事) — Streamable HTTPの必須ヘッダー、SSE、Origin検証
- 第3回: MCPサーバーの窓口は、何を差し出し、何を隠すべきか — スキーマ、annotations、失敗の通り道
- 第4回: Reactは、MCPサーバーを直接叩けるのか — CORS、BFF、SSEの逐次読み、協調的キャンセル
- 第5回: 重い処理を、MCPはどう見せて、どう止めるのか — 進捗通知、購読、Tasks拡張
- 第6回: その扉を開けるのは、誰の仕事か — 認可の設計、legacy/modernの互換判定、生成SQLの危うさ
参考にした一次情報
- Streamable HTTP(2026-07-28)(単一エンドポイント、
Accept/Content-Type要件、MCP-Protocol-Version、Mcp-Method/Mcp-NameとHeaderMismatch、Origin検証、SSEのヘッダー要件、GET/DELETE削除の記述) - Streamable HTTP(2025-11-25)(
MCP-Protocol-Version省略時のSHOULD規定、GETストリームとセッション管理の旧仕様) - JSON-RPC 2.0 Specification(バッチリクエストと、応答順序が任意でよいという規定)
- Server-Sent Events(WHATWG HTML)(イベントストリームの行ベースの形式、
event:/data:/ 空行の区切り)








