結論

MCP の Streamable HTTP は、1つの URL(MCP エンドポイント)へ JSON-RPC を1件ずつ POST し、サーバーが「JSON 1個」か「その要求専用の SSE ストリーム」で答える、リモート MCP サーバー用の通信方式です。2025-03-26 版で以前の HTTP+SSE(2本のエンドポイント)を置き換え、2026-07-28 版ではセッション(Mcp-Session-Id)・initialize・GET のストリーム・Last-Event-ID による再開が仕様から外れました。

この記事でわかること

  • Python SDK(mcp 2.3.0)で作ったサーバーに curl で話しかけた、initialize→Mcp-Session-Id→tools/list→tools/call→DELETE の HTTP の原文と、SSE で返る場合・JSON で返る場合の違い
  • 進捗通知・切断後の再開(Last-Event-ID)・ステートレス運用・構造化された出力(outputSchema と structuredContent)・2026-07-28 版の1回完結の POST を実測した結果
  • 仕様の版ごとの違いの表、Origin の検証と 127.0.0.1 への bind で SDK の既定がどう変わるか、認可(OAuth)の公式の流れの概要、よくあるエラー原文と判断表

2026年11月4日更新|mcp 2.3.0(Python SDK)・Python 3.13.18・uvicorn 0.54.0・curl 8.5.0 で実行確認

この記事で実際に動かしたもの:PyPI の mcp 2.3.0 で書いた MCP サーバー(足し算・固定値の天気・進捗を出すカウンターの3ツール。LLM は使いません)を 127.0.0.1 の空きポートで起動し、curl と SDK のクライアントで 2024-11-05・2025-11-25・2026-07-28 の3つの版の通信を流しました。

sequenceDiagram
    participant C as クライアント
    participant S as MCP サーバー
    C->>S: POST initialize
    S-->>C: 200 + Mcp-Session-Id
    C->>S: POST initialized 通知
    S-->>C: 202 Accepted
    C->>S: POST tools/call
    S-->>C: SSE 進捗 → 結果
    C->>S: DELETE
    S-->>C: 200

上の図は 2025-11-25 版までの流れです。2026-07-28 版では最初の2往復と DELETE が無くなり、tools/call などの POST 1回ごとに完結します(後の節で原文を並べます)。よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
mcp streamable http とは 1つの URL に POST で JSON-RPC を送り、JSON か SSE で受け取る方式。リモート MCP サーバーの標準 Streamable HTTPとは
mcp sse 廃止とは 2024-11-05 版の HTTP+SSE(/sse と /messages の2本)が 2025-03-26 版で置き換えられ、2026-07-28 版で Deprecated に分類された 仕様の版ごとの違い
mcp リモートサーバーの作り方 Python なら MCPServer の streamable_http_app() を uvicorn で 127.0.0.1 に起動。既定のパスは /mcp Python SDK で作って通信を見る
Mcp-Session-Id はいつ付く 2025-11-25 版までは initialize の応答で発行され、以後の全要求に付ける。2026-07-28 版には無い 通信の中身(2025-11-25)
SSE と JSON のどちらで返る サーバーが要求ごとに選ぶ。クライアントは両方を受けられる必要がある。SDK は json_response=True で JSON 固定 SSE で返る場合と JSON で返る場合
切断したら結果は消える? 2025-11-25 版はイベントストアがあれば Last-Event-ID で続きを受け取れる。2026-07-28 版は再開が無く、要求を出し直す 切断と再開
ステートレスにできる? SDK は stateless_http=True。2026-07-28 版は仕様そのものがステートレス ステートレス運用
Origin の検証は必要? 仕様は MUST。不正な Origin には 403。SDK は host が 127.0.0.1 なら自動で有効、0.0.0.0 だと既定で外れる Origin の検証と 127.0.0.1 への bind
認証・認可はどうする HTTP では OAuth 2.1 ベースの仕様に従うのが SHOULD。サーバーは 401 と Protected Resource Metadata を返す 認可(OAuth)の流れの概要

確認環境:Ubuntu 24.04.5 LTS(2 CPU・メモリ 8GB の共有マシン)上の Python 3.13.18 で、作業用フォルダーの venv に PyPI の mcp 2.3.0(同時に入った mcp-types 2.3.0・starlette 1.10.0・sse-starlette 3.6.0・uvicorn 0.54.0・pydantic 2.14.2・httpx2 2.16.0)を入れました。サーバーはすべて 127.0.0.1 の空きポートで起動し、PID を控えて SIGTERM で止めています。通信は curl 8.5.0 で原文を取り、SDK のクライアント(mcp.Client)の要求は、サーバー側に挟んだ自作の記録用ミドルウェアで1行ずつ記録しました。LLM の API やモデルの重みには接続していないため、ツールは固定の計算と固定応答だけです。仕様の版ごとの違い・認可・セキュリティの記述は、modelcontextprotocol.io の各版の仕様ページ(Transports・Changelog・Authorization・Security Best Practices・Tools・Versioning)で確かめた範囲だけを書いています。外部の認可サーバーは使わず、認可は 401 と Protected Resource Metadata を返すところまでを手元で確かめました。図は mermaid 11 系で描画を確かめています(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MCP Streamable HTTPとは|リモートサーバーの作り方と通信の中身」

MCP の Streamable HTTPとは(stdio・以前の HTTP+SSE との違い)

この節では、Streamable HTTP が MCP のどの層の話で、stdio や以前の HTTP+SSE と何が違うのかが分かります。

MCP(Model Context Protocol)のメッセージは JSON-RPC 2.0 で、Streamable HTTP はそれを HTTP で運ぶ「トランスポート」の1つです。公式仕様の Transports のページは、標準のトランスポートとして stdio と Streamable HTTP の2つを定めています。stdio はクライアントがサーバーを子プロセスとして起動し、標準入出力で1行1メッセージをやり取りする方式で、同じマシンの中だけで完結します。Streamable HTTP は、サーバーが独立したプロセスとして複数のクライアントの接続を受ける方式で、ネットワーク越しに使う「リモート MCP サーバー」はこちらを使います。なお「リモート MCP」という呼び方は仕様の用語ではなく、仕様が使うのは「MCP エンドポイント」という言葉です。

1エンドポイントは1つ

サーバーは POST を受ける1つのパス(例 /mcp)を持つ。2025-11-25 版までは同じパスで GET(通知用の SSE)と DELETE(セッション終了)も扱う。

21メッセージ=1 POST

クライアントは JSON-RPC の要求・通知を1件ずつ POST する。Accept には application/json と text/event-stream の両方を書く。

3応答の形はサーバーが選ぶ

要求には JSON 1個か SSE ストリームで答える。SSE なら最終の応答の前に進捗などの通知を流せる。通知の POST には 202 で本文なし。

以前の HTTP+SSE(2024-11-05 版)は、クライアントがまず GET で SSE の接続を張り、サーバーが最初の endpoint イベントで「メッセージを POST する先の URL」を教え、以後の応答はすべてその SSE 接続に流す、という2本立てでした。手元で SDK の sse_app() を起動して GET /sse を開いた原文です。

$ curl -sS -i -N http://127.0.0.1:35627/sse
HTTP/1.1 200 OK
content-type: text/event-stream; charset=utf-8

event: endpoint
data: /messages/?session_id=0bc63195af3f418a82dd57b556eef640

(別の端末で /messages/?session_id=... に initialize を POST すると、POST の応答は 202 Accepted だけ)

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{...},"protocolVersion":"2024-11-05","serverInfo":{"name":"t249-legacy-sse","version":"0.1.0"}}}

HTTP+SSE(2024-11-05)

GET /sse と POST /messages の2本のエンドポイント

応答は POST の戻りではなく、張りっぱなしの SSE に届く

SSE の接続が切れると応答の行き先が無くなる

Streamable HTTP(2025-03-26〜)

POST を受ける1本のエンドポイント

応答はその POST の戻り(JSON か、その要求専用の SSE)

ふつうの HTTP の要求・応答として扱え、通知が要らなければ SSE も要らない

MCP の全体像(ホスト・クライアント・サーバー、ツール・リソース・プロンプト)は、関連記事「MCPとは?サーバーとクライアントの仕組みと設定例を図解」で扱っています。この記事は、その中のトランスポート、とくに HTTP の上で何が流れるかに絞ります。

仕様の版ごとの違い(2024-11-05・2025-03-26・2025-06-18・2025-11-25・2026-07-28)

この節では、Streamable HTTP にかかわる決まりが仕様の版ごとにどう変わったかが、公式の仕様ページで確かめた範囲で分かります。

MCP の仕様は「最後に後方互換でない変更をした日付」を版の名前にしています。公式の Versioning のページでは、2026年11月4日時点の current(現行)は 2026-07-28 です。Streamable HTTP は 2025-03-26 版で入り、2026-07-28 版で形が大きく変わりました。各版の Transports と Changelog のページから、トランスポートにかかわる差分だけを抜き出した表です。

版 トランスポートの形 主な変更(公式の記述)
2024-11-05 stdio と HTTP+SSE SSE のエンドポイントと POST のエンドポイントの2本。接続時に endpoint イベントで POST 先を通知
2025-03-26 stdio と Streamable HTTP HTTP+SSE を置き換え。1本の MCP エンドポイント、Mcp-Session-Id、GET の SSE、Last-Event-ID での再開。POST 本文に JSON-RPC のバッチ(配列)も可
2025-06-18 同上 JSON-RPC のバッチを削除。以後の要求に MCP-Protocol-Version ヘッダーが MUST。ヘッダーが無いときサーバーは 2025-03-26 とみなす(SHOULD)。構造化されたツール出力、サーバーを OAuth の Resource Server と位置づけ
2025-11-25 同上 不正な Origin には 403 を返すと明記。SSE の開始直後に「イベント ID と空の data」を送る、サーバーは接続を任意に切ってよく retry を守って再接続(ポーリング)。再開は元が POST でも常に GET。Client ID Metadata Documents を推奨の登録方法に
2026-07-28 stdio と Streamable HTTP(作り直し) セッションと Mcp-Session-Id、initialize の往復、GET のストリーム、Last-Event-ID の再開を削除。server/discover を必須に。全 POST に Mcp-Method(と Mcp-Name)ヘッダー。HTTP+SSE は Deprecated に分類

2026-07-28 版の Changelog は、主な変更の1番目に「Remove protocol-level sessions and the Mcp-Session-Id header from the Streamable HTTP transport.」、2番目に「Make MCP stateless: remove the initialize/notifications/initialized handshake.」と書いています。サーバーからクライアントへの要求(sampling・elicitation・roots)は、SSE で別の要求を送る形から、結果の中に InputRequiredResult を返してクライアントが元の要求をやり直す MRTR(Multi Round-Trip Requests)に変わり、変更通知の長いストリームは GET ではなく subscriptions/listen という POST の応答ストリームになりました。

5版SDK 2.3.0 が知っている版2024-11-05 から 2026-07-28 まで
7→4回同じ作業の HTTP 要求の数2025-11-25 の流れと 2026-07-28 の流れ(実測)
0件2026-07-28 の Mcp-Session-Idヘッダーを送っても無視された

Python SDK 2.3.0 の中にある版の一覧(mcp_types/version.py)も、この区切りどおりです。initialize で合意する「handshake の版」が 2024-11-05〜2025-11-25 の4つ、要求ごとに版を書く「modern の版」が 2026-07-28 の1つに分かれています。

KNOWN_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28")
HANDSHAKE_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
MODERN_PROTOCOL_VERSIONS = ("2026-07-28",)

つまり 2026年11月時点のクライアント・サーバーには、両方の時代の相手が混ざります。以降の節では、まだ多くの実装が話している 2025-11-25 版の流れを先に見て、2026-07-28 版を後に並べます。

Python SDK(mcp 2.3.0)で作り、initialize から tools/call までの通信を見る

この節では、Streamable HTTP の MCP サーバーの最小構成と、2025-11-25 版で initialize からツール呼び出しまでに流れる HTTP の原文が分かります。

mcp 2.x では、1.x の FastMCP が MCPServer に名前を変えています。1.x のコードのまま from mcp.server.fastmcp import FastMCP を実行すると、2.3.0 では「No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer」で止まります。今回のサーバーの要点だけを抜き出したコードです。

import asyncio
import uvicorn
from pydantic import BaseModel, Field
from mcp.server.mcpserver import MCPServer, Context

mcp = MCPServer("t249-demo", version="0.1.0")

@mcp.tool()
def add(a: int, b: int) -> int:
    """2つの整数を足す"""
    return a + b

class Weather(BaseModel):
    city: str
    temp_c: float = Field(description="気温(℃)")
    note: str

@mcp.tool()
def fake_weather(city: str) -> Weather:
    """固定値の天気を返す(外部には問い合わせない)"""
    return Weather(city=city, temp_c=18.5, note="固定応答")

@mcp.tool()
async def slow_count(n: int, ctx: Context) -> str:
    """n 回、0.5 秒ごとに進捗を通知する"""
    for i in range(1, n + 1):
        await asyncio.sleep(0.5)
        await ctx.report_progress(i, n, f"step {i}/{n}")
    return f"counted {n}"

app = mcp.streamable_http_app(host="127.0.0.1")   # 既定のパスは /mcp
uvicorn.run(app, host="127.0.0.1", port=49787)

streamable_http_app() は Starlette のアプリを返すので、uvicorn で 127.0.0.1 に bind します(mcp.run(transport="streamable-http") でも同じで、既定の host は 127.0.0.1、port は 8000)。ここからは curl だけで話しかけます。まず initialize です。

$ curl -sS -i -X POST http://127.0.0.1:49787/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"8"}}}'
HTTP/1.1 200 OK
cache-control: no-cache, no-transform
connection: keep-alive
content-type: text/event-stream
mcp-session-id: 5d20598045f34d08ad52abb44c1c1c61
x-accel-buffering: no
Transfer-Encoding: chunked

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-11-25","serverInfo":{"name":"t249-demo","version":"0.1.0"}}}

応答ヘッダーの mcp-session-id がセッション ID です。仕様は「暗号的に安全でグローバルに一意(UUID・JWT・ハッシュなど)」「0x21〜0x7E の可視 ASCII だけ」を求めていて、SDK は UUID4 の16進32文字を返しました。以後の要求には、この ID と MCP-Protocol-Version を付けます。

$ H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
     -H 'Mcp-Session-Id: 5d20598045f34d08ad52abb44c1c1c61' -H 'MCP-Protocol-Version: 2025-11-25')

$ curl -sS -i -X POST http://127.0.0.1:49787/mcp "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202 Accepted
content-type: application/json
mcp-session-id: 5d20598045f34d08ad52abb44c1c1c61
content-length: 0

$ curl -sS -X POST http://127.0.0.1:49787/mcp "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"description":"2つの整数を足す","inputSchema":{"properties":{"a":{"title":"A","type":"integer"},"b":{"title":"B","type":"integer"}},"required":["a","b"],"type":"object","title":"addArguments"},"name":"add","outputSchema":{"properties":{"result":{"title":"Result","type":"integer"}},"required":["result"],"type":"object","title":"addOutput"}}, ...]}}

$ curl -sS -X POST http://127.0.0.1:49787/mcp "${H[@]}" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"structuredContent":{"result":5}}}
  1. 1initialize の POSTセッション ID はまだ無い。応答に Mcp-Session-Id が付き、版(protocolVersion)と能力(capabilities)が決まる
  2. 2notifications/initialized の POST通知なので 202 Accepted・本文なし。仕様の「MUST return HTTP status code 202 Accepted with no body」どおり
  3. 3tools/list・tools/call の POST要求なので 200 で JSON か SSE。既定の SDK は SSE(event: message)で1件だけ流して閉じた
  4. 4(任意)GET で通知用の SSE を開く要求に関係しないサーバーからの通知を受ける口。後の節で原文
  5. 5DELETE でセッションを終える終わった ID で送ると 404。クライアントは新しい initialize からやり直す

ツール・リソース・プロンプトを一通り持つサーバーを Python で作り、Claude Code に登録するまでの手順は、関連記事「MCPサーバーの作り方|Pythonの完成コードとClaude Code登録」で扱っています。TypeScript の公式 SDK で同じことをする場合は、関連記事「MCPサーバーの作り方(TypeScript)|公式SDKでツール・リソースを作る」で扱っています。この記事は作り方よりも、通信の中身と版の違いを主に扱います。

SSE で返る場合と JSON で返る場合(進捗通知の届き方を実測)

この節では、同じ要求がサーバーの設定で SSE になったり JSON になったりする様子と、そのときに進捗通知がどう届くか(届かないか)が分かります。

仕様は、JSON-RPC の要求に対してサーバーが Content-Type: text/event-stream(SSE)か application/json(JSON 1個)のどちらかを返すことを MUST とし、クライアントには両方を受けることを MUST としています。SSE を選ぶと、最終の応答の前に、その要求にかかわる通知や要求を流せます。0.5 秒ごとに進捗を出す slow_count を、_meta.progressToken を付けて呼び、行ごとに受信時刻(秒.ミリ秒)を付けた原文です。

$ curl -sS -N -X POST http://127.0.0.1:49787/mcp "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"slow_count","arguments":{"n":3},"_meta":{"progressToken":"p1"}}}' \
  | while IFS= read -r l; do printf '%s %s\n' "$(date +%S.%3N)" "$l"; done
43.158 event: message
43.162 data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":1,"total":3,"message":"step 1/3"}}
43.658 event: message
43.662 data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":2,"total":3,"message":"step 2/3"}}
44.164 event: message
44.168 data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"p1","progress":3,"total":3,"message":"step 3/3"}}
44.173 event: message
44.177 data: {"jsonrpc":"2.0","id":5,"result":{"content":[{"text":"counted 3","type":"text"}],"isError":false,"structuredContent":{"result":"counted 3"}}}

進捗が 0.50 秒おきに届き、最後に id: 5 の応答が来てストリームが閉じています。応答ヘッダーの x-accel-buffering: no は、nginx などのリバースプロキシに応答のバッファリングをさせないための値で、2026-07-28 版の仕様は SSE を始めるときにこのヘッダーを付けることを SHOULD としています。

次に、同じサーバーを streamable_http_app(json_response=True) で起動し直して、同じ要求を送った結果です。

HTTP/1.1 200 OK
content-type: application/json
mcp-session-id: c5bcaae9a2c347249f50a6e3c3f6f0e9
content-length: 141

{"jsonrpc":"2.0","id":5,"result":{"content":[{"text":"counted 3","type":"text"}],"isError":false,"structuredContent":{"result":"counted 3"}}}
elapsed 1.542
サーバーの設定 Content-Type 進捗通知(3件出す) 完了までの時間
既定(SSE) text/event-stream 3件とも届いた 約 1.5 秒(最後の通知の 5ms 後に結果)
json_response=True application/json 0件(GET の SSE を開いていても 0件) 1.542 秒で結果だけ

JSON 固定にすると応答は1個になるため、要求の途中の通知を返す道がありません。GET の SSE を同じセッションで開いたまま試しても、進捗はそちらにも流れませんでした(仕様上、GET のストリームは「要求に関係しない」メッセージ用です)。進捗・ログ・途中経過を出したいツールがあるなら SSE、すべて短時間で終わる問い合わせだけなら JSON 固定、が分かれ目です。JSON 固定は、SSE を通さない中継装置やログ基盤との相性を優先したいときの選択肢になります。

SSE の行の形(event・data・id・retry と空行)や、ブラウザーの EventSource・nginx の設定は、関連記事「Spring Boot SSE|SseEmitterの使い方と再接続・nginx設定」で扱っています。MCP の SSE は、その上に JSON-RPC のメッセージを1件ずつ載せたものです。

GET の SSE ストリームと DELETE によるセッションの終了(2025-11-25 まで)

この節では、サーバーからの通知を受けるための GET、セッションを終える DELETE、終わったセッションへの要求がどう返るかが分かります。

2025-11-25 版までは、クライアントは同じ MCP エンドポイントに GET を送って、サーバー発の通知・要求を受ける SSE を開けます(MAY)。サーバーは SSE で答えるか、405 Method Not Allowed で「この口は無い」と答えます。SDK のサーバーは SSE を開き、何も無ければそのまま待ちました。2本目の GET を同じセッションで開くと 409 になります。

$ curl -sS -i -N --max-time 3 http://127.0.0.1:49787/mcp -H 'Accept: text/event-stream' \
  -H 'Mcp-Session-Id: 5d20598045f34d08ad52abb44c1c1c61' -H 'MCP-Protocol-Version: 2025-11-25'
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 5d20598045f34d08ad52abb44c1c1c61
x-accel-buffering: no
curl: (28) Operation timed out after 3002 milliseconds with 0 bytes received

(1本目を開いたまま、同じセッションで2本目の GET)
HTTP/1.1 409 Conflict
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Conflict: Only one SSE stream is allowed per session"}}

$ curl -sS -i -X DELETE http://127.0.0.1:49787/mcp -H 'Mcp-Session-Id: 5d20598045f34d08ad52abb44c1c1c61' -H 'MCP-Protocol-Version: 2025-11-25'
HTTP/1.1 200 OK
content-length: 0

(終わったセッションで tools/list)
HTTP/1.1 404 Not Found
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Session not found"}}

仕様の決まりは、「セッションを終えたサーバーは、その ID の要求に 404 を返す(MUST)」「404 を受けたクライアントは、ID を付けない新しい initialize で始め直す(MUST)」「不要になったセッションは DELETE で明示的に終える(SHOULD)。サーバーは 405 で断ってもよい(MAY)」です。SDK の既定では、操作の無いセッションは 30 分(session_idle_timeout)で閉じられ、同時のセッションは 10,000 個(max_sessions)までです。

SDK のクライアントを使うと、この一連の流れを自動で行います。Client(url, mode="legacy")(initialize を使う流れ)で list_tools・call_tool 2回を実行したとき、サーバー側の記録に残った HTTP の要求です。

POST {accept} initialize                                  -> 200 text/event-stream
POST {accept, mcp-session-id, mcp-protocol-version} notifications/initialized -> 202
GET  {accept, mcp-session-id, mcp-protocol-version}       -> 200 text/event-stream
POST {...} tools/list                                     -> 200 text/event-stream
POST {...} tools/call fake_weather                        -> 200 text/event-stream
POST {...} tools/call slow_count  (_meta.progressToken: 4) -> 200 text/event-stream
DELETE {...}                                              -> 200 application/json

クライアントは initialize の直後に GET の SSE を自分で開き、async with を抜けるときに DELETE を送っていました。進捗のコールバックを渡すと、progressToken には JSON-RPC の id と同じ値(4)が入りました。

切断と再開(Last-Event-ID とイベントストア)を実測する

この節では、SSE の途中で接続が切れたときに結果が失われるかどうかと、Last-Event-ID で続きを受け取る仕組みが、2025-11-25 版の実測で分かります。

2025-11-25 版の仕様では、切断は「取り消し」ではありません。「Disconnection SHOULD NOT be interpreted as the client cancelling its request.」とあり、取り消すならクライアントが notifications/cancelled を明示的に送ります。サーバーが SSE のイベントに id を付けていれば、クライアントは GET に Last-Event-ID ヘッダーを付けて、切れたストリームの続きを受け取れます(サーバーが再送するのは MAY)。SDK では streamable_http_app(event_store=..., retry_interval=1000) でイベントの保存先を渡すと有効になります。保存先は自作の最小のもの(リストに積むだけ)を使い、6 回進捗を出すツールを 1.3 秒で切りました。

$ curl -sS -N --max-time 1.3 -X POST $U "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"slow_count","arguments":{"n":6},"_meta":{"progressToken":"r1"}}}'
id: 7_3
data:
retry: 1000

id: 7_4
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"r1","progress":1,"total":6,"message":"step 1/6"}}

id: 7_5
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"r1","progress":2,"total":6,"message":"step 2/6"}}

curl: (28) Operation timed out after 1301 milliseconds with 356 bytes received

(2.5 秒待ってから、最後に受け取った ID を付けて GET)
$ curl -sS -i -N $U -H 'Accept: text/event-stream' -H "Mcp-Session-Id: $SID" \
  -H 'MCP-Protocol-Version: 2025-11-25' -H 'Last-Event-ID: 7_5'
HTTP/1.1 200 OK
content-type: text/event-stream

id: 7_6
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"r1","progress":3,"total":6,"message":"step 3/6"}}
(7_7・7_8・7_9 は progress 4〜6)
id: 7_10
event: message
data: {"jsonrpc":"2.0","id":7,"result":{"content":[{"text":"counted 6","type":"text"}],"isError":false,"structuredContent":{"result":"counted 6"}}}

id: 7_11
data:
retry: 1000

先頭の「id と空の data と retry」が、2025-11-25 版で入った「SSE を始めたらすぐ、イベント ID と空の data のイベントを送って再接続に備えさせる(SHOULD)」の実物です。切断の後もツールは最後まで動き続け、再開の GET で progress 3〜6 と最終結果をすべて受け取れました。

条件 切断後の GET(Last-Event-ID 付き) 結果
イベントストアあり・既知の ID(7_5) 200 で 7_6〜7_10 を再送 進捗4件と最終結果を受け取れた
イベントストアあり・知らない ID(99_99) 200 で何も流さず閉じる 受け取れない
イベントストアなし 500 Internal Server Error。サーバーのログに「ASGI callable returned without starting response.」 結果は失われた(イベントに id も付かない)

イベントストアはセッションの全イベントを保持するため、本番では Redis などに置き、保持期間を決めます。複数台で動かすなら、再開の GET が別の台に届いても同じストアを読める構成が要ります。2026-07-28 版ではこの再開の仕組み自体が仕様から外れ、「切れた応答ストリームの要求は失われ、クライアントは新しい ID で出し直す(MUST)」に変わりました。同じ版では、SSE の応答ストリームを閉じることが取り消しの合図にもなります。長い処理を切断に強くしたいなら、版にかかわらず「ツールの処理を冪等にする」「時間のかかる処理はジョブの ID を返して後で問い合わせる形にする」のほうが確実です。

ステートレス運用(stateless_http)とスケールの考え方

この節では、セッションを持たないモードで何ができて何ができなくなるか、ロードバランサーの後ろで複数台にするときに何を考えるかが分かります。

SDK の stateless_http=True は、要求ごとに新しいトランスポートを作り、Mcp-Session-Id を発行しないモードです。実測では、initialize をしなくても MCP-Protocol-Version: 2025-11-25 を付けた tools/call がそのまま 200 で通り、SSE の進捗も届きました。一方で DELETE は 405 で断られます。

(stateless_http=True のサーバーに、initialize もセッション ID も無しで)
$ curl -sS -i -X POST $U -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
HTTP/1.1 200 OK
content-type: text/event-stream

event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"structuredContent":{"result":5}}}

$ curl -sS -i -X DELETE $U -H 'MCP-Protocol-Version: 2025-11-25'
HTTP/1.1 405 Method Not Allowed
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Method Not Allowed: Session termination not supported"}}
1

どの台に届いてもよい

セッションの表をサーバーのメモリに持たないので、ロードバランサーで振り分けても状態が食い違わない。

スティッキーセッション不要

2

要求の中の通知は届く

SSE の応答の中で進捗は流せる(実測で 2件届いた)。要求に関係しない通知の口は持てない。

GET の通知は期待しない

3

状態は引数で渡す

呼び出しをまたぐ状態が要るなら、サーバーが発行した ID をツールの引数として渡す。2026-07-28 版の考え方と同じ。

DB や KVS に置く

4

セッションを認証に使わない

公式の Security Best Practices は「MCP Servers MUST NOT use sessions for authentication.」。毎回の要求を認可で確かめる。

ステートフルでも同じ

ステートフルのまま複数台にするなら、同じセッション ID の要求を同じ台に届ける(スティッキー)か、セッションとイベントストアを共有の置き場に出すかのどちらかが要ります。公式の Security Best Practices は、セッション ID を共有のキューなどに置くときは <user_id>:<session_id> のように利用者の情報と組み合わせることを SHOULD としています。2026-07-28 版はプロトコルのセッション自体が無くなったため、この悩みは「状態をどこに置くか」というアプリの設計に移りました。

2026-07-28 版の通信の中身(server/discover・Mcp-Method・セッションなし)

この節では、現行の 2026-07-28 版で、initialize もセッションも無しに1回の POST で完結する通信の原文と、ヘッダーの検証で返るエラーが分かります。

2026-07-28 版では、すべての要求が自分の版とクライアントの能力を params._meta に持ち、HTTP では同じ値を MCP-Protocol-Version ヘッダーにも書きます。さらに全 POST に Mcp-Method、tools/call・resources/read・prompts/get には Mcp-Name が必須になりました。ロードバランサーやゲートウェイが本文を読まずに振り分けられるようにするためで、サーバーはヘッダーと本文が食い違えば 400 と HeaderMismatch(-32020)で断る決まりです。SDK 2.3.0 のサーバーは、MCP-Protocol-Version がハンドシェイクの版(2025-11-25 など)ならセッションのある経路、それ以外ならこの版の経路へ振り分けます。

$ META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"}}'

$ curl -sS -i -X POST $U -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{$META}}"
HTTP/1.1 200 OK
content-type: application/json

{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"t249-demo","version":"0.1.0"}}}}

$ curl -sS -i -X POST $U ...(同じヘッダー) -H 'Mcp-Method: tools/call' -H 'Mcp-Name: add' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"a\":2,\"b\":3},$META}}"
HTTP/1.1 200 OK
content-type: application/json

{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"5","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":5},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"t249-demo","version":"0.1.0"}}}}

応答にはセッション ID が無く、結果には resultType: "complete" と、サーバー自身の情報(serverInfo)が毎回入ります。tools/list と server/discover の結果には、キャッシュのための ttlMs と cacheScope が付きました(2026-07-28 版で必須になった項目です)。同じ版で試した失敗の原文です。

送ったもの HTTP JSON-RPC のエラー(原文)
Mcp-Name: fake_weather で本文は add 400 -32020 mcp-name header does not match the request body's 'name' parameter
Mcp-Method ヘッダーなし 400 -32020 mcp-method header does not match the request body's method
版 2027-01-01(知らない版) 400 -32022 Unsupported protocol version(data に supported と requested)
この版で initialize を送る 400 -32602 params._meta must be an object carrying the required ... envelope keys
知らないメソッド foo/bar 404 -32601 Method not found
GET(通知の口を開こうとする) 405 本文なし。allow: POST
Accept: application/json だけ 406 本文なし

2026-07-28 版の仕様は、以前の版のクライアントから GET や DELETE が来たら 405、Mcp-Session-Id や Last-Event-ID は無視する(SHOULD)と書いていて、実測でも Mcp-Session-Id: abc を付けた要求はそのまま 200 で処理されました。

SDK のクライアントを Client(url)(既定の mode="auto")で使うと、まず server/discover を送り、相手が 2026-07-28 版を話せればその版で続け、話せなければ initialize に戻ります。前の節と同じ作業(list_tools と call_tool 2回)の記録です。

POST {mcp-protocol-version: 2026-07-28, mcp-method: server/discover}                  -> 200 application/json
POST {mcp-protocol-version: 2026-07-28, mcp-method: tools/list}                       -> 200 application/json
POST {mcp-protocol-version: 2026-07-28, mcp-method: tools/call, mcp-name: fake_weather} -> 200 application/json
POST {mcp-protocol-version: 2026-07-28, mcp-method: tools/call, mcp-name: slow_count}   -> 200 text/event-stream

2025-11-25 の流れでは 7 回だった HTTP の要求が 4 回になり、GET の張りっぱなしの接続も DELETE もありません。進捗を出すツールだけが SSE になり、すぐ終わるものは JSON で返っています。SDK のサーバーは、15 秒以内に通知を出さずに終わる要求は JSON で答え、通知が出た時点で SSE に切り替える作りです。

手元の 2 CPU の共有マシンで、add を 200 回続けて呼んだときの1回あたりの時間です。他の作業と同居しているため値は揺れます。傾向を見る目安として、同じ条件を2回ずつ測りました。

サーバー クライアントの流れ p50(2回) p95(2回)
既定(SSE) 2025-11-25(legacy) 7.96 / 7.49 ms 12.39 / 10.87 ms
既定(SSE) 2026-07-28(auto) 3.98 / 3.53 ms 7.74 / 7.56 ms
json_response=True 2025-11-25(legacy) 2.50 / 5.48 ms 7.98 / 11.58 ms
json_response=True 2026-07-28(auto) 3.72 / 2.49 ms 6.92 / 7.07 ms

差が出たのは「2025-11-25 の流れで SSE を毎回開く」組み合わせだけで、短い要求を SSE で包む分のコストが見えています。どれも 1 回 10ms 前後の小さな差で、ツールの中身(DB や外部 API)の時間に比べれば小さいことが多いはずです。

構造化された出力(outputSchema と structuredContent)

この節では、2025-06-18 版で入った構造化されたツール出力が、通信の上でどう見えるかと、SDK で型を書くだけで何が付くかが分かります。

ツールが outputSchema を宣言すると、サーバーはそのスキーマに合う structuredContent を返す MUST、クライアントはそれを検証する SHOULD が生まれます。SDK 2.3.0 では、Python の戻り値の型注釈から outputSchema が自動で作られました。-> int のような単純な型は {"result": ...} に包まれ、pydantic のモデルはそのフィールドがそのままスキーマになります。

{"name":"fake_weather",
 "inputSchema":{"properties":{"city":{"title":"City","type":"string"}},"required":["city"],"type":"object","title":"fake_weatherArguments"},
 "outputSchema":{"properties":{"city":{"title":"City","type":"string"},
                               "temp_c":{"description":"気温(℃)","title":"Temp C","type":"number"},
                               "note":{"title":"Note","type":"string"}},
                 "required":["city","temp_c","note"],"type":"object","title":"Weather"}}

(tools/call の結果)
{"content":[{"text":"{\n  \"city\": \"Tokyo\",\n  \"temp_c\": 18.5,\n  \"note\": \"固定応答\"\n}","type":"text"}],
 "isError":false,
 "structuredContent":{"city":"Tokyo","temp_c":18.5,"note":"固定応答"}}

結果には structuredContent と同じ内容を JSON 文字列にした text も入っています。仕様の「For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.」に沿った形で、構造化された出力を読めない以前のクライアントにも同じ情報が届きます。SDK のクライアントでは result.structured_content で dict として受け取れました。

ツールの結果を機械が読む前提のとき

やってはいけない結果を自由な文章の text だけで返し、クライアント側で正規表現で数値を抜き出す。スキーマを宣言したのに、合わない形(必須の項目が欠けた dict)を返す
→
代わりに戻り値に型(pydantic のモデルや TypedDict)を付けて outputSchema を宣言し、structuredContent で返す。互換のために text にも同じ JSON を入れる

確かめ方:tools/list の outputSchema と、tools/call の structuredContent を curl で並べて見る

入力の誤りの返し方も見ておきます。add に {"a":"x"} を渡すと、JSON-RPC のエラーではなく isError: true の結果として、pydantic の検証メッセージ(「Input should be a valid integer」「Field required」)が text で返りました。知らないツール名も「Unknown tool: nope」が isError: true で返っています。2025-11-25 版の仕様は、入力の検証エラーを「Tool Execution Errors」として結果で返すと明記し、モデルが自分で直して再試行できるようにする意図を書いています(知らないツールは仕様の例ではプロトコルのエラー -32602 として示されています)。

Origin の検証と 127.0.0.1 への bind(公式の注意と SDK の既定)

この節では、仕様が Streamable HTTP の実装に求めるセキュリティの3点と、SDK の既定の保護が host の指定でどう変わるかが、実測で分かります。

公式仕様の Security Warning は、どの版でも次の3点を挙げています。「Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks」「When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)」「Servers SHOULD implement proper authentication for all connections」。2025-11-25 版からは、Origin があって不正なら 403 Forbidden を返すことも MUST になりました。理由として仕様は、これらが無いと遠くの Web サイトから DNS リバインディングでローカルの MCP サーバーに触れられてしまう、と書いています。

SDK 2.3.0 の streamable_http_app(host=...) は、host が 127.0.0.1・localhost・::1 のときだけ、Host と Origin の許可リストを自動で有効にします(許可されるのは 127.0.0.1・localhost・[::1] の任意のポート)。既定の 127.0.0.1 のサーバーに送った結果です。

Origin: http://evil.example       -> HTTP 403  Invalid Origin header
Origin: http://localhost:3000     -> HTTP 200
Host: attacker.example            -> HTTP 421  Invalid Host header
(サーバーのログ)Invalid Origin header: http://evil.example
                  Invalid Host header: attacker.example

ところが、host="0.0.0.0" を渡してアプリを作ると、この保護は既定では付きません(ソースのコメントは「If not specified, disable DNS rebinding protection by default for backwards compatibility」)。実際の bind は 127.0.0.1 のまま、アプリだけ host="0.0.0.0" で作って同じ要求を送りました。

アプリの作り方 Origin: http://evil.example 許可した Origin Host: attacker.example
host="127.0.0.1"(既定) 403 200(localhost:3000) 421
host="0.0.0.0"・設定なし 200(通ってしまう) 200 200(通ってしまう)
host="0.0.0.0"・許可リストを明示 403 200(app.example.internal) 421

コンテナの中で動かすために 0.0.0.0 で待ち受けたい場合は、許可リストを自分で渡します。

from mcp.server.transport_security import TransportSecuritySettings

app = mcp.streamable_http_app(
    host="0.0.0.0",
    transport_security=TransportSecuritySettings(
        enable_dns_rebinding_protection=True,
        allowed_hosts=["127.0.0.1:*", "mcp.example.internal"],
        allowed_origins=["https://app.example.internal"],
    ),
)

公式の Security Best Practices は、ローカルで動かすつもりのサーバーについて「stdio を使ってクライアントだけが触れるようにする」「HTTP を使うなら認可トークンを求める、または権限を絞った Unix ドメインソケットなどを使う」ことを SHOULD としています。手元だけで使うなら、まず stdio を選ぶのが素直です。

認可(OAuth)の流れの概要(公式仕様の範囲)

この節では、HTTP の MCP サーバーに認可を付けるとき、公式仕様がどんな役割分担と順番を定めているかの概要と、サーバーが最初に返す 401 の実物が分かります。

認可は MCP で OPTIONAL ですが、付けるなら HTTP のトランスポートではこの仕様に従うのが SHOULD、stdio では仕様に従わず環境(環境変数など)から資格情報を得るのが SHOULD です。2025-11-25 版の Authorization のページは、OAuth 2.1(ドラフト)・RFC 8414・RFC 9728・RFC 7591・Client ID Metadata Documents を土台に、MCP サーバーを OAuth の resource server、MCP クライアントを OAuth のクライアントと位置づけています。トークンを発行する認可サーバーは別にあってよく、その中身は仕様の範囲外です。

1

トークン無しの要求に 401

サーバーは 401 Unauthorized と、WWW-Authenticate の resource_metadata で Protected Resource Metadata(RFC 9728)の URL を示す。必要なスコープを scope で示すのが SHOULD。

ヘッダーが無ければ well-known の URL を順に試す

2

認可サーバーを見つける

メタデータの authorization_servers から認可サーバーを選び、OAuth 2.0 Authorization Server Metadata か OpenID Connect Discovery の文書を取得する。

クライアントは両方の発見方法に対応する MUST

3

クライアントを登録する

事前登録があればそれ、無ければ Client ID Metadata Documents(HTTPS の URL を client_id にする)、それも無ければ Dynamic Client Registration、の順。

2026-07-28 版で DCR は Deprecated

4

PKCE と resource を付けて認可コードを得る

PKCE(S256)を必ず使い、認可要求とトークン要求の両方に resource(RFC 8707、MCP サーバーの正規の URI)を入れる。

PKCE に未対応の認可サーバーなら進まない MUST

5

毎回の要求に Bearer トークン

Authorization: Bearer ... をすべての HTTP 要求に付ける(同じセッションの中でも)。URL のクエリにトークンを入れない MUST。

サーバーは自分宛て(audience)か検証する MUST

SDK 2.3.0 で、固定の文字列だけを通す検証器(形を見るためのもの)と AuthSettings(resource_server_url=..., required_scopes=["tools:read"], validate_token_resource=True) を付けたサーバーを 127.0.0.1 で起動し、最初の 401 と、そこで示されたメタデータを取得した原文です。

$ curl -sS -i -X POST http://127.0.0.1:55015/mcp -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -H 'MCP-Protocol-Version: 2025-11-25' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/1.1 401 Unauthorized
content-type: application/json
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:55015/.well-known/oauth-protected-resource/mcp"

{"error": "invalid_token", "error_description": "Authentication required"}

$ curl -sS -i http://127.0.0.1:55015/.well-known/oauth-protected-resource/mcp
HTTP/1.1 200 OK
cache-control: public, max-age=3600
content-type: application/json

{"resource":"http://127.0.0.1:55015/mcp","authorization_servers":["https://auth.example.internal"],"scopes_supported":["tools:read"],"bearer_methods_supported":["header"]}

メタデータの URL は、仕様の例どおり MCP エンドポイントのパス(/mcp)を well-known の後ろに付けた形でした。正しいトークンを付けると同じ要求は 200 になります。仕様は、サーバーが受けたトークンをそのまま上流の API に渡す「token passthrough」を禁止し(MUST NOT)、上流には別のトークンを使うよう求めています。認可の仕組みそのもの(OAuth と OpenID Connect の役割の違い)は、関連記事「OAuth 2.0とOpenID Connectの違いをわかりやすく解説」で扱っています。Claude Code からトークン付きのリモートサーバーを登録する手順は、関連記事「Claude Code MCP設定の追加方法|.mcp.json雛形と一覧」で扱っています。

よくあるエラーと直し方(原文つき)

この節では、Streamable HTTP の MCP サーバーに curl や自作のクライアントで話しかけたときに出るエラーの原文と、その原因・直し方が分かります。

症状(原文) 原因 直し方
406 Not Acceptable: Client must accept both application/json and text/event-stream Accept に片方しか書いていない(または Accept が空) Accept: application/json, text/event-stream を付ける。curl の既定 */* なら通る
400 Invalid Content-Type header POST の Content-Type が application/json でない -H 'Content-Type: application/json'。curl の -d だけだと別の型になる
400 Bad Request: Missing session ID ステートフルのサーバーに、initialize 以外を Mcp-Session-Id 無しで送った initialize の応答ヘッダーの ID を付ける。または stateless_http=True
404 Session not found DELETE 済み・期限切れ(既定 30 分)・サーバー再起動・別の台に届いた 新しい initialize からやり直す(仕様の MUST)。複数台ならスティッキーか共有ストア
409 Conflict: Only one SSE stream is allowed per session 同じセッションで GET の SSE を2本開いた GET は1本だけにする。クライアントの多重接続を止める
403 Invalid Origin header/421 Invalid Host header 許可リストに無い Origin・Host から来た 正しい Origin なら allowed_origins・allowed_hosts に足す。リバースプロキシの Host の書き換えも確かめる
400 -32602 Validation error … union[JSONRPCRequest, …] JSON-RPC のバッチ(配列)を送った。2025-06-18 版で削除済み 1件ずつ POST する
400 -32700 Parse error: EOF while parsing a value 本文の JSON が壊れている(シェルの引用符の閉じ忘れなど) 本文をファイルにして --data-binary @body.json で送る
400 -32602 params._meta must be an object carrying the required … envelope keys ハンドシェイクの版ではない MCP-Protocol-Version(打ち間違いを含む)を送り、2026-07-28 の経路で読まれた 版の綴りを確かめる。2026-07-28 で話すなら _meta に版と能力を入れる
500 Internal Server Error(ログに ASGI callable returned without starting response.) イベントストアの無いサーバーに Last-Event-ID 付きの GET を送った 再開を使うなら event_store を渡す。使わないなら Last-Event-ID を送らない
No module named 'mcp.server.fastmcp'. This is mcp 2.x … 1.x のコードを mcp 2.x で動かした from mcp.server.mcpserver import MCPServer に直す。または mcp<2 に固定
405 Method Not Allowed(allow: GET, HEAD) 以前の HTTP+SSE のサーバーの /sse に、Streamable HTTP として POST した 相手の版を確かめる。仕様の後方互換の手順では、4xx なら GET で endpoint イベントを待つ

curl で原因を切り分けるときは、-i で応答ヘッダーを、-N で SSE をバッファーせずに見て、-w '%{http_code} %{content_type}\n' で状態とContent-Typeを1行に出すと速く進みます。

判断表:どのトランスポート・どのモードで作るか

この節では、作る MCP サーバーの使われ方から、stdio と Streamable HTTP、ステートフルとステートレス、SSE と JSON のどれを選ぶかが分かります。

状況 選ぶもの 理由(実測・公式)
自分の PC のエディターや Claude Code からだけ使う stdio 他のプロセスから触れない。公式の Security Best Practices もローカル用には stdio を勧める
チームや社内の複数の人・エージェントから使う Streamable HTTP+認可 1つの URL を共有できる。ネットワークに出すなら 401 と Bearer トークン
ロードバランサーの後ろで複数台にする stateless_http=True(または 2026-07-28 版) セッションの表を持たないので振り分けの制約が無い。進捗は要求の中で流せた
長い処理の途中経過を見せたい SSE(既定) json_response=True では進捗が 0件だった
短い問い合わせだけで、中継装置が SSE を嫌う json_response=True 応答が1個の JSON になり、ふつうの API と同じに扱える
切断しても結果を失いたくない(2025-11-25 の相手) event_store+retry_interval Last-Event-ID で残り全部を受け取れた。2026-07-28 版には無いので、ジョブ ID 方式も用意
新しく作る Streamable HTTP(HTTP+SSE は使わない) HTTP+SSE は 2026-07-28 版で Deprecated。新規は採用しない SHOULD NOT
既存のクライアントがまだ 2025-11-25 を話す 両方を受けるサーバー(SDK 2.x の既定) SDK 2.3.0 は MCP-Protocol-Version で経路を分け、同じ /mcp で両方に答えた

よくある質問

Q. Streamable HTTP は WebSocket とは違いますか?
違います。Streamable HTTP はふつうの HTTP の POST と、その応答としての SSE(サーバーからの一方向のストリーム)だけでできています。双方向の常時接続を張る WebSocket は MCP の標準のトランスポートには含まれていません。仕様は独自のトランスポートを作ることを認めていますが(MAY)、その場合も JSON-RPC の形とライフサイクルの決まりを守る MUST があります。

Q. 「MCP の SSE は廃止」とはどういう意味ですか?
廃止の対象は、2024-11-05 版の「HTTP+SSE トランスポート」(GET /sse と POST /messages の2本立て)です。2025-03-26 版で Streamable HTTP に置き換えられ、2026-07-28 版で Deprecated(新規の実装は採用しない SHOULD NOT、将来の版で削除され得る)に分類されました。SSE という技術そのものは、Streamable HTTP の応答の形として今も使われています。

Q. 2025-11-25 版のクライアントと 2026-07-28 版のサーバーはつながりますか?
両方を話せる側があればつながります。2026-07-28 版の仕様は、クライアントがまず新しい形で要求し、400 の本文が新しい形のエラーでなければ initialize に戻る、という手順を書いています。SDK 2.3.0 のクライアントの既定(auto)は server/discover を先に送り、サーバーは同じ /mcp で両方の版に答えました。

Q. MCP-Protocol-Version ヘッダーを付け忘れるとどうなりますか?
2025-06-18・2025-11-25 版の仕様では、ヘッダーが無く他に版を知る手段も無いとき、サーバーは 2025-03-26 とみなす(SHOULD)、不正・未対応の値なら 400(MUST)です。2026-07-28 版では全 POST で必須になり、本文の _meta の版と一致しなければ 400 です。

Q. セッション ID を認証の代わりにしてよいですか?
いけません。公式の Security Best Practices は「MCP Servers MUST NOT use sessions for authentication.」と書き、認可を実装するサーバーはすべての要求を検証する MUST としています。セッション ID は、安全な乱数で作り、利用者の情報と結び付けて扱います。

Q. nginx などのリバースプロキシの後ろに置くときの注意は?
SSE がバッファーに溜まらないようにすることです。SDK は応答に x-accel-buffering: no を付けていて、nginx はこのヘッダーを見て応答のバッファリングを止めます。長い SSE がプロキシのアイドルタイムアウトで切られないよう、2026-07-28 版の仕様は長いストリームでコメント行(: で始まる行)を定期的に送ることを勧めています。

Q. ブラウザーから直接 MCP サーバーを呼べますか?
HTTP なので技術的には fetch で POST できますが、ブラウザーは Origin ヘッダーを付けるため、サーバーの許可リストに入っていなければ 403 になります(実測の Invalid Origin header)。許可する Origin を明示し、認可も付けたうえで、CORS の設定を合わせます。

Q. 進捗通知を受け取るには何が必要ですか?
要求の params._meta.progressToken に値を入れ、サーバーが SSE で答えることです。実測では、トークンを付けて SSE で受けたときに 0.5 秒ごとの notifications/progress が届き、JSON 固定のサーバーでは届きませんでした。

まとめ

  • Streamable HTTP は、1つの MCP エンドポイントに JSON-RPC を1件ずつ POST し、サーバーが JSON 1個か要求専用の SSE で答える方式です。以前の HTTP+SSE(/sse と /messages の2本)は 2025-03-26 版で置き換えられ、2026-07-28 版で Deprecated になりました。
  • 2025-11-25 版までは initialize→Mcp-Session-Id→以後の要求に ID と MCP-Protocol-Version→DELETE、という流れで、SDK のクライアントの記録では 7 回の HTTP 要求でした。2026-07-28 版はセッションも initialize も無く、Mcp-Method・Mcp-Name ヘッダーと _meta の版で1回ごとに完結し、同じ作業が 4 回でした。
  • 進捗通知は SSE の応答の中に流れ、json_response=True では 0件でした。切断後は、イベントストアがあれば Last-Event-ID で残り全部を受け取れ、無ければ失われました(2026-07-28 版には再開が無く、出し直しが前提)。
  • 構造化された出力は、SDK では戻り値の型を書くだけで outputSchema と structuredContent が付き、互換のための text も入りました。入力の誤りは isError: true の結果で返ります。
  • 仕様は Origin の検証(不正なら 403)と、ローカルでは 127.0.0.1 への bind を求めています。SDK は host が 127.0.0.1 なら自動で保護しますが、host="0.0.0.0" では既定で外れるため、許可リストを明示します。ネットワークに出すなら、401 と Protected Resource Metadata から始まる OAuth の流れで認可を付けます。

参考・出典

確認日はいずれも 2026年11月4日です。

  • Model Context Protocol「Transports」(2025-11-25):https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
  • Model Context Protocol「Transports」(2025-06-18):https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
  • Model Context Protocol「Transports」(2025-03-26):https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
  • Model Context Protocol「Transports」(2024-11-05、HTTP with SSE):https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
  • Model Context Protocol「Streamable HTTP」(2026-07-28):https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
  • Model Context Protocol「Key Changes」(2025-06-18・2025-11-25・2026-07-28 の各 Changelog):https://modelcontextprotocol.io/specification/2026-07-28/changelog
  • Model Context Protocol「Versioning」:https://modelcontextprotocol.io/specification/versioning
  • Model Context Protocol「Authorization」(2025-11-25):https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
  • Model Context Protocol「Security Best Practices」(2025-11-25):https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices
  • Model Context Protocol「Tools」(2025-11-25、Structured Content・Output Schema・Error Handling):https://modelcontextprotocol.io/specification/2025-11-25/server/tools
  • PyPI「mcp」(Model Context Protocol SDK、2.3.0):https://pypi.org/project/mcp/
  • WHATWG「HTML Standard: Server-sent events」(id・retry・Last-Event-ID):https://html.spec.whatwg.org/multipage/server-sent-events.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MCP Streamable HTTPとは|リモートサーバーの作り方と通信の中身」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり