Reactは、MCPサーバーを直接叩けるのか
連載「ReactでMCPクライアントを作る」第4回 — ブラウザから直接叩けるか、SSEの逐次読みと協調的キャンセル
ブラウザで動くReactのコードから、MCPサーバーへ直接問い合わせを送ってよいのでしょうか。この記事は、全6回の連載「ReactでMCPクライアントを作る」の第4回です。第1回でMCP(Model Context Protocol)がホスト・クライアント・サーバーという三者からなることを確認し、第2回では、その三者が実際にはStreamable HTTPという1本のPOSTのやり取りの上でどう話しているかを、Node.jsから実測して確かめました。
第2回の最後には、ブラウザから直接この窓口を叩こうとするとCORSのプリフライトが先に止めるはずだ、というところまで触れて次回送りにしていました。サーバー側がツールやリソースをどう設計するかは第3回で扱ったので、今回はその続きとして、ホストの中でも実際にユーザーの目に触れる場所、つまりブラウザで動くReact側の実装に踏み込みます。
この連載で作っているアプリは、これまでと変わりません。ユーザーがブラウザのテキストボックスに「先月の売上が多かった順に商品を10件教えて」のような自然言語の問い合わせを入力すると、アプリがLLMのAPIでSQLを組み立て、MCPサーバー経由でデータベースに問い合わせて、結果を表で表示します。この一連の流れのうち、「MCPサーバーに問い合わせる」という部分をReactのコードから直接書いてよいのか、それが今回の問いです。
結論から言うと、答えは「叩けない」です。そして、その理由を確かめる過程で、もう一つ見過ごしやすい落とし穴に行き当たりました。ユーザーがブラウザのタブを閉じても、サーバー側の処理は動き続けることがある、という落とし穴です。
目次
オリジンをまたぐ、ということ
まず整理しておきたいのは、オリジンという言葉です。オリジン(origin)とは、スキーム(httpやhttps)・ホスト名・ポート番号の組で決まる「その場所」の単位で、http://127.0.0.1:3601とhttp://127.0.0.1:3602は、ホスト名が同じでもポート番号が違うので、ブラウザから見ると別のオリジンとして扱われます。
ブラウザには同一オリジンポリシー(same-origin policy)という基本ルールがあり、あるページのJavaScriptは既定では自分と同じオリジンのレスポンスしか自由に読み取れません。これは、たとえば別サイトの悪意あるページが、あなたがログイン中の別サービスへ勝手にリクエストを送って情報を盗み出す、といった攻撃からブラウザ利用者を守るための、ブラウザ自身が持つ防御です。
CORS(Cross-Origin Resource Sharing)は、この同一オリジンポリシーを緩めるための仕組みです。サーバー側が「このオリジンからのアクセスは許可します」という意味のヘッダー(Access-Control-Allow-Originなど)を応答に含めれば、ブラウザはオリジンをまたいだ読み取りを許可します。逆に言えば、サーバー側が何も設定しなければ、オリジンをまたぐアクセスは既定で拒否されます。
さらに、Content-Type: application/jsonのようなヘッダーを使うPOSTリクエストは、CORSの分類上「単純リクエスト」に当たりません。Fetch標準は、Content-TypeがCORSの意味で安全(safelisted)とみなされる条件をこう定めています。
If mimeType’s essence is not “application/x-www-form-urlencoded”, “multipart/form-data”, or “text/plain”, then return false.
日本語にすると、「MIMEタイプの本体がapplication/x-www-form-urlencoded・multipart/form-data・text/plainのいずれでもない場合、(安全とはみなさず)falseを返す」という意味です。それ以外のContent-Typeを使うPOSTは単純リクエストとして扱われず、ブラウザは本番のリクエストを送る前に、まずOPTIONSメソッドでプリフライトリクエストを送って確認します。第2回で送っていたJSON-RPCのリクエストはContent-Type: application/jsonを使うため、この条件に当てはまります。
最後にもう一つ、BFF(Backend For Frontend)という言葉も使います。画面を出しているオリジンと同じ場所に小さな中継サーバーを立て、ブラウザはその中継サーバーとだけ話し、別オリジンへの本当のリクエストは中継サーバー自身がサーバー同士の通信として送る、という構成のことです。CORSはブラウザというクライアント側が実装している制約なので、サーバーのプログラムが別のサーバーへリクエストを送るところには働かず、この構成が成り立つのはそのためです。
実測 — 直接叩くと止まり、同じオリジンの中継を挟むと通る
以降の実測は、Node.js v22.12.0、公式TypeScript SDK(@modelcontextprotocol/sdk)バージョン1.30.0、Chromeブラウザ、実行日2026-08-06の環境によるものです。検証用に2つのオリジンを用意し、一方はMCPサーバーそのもの(CORSに関わるヘッダーは一切付けていません)、もう一方は画面を出すオリジンで、そこに/bffという同一オリジンの中継を用意しました。
ツールは、これまでの回と同じechoのほか、進捗を通知しながら数を数える最小限のツールを2種類(キャンセル信号を見ない版と、見る版)用意しました。後者は、「件数の多いSQLを実行しながら経過を報告する」処理の代役です。
別オリジンへ直接
ブラウザの画面から、MCPサーバーのエンドポイントへ直接POSTを送ってみます。
const BODY = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hello' } } };
const HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' };
// 1) 別オリジンの MCP エンドポイントを直接
await fetch('http://127.0.0.1:3601/mcp', { method: 'POST', headers: HEADERS, body: JSON.stringify(BODY) });
Chromeのコンソールに出た内容です(貼り付けたまま)。
[error] Access to fetch at 'http://127.0.0.1:3601/mcp' from origin 'http://127.0.0.1:3602' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
[error] Failed to load resource: net::ERR_FAILED
[log] DIRECT threw: TypeError: Failed to fetch
止まっているのは、プリフライトの段階です。エラー文の中にpreflight requestという語が含まれていることに注目してください。ブラウザは本番のPOSTを送る前にOPTIONSでのプリフライトを送り、その応答にAccess-Control-Allow-Originが無かったために、本番のリクエストそのものを止めています。
MCPサーバー側のコードには、この時点で一切到達していません。第2回で確認したとおり、このSDKの応答にCORSに関するヘッダーは一切付いておらず、OPTIONSは405を返します。つまり、サーバー側がOriginをどれだけ検証しているかという話とは別の層で、ブラウザ自身のCORSという仕組みが先に働いて止めている、ということです。サーバーがOriginを厳密に検証していたとしても、CORSの許可ヘッダーを返していない限り、ブラウザ側のこの防御は同じように作動します。
同一オリジンの中継(BFF)を経由
同じ画面から、今度は同一オリジンの/bffへ同じ内容を送ります。
// 2) 同一オリジンの中継を経由
await fetch('/bff', { method: 'POST', headers: HEADERS, body: JSON.stringify(BODY) });
[log] BFF ok status=200 content-type=text/event-stream; charset=utf-8
[log] BFF body=event: message
data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}
[log] DONE
200が返り、ツールは実行されました。/bffはブラウザから見て画面と同じオリジンなので、CORSの制約そのものが発生しません。中継サーバーは自分自身がサーバーとして、別オリジンのMCPエンドポイントへリクエストを送っており、この経路にブラウザのCORSは関与しません。
このBFFは検証用の最小の実装で、認証・認可・レート制限・タイムアウトのいずれも持っていません。実運用の構成として提示しているものではなく、CORSと、この先で見る逐次読み取りの成立条件だけを確かめるための土台です。
中継はバッファしてはいけない
中継サーバーの作りを間違えると、ここまでの話が台無しになります。たとえば中継の実装を、await upstream.text()のようにMCPサーバーからの応答をいったん全部受け切ってからブラウザへ返す形にすると、SSEストリームが持っていた「届いたそばから流れてくる」という逐次性が消え、すべてのイベントが最後にまとめて届くだけになります。
見た目のステータスコードやレスポンスの形は変わらないため気づきにくい失敗ですが、次に見る進捗通知のリアルタイム性は、この一点にかかっています。中継を書くときは、受け取ったバイト列を溜め込まず、そのままブラウザ側のストリームへ流し続けましょう。
ブラウザでSSEを逐次読む — 進捗と結果は同じ管を流れてくる
fetchの応答が持つresponse.bodyは、逐次読み出せるReadableStreamです。WHATWGのStreams標準はこれを、データの出どころを表し、そこからチャンクを一つずつ読み出せるものとして定義しています。中継経由の応答をこのストリームとして読み、空行(SSEのイベント区切り)でフレームに切り出し、到着した時刻とともに記録してみます。
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) !== -1) {
events.push({ atMs: Date.now() - t0, frame: buf.slice(0, i) });
buf = buf.slice(i + 2);
}
}
進捗を5回通知しながら数えるツールを_meta.progressToken付きで呼んだときの実測です(到着時刻つき、貼り付けたまま)。
956ms event: message
data: {"method":"notifications/progress","params":{"progressToken":"p1","progress":1,"total":5,"message":"step 1"},"jsonrpc":"2.0"}
1655ms event: message
data: {"method":"notifications/progress","params":{"progressToken":"p1","progress":2,"total":5,"message":"step 2"},"jsonrpc":"2.0"}
2361ms … progress 3
3070ms … progress 4
3783ms … progress 5
3784ms event: message
data: {"result":{"content":[{"type":"text","text":"counted to 5"}]},"jsonrpc":"2.0","id":1}
約0.7秒おきに5件の進捗通知が先に届き、最後に最終レスポンスが届きます。仕様は、SSE応答ストリームの上でサーバーが何を送ってよいかをこう定めています。
The server MAY send JSON-RPC notifications — for example, notifications/progress or notifications/message — before the final response. These notifications MUST relate to the originating client request.
日本語にすると、「サーバーは、最終レスポンスの前にnotifications/progressやnotifications/messageのようなJSON-RPCの通知を送ってもよい。これらの通知は、もとになったクライアントのリクエストに関連するものでなければならない」という意味です。実測はこの記述どおりに動いており、進捗トークンについても仕様は文字列か整数でなければならず、有効なリクエストをまたいで一意でなければならないと定めています。
ここで受け手が仕分けに使えるのはidの有無です。JSON-RPCの通知(notifications/progress)にはidがなく、一方、最終レスポンス(上のログでいう最後の1件)にだけid: 1が付いています。
同じストリームの上に、進捗通知と最終レスポンスという性質の異なる2種類のメッセージが混ざって流れてくるため、ブラウザ側は「idがあるものが最終結果、無いものが通知」という基準で仕分けます。作っているアプリで言えば、件数の多いSQLを実行しているあいだ「現在◯件処理しました」と進捗を出しておき、最後に届いたレスポンスだけを結果の表へ流し込む、という形になります。
キャンセルの落とし穴 — タブを閉じても、処理は生きている
クライアント側の中断そのものは、素直に動きます。AbortControllerを使ってfetchを2.5秒後に中断すると、次のようになりました。
outcome: "AbortError: BodyStreamBuffer was aborted"
framesReceived: 3
クライアント側は3件のフレームを受け取ったところで例外になり、それ以上待ちません。ここまでは予想どおりです。
問題はサーバー側で、しかもここは仕様の版を取り違えると読み違える箇所です。2026-07-28のStreamable HTTPの仕様は、SSE応答ストリームが閉じられることをキャンセルの合図として明確に位置づけています。
Closing the SSE response stream MUST be treated by the server as cancellation of that request. Because each request has its own response stream, the transport-level disconnect is unambiguous. The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it.
日本語にすると、「SSE応答ストリームが閉じられることは、サーバーによってそのリクエストのキャンセルとして扱われなければならない。各リクエストは自分専用の応答ストリームを持つため、トランスポート層での切断に曖昧さはない。サーバーはキャンセルされたリクエストの処理を、実用的な範囲でできるだけ速やかに止めるべきであり、それ以降そのリクエストに関するメッセージを一切送ってはならない」という意味です。ただし、これは2026-07-28の文言です。
第1回で確認したとおり、今回実測している公式TypeScript SDK(1.30.0)が実装しているのは2025-11-25までで、この版のStreamable HTTPは、むしろ逆のことを定めています。
Disconnection SHOULD NOT be interpreted as the client cancelling its request. To cancel, the client SHOULD explicitly send an MCP
CancelledNotification.
日本語にすると、「切断は、クライアントがリクエストをキャンセルしたものと解釈すべきではない。キャンセルしたい場合、クライアントはMCPのCancelledNotificationを明示的に送るべきである」という意味です。つまり、「ストリームを閉じる=キャンセル」というMUSTは、今回実測しているSDKが対象としていない将来の版の話であり、SDKが実装している版はむしろ逆を定めていることになります。
それでも実際に試すと、キャンセル信号を見ない版のツール(10ステップ数えるツール)で、トランスポート層は切断を検知していました(サーバーのログ、貼り付けたまま)。
[server] slow_count step 1/10
[server] slow_count step 2/10
[server] slow_count step 3/10
[bff] downstream closed early -> aborting upstream
[bff] streaming stopped: AbortError
[server] response closed (client disconnect or normal end)
[server] slow_count step 4/10
[server] slow_count step 5/10
[server] slow_count step 6/10
[server] slow_count step 7/10
[server] slow_count step 8/10
[server] slow_count step 9/10
[server] slow_count step 10/10
response closedというログが出ています。つまり、CancelledNotificationのような明示的な通知を送っていなくても、トランスポート層は切断そのものを認識しています。しかし、ツールのハンドラは10ステップ目まで、最後まで走り切りました。
これがSDKが2025-11-25のSHOULD NOTにそのまま従っているために起きているのか、単に別の理由でハンドラを自動停止させていないだけなのかは、ソースコードを精読していないため断定しません。確かなのは、「仕様がMUSTと定めているのだから実装もそう動くはずだ」という読み方が、ここでは成り立たないということです。この連載が第1回から繰り返してきた「仕様の現在地と、今日書ける形を分けて読む」という規律は、キャンセルの話にもそのまま効いてきます。
キャンセルは協調的(cooperative)です。ハンドラの中身はただのJavaScriptの関数にすぎません。
外側から強制終了させられているわけではなく、ハンドラ自身が「自分は中断されたか」を確認して初めて止まります。これはAbortControllerが持つ、もっと一般的な性質でもあります。DOM標準はAbortSignalについて、次のように書いています。
Changes to an AbortSignal object represent the wishes of the corresponding AbortController object, but an API observing the AbortSignal object can choose to ignore them.
日本語にすると、「AbortSignalオブジェクトへの変化は、対応するAbortControllerオブジェクトの意思を表すが、そのAbortSignalオブジェクトを観測しているAPI側は、それを無視するという選択もできる」という意味です。今回のslow_countハンドラは、まさにこのextra.signalを一度も見ていなかったため、意思表示を無視し続けた形になります。
実際、同じ処理でextra.signalを見るだけの版に替えると、挙動は変わります。
async ({ steps }, extra) => {
for (let i = 1; i <= steps; i++) {
await new Promise(r => setTimeout(r, 700));
if (extra && extra.signal && extra.signal.aborted) {
throw new Error('cancelled');
}
// …
}
}
[server] slow_count_cancellable step 1/10
[server] slow_count_cancellable step 2/10
[server] slow_count_cancellable step 3/10
[bff] downstream closed early -> aborting upstream
[bff] streaming stopped: AbortError
[server] response closed (client disconnect or normal end)
[server] slow_count_cancellable ABORTED at step 4/10
こちらは4ステップ目で止まります。では、ハンドラがextra.signal.abortedを確認してさえいれば、それだけで止まるのでしょうか。ここまでの結果だけを見るとそう読めますが、それだけでは足りませんでした。
同じextra.signal.abortedを確認するハンドラを、配線だけを変えた2つのルートに置いて比べました。一方はres.on('close', () => { transport.close(); mcp.close(); })という、接続が閉じたときにtransport.close()を呼ぶ配線を持ち、もう一方はこの配線を持ちません。どちらも1.8秒後にクライアント側から接続を切った実測です(貼り付けたまま)。
########## A: res.on(close) の配線あり ##########
[wired] step 1/10
[wired] step 2/10
[wired] step 3/10
>>> client destroys the connection for /wired
[wired] ABORTED at step 4/10
########## B: 配線なし ##########
[unwired] step 1/10
[unwired] step 2/10
[unwired] step 3/10
>>> client destroys the connection for /unwired
[unwired] step 4/10
[unwired] step 5/10
[unwired] step 6/10
[unwired] step 7/10
[unwired] step 8/10
[unwired] step 9/10
[unwired] step 10/10
ハンドラのコードは2つのルートでまったく同じです。違うのは、切断をtransport.close()へつなぐ配線があるかどうかだけです。配線が無いと、ハンドラがextra.signalを確認するコードを持っていても、そのsignal自体がabortされないため、最後まで走り切ります。
つまり、キャンセルを実際に効かせるには、切断をtransport.close()へつなぐ配線と、ハンドラがextra.signalを確認することの、両方が要ります。どちらか一方だけでは足りません。ユーザーが「もう結果はいらない」とタブを閉じても、この配線とハンドラ側の確認が両方そろっていなければ、裏側では長いSQLが動き続けているかもしれません。
だれが同意を取るのか、APIキーはどこに置くべきか
ここまでの2つの実測は、設計上の判断にもつながります。では、誰が同意を取り、APIキーはどこに置くべきなのでしょうか。
まず、ブラウザに届くJavaScriptはすべて、その気になれば開発者ツールやネットワークタブで読める状態にあります。恒久的に使い回すAPIキーのような秘密情報を、ブラウザ側のコードや設定に直接埋め込むと、それはもう秘密ではなくなります。中継(BFF)を挟む構成には、CORSを回避するという理由だけでなく、こうした秘密情報をブラウザの外側に置く場所を作る、という意味もあります。
一方で、「誰が同意を取るか」という問題は残ります。仕様のSecurityの節は、次のような原則を掲げています。
Hosts must obtain explicit user consent before invoking any tool Users should understand what each tool does before authorizing its use
日本語にすると、「ホストは、いかなるツールを呼び出す前にも、ユーザーの明示的な同意を得なければならない」「ユーザーは、その使用を許可する前に、各ツールが何をするのかを理解しているべきである」という意味です。同じ節はデータについても同様の原則を掲げています。
Hosts must obtain explicit user consent before exposing user data to servers Hosts must not transmit resource data elsewhere without user consent
「ホストは、ユーザーのデータをサーバーに晒す前に、明示的なユーザーの同意を得なければならない」「ホストは、ユーザーの同意なしにリソースのデータを他へ送信してはならない」という意味です。第1回で確認したとおり、ホストとはユーザーと直接向き合い接続を開始するアプリケーションのことで、今回でいえば、ブラウザで動くReactのアプリそのものがホストです。
つまり、同意を求めるUI(「このツールを実行してよいですか」という確認)を持つべきなのはホストであり、それはブラウザの中、ユーザーの目の前にあるReactのコードの役割です。一方で、その同意を得たあとに実際にMCPサーバーへ話しかける経路と、そのために必要な認証情報の管理は、ブラウザの外、つまり中継サーバー側の役割になります。
「同意を取る場所」と「秘密情報を持つ場所」は、意図的に分けて設計する必要があるというのが、ここまでの実測と仕様の原則を合わせて言えることです。なお、この記事で検証した中継は、この同意フローや鍵の管理そのものを実装したものではありません。CORSと逐次読み取りが成立することだけを確かめた最小の土台であり、その上に何を積むかは、これから設計する側の仕事です。
React側の状態設計(一般論)
ここまでの実測は、Reactの状態設計にそのまま跳ね返ってきます。ただし以下は一般的な設計の指針であり、Reactでの実装そのものを検証したものではありません。
- 実行中の表示:
_meta.progressTokenを添えてリクエストを送り、届いたnotifications/progressのprogress・total・messageを状態に反映すれば、プログレスバーや「◯件処理中」のような表示を組み立てられます。 - キャンセル操作: キャンセルボタンは
AbortController.abort()を呼ぶだけで、クライアント側の表示を止めることはできます。ただし今回確かめたとおり、サーバー側で切断をtransport.close()へつなぐ配線と、ハンドラがextra.signalを確認する実装の、両方が揃っていない限り、サーバー側の処理そのものは止まりません。UI側で「キャンセルしました」と表示することと、サーバー側で実際に処理が止まったことは、別の事実です。 - 結果の表描画: 届いたメッセージのうち
idを持つものだけを最終結果として扱い、テーブルの描画に使います。idを持たない進捗通知は、表の描画ではなく進捗表示の更新にだけ使います。
この先扱うこと
今回で拾いきれなかった論点は、次の回に送ります。
| 積み残した論点 | 送り先 |
|---|---|
Tasks拡張による非同期実行とtasks/cancel、subscriptions/listenの長時間ストリーム | 第5回 |
| 認可の設計、legacyとmodernのデュアルエラ互換、生成SQLをそのまま実行してよいか | 第6回 |
まとめ
ブラウザのReactから、別オリジンのMCPサーバーをfetchで直接叩くことはできません。Content-Type: application/jsonを使うPOSTはCORSの単純リクエストに当たらないため、プリフライトの段階でブラウザ自身に止められ、サーバー側のコードには到達しません。同一オリジンの中継を挟めば200で通りますが、中継はバッファせずにストリームをそのまま流す実装にしないと、SSEの逐次性が消えます。
ブラウザでresponse.bodyをReadableStreamとして読めば、進捗通知と最終レスポンスが同じストリームの上に混ざって届くので、idの有無で仕分けます。そして最大の落とし穴は、キャンセルが協調的だという点でした。
AbortControllerで中断すれば、切断はサーバーのトランスポート層まで届きますが、切断をtransport.close()へつなぐ配線と、ツールのハンドラがextra.signalを確認することの、両方が揃わない限り、処理は最後まで走り切ります。今回実測したSDKが実装している2025-11-25の仕様は、そもそも切断をキャンセルと解釈すべきではないと定めており、2026-07-28のMUSTをそのままこの実装の説明に使うことはできません。
それでもブラウザのタブを閉じても、サーバー側の処理は走り続けうる、というのが今回の実測から得られた、いちばん実務的な教訓です。同意をどこで取り、秘密情報をどこに置くか。その設計は、この事実を踏まえたうえで考えたいところです。次回は、この続きとして、Tasks拡張による非同期処理とキャンセルの仕組みを掘り下げます。
この連載の記事
第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)(Security & Endpointの
Origin検証要件、SSE応答での通知の送り方、Cancellationの節の記述) - Transports(2025-11-25)(今回実測したSDKが実装する版の切断の扱い、
Disconnection SHOULD NOT be interpreted as cancellingの記述) - Progress(2026-07-28)(
progressTokenの要件、進捗通知の送られ方) - MCP Specification(latest)(Security and Trust & Safetyの節、ユーザー同意とデータプライバシーの原則)
- Fetch Standard(CORS-safelisted request-headerの定義、
Content-Typeが単純リクエストとみなされる条件) - Streams Standard(
ReadableStreamの定義) - DOM Standard(
AbortController/AbortSignalの定義、シグナルを無視できるという記述)








