コンテンツにスキップ

MCP HTTP/SSE Transport

MCPツールボックスへの二つ目の玄関——単発呼び出しはBearer認証のREST、長時間接続のストリームはServer-Sent Eventsで、リモートクライアントがstdio transportと同じツールに到達できます。


たとえ話:専用回線 vs. 交換台付きラジオ放送

Section titled “たとえ話:専用回線 vs. 交換台付きラジオ放送”

元々のMCP transportはstdio——単一のClaude CLIプロセスとDuDuClaw MCP serverを直結する専用電話回線です。高速で親密ですが、発信者はちょうど一人だけ。二つのプロセスがstdin/stdoutを共有し、他の誰もダイヤルインできません。

HTTP/SSE transportは交換台とラジオ放送です:

  • 有効なバッジ(Bearer APIキー)を持つ誰もが交換台にダイヤルして単発リクエストを出せます——POST /mcp/v1/call。一回鳴らし、一回答え、切る。
  • あるいはラジオにチューニング——GET /mcp/v1/stream——イベント発生時にプッシュされる長時間接続の放送を聴く。
  • さらに交換台に電話して結果をラジオで告知してもらう——POST /mcp/v1/stream/call——今リクエストを出し、すでに購読しているストリームから回答が返ってくるのを聴く。

交換台は専用回線を置き換えません。stdio transportは引き続きローカルCLIに奉仕します。HTTP/SSEは同じツールボックスを、別プロセス・別コンテナ・別マシンに住む外部クライアントに開放するだけです。


MCP serverとその唯一の消費者が同じ場所にあり一緒に起動される場合、stdioは完璧です。しかし以下には奉仕できません:

  • ネットワーク経由でツールを呼び出したいwebダッシュボードや外部サービス。
  • 単発の同期呼び出しでブロックするのではなく、結果のストリームを購読したい長時間実行クライアント。
  • 単一の実行中MCP serverを共有する複数の独立した発信者。

HTTP/SSEはstdioに触れずにそのギャップを埋めます:

┌──────────────────────────────────────────────┐
│ DuDuClaw MCP Toolbox │
│ channel · memory · agent · skill · task · │
│ shared wiki · autopilot · ... │
└──────────────────────────────────────────────┘
▲ ▲
│ stdio (JSON-RPC 2.0) │ HTTP/SSE (JSON-RPC 2.0)
│ │
┌────────┴────────┐ ┌──────────┴──────────────┐
│ ローカル │ │ リモートHTTP │
│ Claude CLI │ │ クライアント │
│ プロセス │ │ (ダッシュボード/サービス│
│ (一発信者) │ │ /別マシン) │
└─────────────────┘ └──────────────────────────┘
「専用回線」 「交換台 + ラジオ」

一つのコマンドで起動:

duduclaw http-server --bind 127.0.0.1:8765

--no-sseを渡すとストリーミングエンドポイントを無効化し、POST /mcp/v1/callGET /healthzのみを公開します。


エンドポイント メソッド 認証 用途
/mcp/v1/call POST Bearerのみ 単発同期のJSON-RPC 2.0 tools/call、一回の応答。
/mcp/v1/stream GET Bearer または ?api_key= 長時間接続のSSEイベントストリームを開く;conn_idを返す。
/mcp/v1/stream/call POST Bearerのみ 指定ストリームにツール呼び出しを注入;結果はSSEでプッシュ。
/healthz GET なし 死活プローブ——200 OKを返す、認証不要。

/healthzは唯一の認証不要ルートです。それ以外はすべてfail closed:欠落または不正なAuthorizationヘッダは、いかなるツール実行よりも前に拒否されます。


最もシンプルな経路は単発の往復——ダイヤルし、尋ね、答えを得て、切る:

client mcp_http_server dispatcher
| | |
| POST /mcp/v1/call | |
| Authorization: Bearer <key> | |
| { jsonrpc:"2.0", | |
| method:"tools/call", ... } -->| |
| |-- Bearer認証 --------------> |
| |-- rate-limitゲート --------> |
| |-- jsonrpc=="2.0"を検証 |
| |-- method== |
| | "tools/call"を検証 |
| |-- ツールをディスパッチ ----> |
| |<------- ツール結果 --------- |
|<------ JSON-RPC 2.0 result ------| |
| (接続を閉じる) | |

jsonrpc"2.0"でなければ、serverはコード-32600のJSON-RPCエラーを返します。methodtools/callでなければ-32601(“Method not found”)を返します。契約は意図的に厳格です——検証されるのは実際に実行される成果物であり、リクエストの外殻だけではありません。


長時間接続クライアントでは、フローは同じ接続上で二つのリクエストに分かれます:

1. 購読
client --- GET /mcp/v1/stream (Bearer または ?api_key=) ---> server
server --- 接続を登録、conn_idを返す --------------------> client
(broadcast channelが開く;クライアントは聴取中)
2. 発射
client --- POST /mcp/v1/stream/call?conn_id=<id> -------> server
{ jsonrpc:"2.0", method:"tools/call", ... }
server --- 発信者がconn_idを所有するか検証 -------------> (ownershipチェック)
server --- ツールをディスパッチ ------------------------> dispatcher
3. 受信
server --- 結果イベントをconn_idストリームにプッシュ ----> client
(ステップ1のSSE接続経由で届く)

conn_idが二つのリクエストを結びつけます。mcp_sse_store.rsは購読時に所有するprincipalを記録するため、stream/callは発信者がプッシュ先のストリームを実際に所有しているか検証できます——他人の放送に結果をねじ込むことはできません。

各SSE接続はtokio::sync::broadcast送信側と、リプレイ用の接続ごとリングバッファ(容量1024イベント)に支えられます:

SseEventStore
└─ connections: HashMap<conn_id, ConnectionEntry>
└─ tx: broadcast::Sender<String> (ライブfan-out)
└─ ring: 直近1024件のSseEvent (リプレイバッファ)
└─ owner: principal client_id (所有権ゲート)
  • register_connection / remove_connectionがライフサイクルを管理(クライアント切断時に削除し送信側を破棄)。
  • push_eventはライブ放送;recordは放送せずリングバッファに追記。
  • replay_after(conn_id, last_event_id)は再接続クライアントが取りこぼしたイベントを回復させる——1024イベントウィンドウより遅れすぎていればエラーを返す。
  • アイドル接続は10分のTTL後に退去させられ、放棄されたストリームが蓄積しません。

認証された各リクエストは、ツール実行前に二つのチェックを通過します:

受信リクエスト
|
v
┌─────────────────────────────────────────────┐
│ 1. 認証 │
│ /mcp/v1/call → Bearerヘッダのみ │
│ /mcp/v1/stream → Bearer または ?api_key=│
│ 欠落/不正 → 拒否 (fail closed) │
└─────────────────────────────────────────────┘
| Principalを解決
v
┌─────────────────────────────────────────────┐
│ 2. RATE LIMIT (OpType::HttpRequest) │
│ token bucket:キーごと60 req/min │
│ 容量60、補充1.0 token/s │
│ Read/Write bucketsから独立 │
└─────────────────────────────────────────────┘
| 許可
v
ツールをディスパッチ → (下流でRead/Write scopeチェックは依然適用)

HTTPゲート(OpType::HttpRequest)は、既存のRead(100/min)・Write(20/min)制限とは別個のtoken bucketです——transport層に位置し、per-tool scopeチェックが走る前に、APIキーごとに生リクエスト量をスロットルします。GET /mcp/v1/streamはキーを?api_key=で運べます(ヘッダを設定できないブラウザのEventSourceでも認証可能にするため);POST /mcp/v1/callPOST /mcp/v1/stream/callはBearerヘッダのみ受け付けます。

すべての応答——認証不要の/healthzさえも——標準のcapabilityヘッダを携えるため、クライアントは両transport間で一貫して機能をネゴシエートできます。


HTTP経由の単発同期ツール呼び出し:

ターミナルウィンドウ
curl -X POST http://127.0.0.1:8765/mcp/v1/call \
-H "Authorization: Bearer $DUDUCLAW_MCP_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "memory_search",
"arguments": { "query": "deployment target" }
}
}'

認証不要の死活チェック:

ターミナルウィンドウ
curl http://127.0.0.1:8765/healthz
# → 200 OK

ブラウザからストリームを購読(EventSourceはヘッダを設定できないため、キーはquery stringに):

const es = new EventSource("http://127.0.0.1:8765/mcp/v1/stream?api_key=" + key);
es.onmessage = (e) => console.log("event:", e.data);

一つのツールボックス、二つの扉

Section titled “一つのツールボックス、二つの扉”

同じMCPツール——channel、memory、agent、skill、task、shared wiki、autopilot——は、ローカルCLIからstdio経由でも、そしてリモートHTTPクライアントからネットワーク経由でも到達可能です。ツールの二つ目の実装はありません;HTTP層は同じdispatcherの前に置かれたtransportアダプタです。

SSEはクライアントが結果を購読することを可能にし、同期呼び出しでブロックしたりループでポーリングしたりせずに済みます。stream/callを発射すれば、すでに開いている接続から答えが返ってきます——1024イベントのリプレイバッファ付きで、短い切断でもデータを失いません。

デフォルトでfail closed、デフォルトでスロットル

Section titled “デフォルトでfail closed、デフォルトでスロットル”

/healthzを除き、有効なキーなしにツールを奉仕するルートはありません。60 req/minのHTTP bucketは、per-tool scopeチェックが走る前にtransport層で乱用を上限制限し、所有権検証はある発信者が他人のストリームにプッシュするのを止めます。

/mcp/v1/stream?api_key=フォールバックは、まさにEventSourceAuthorizationヘッダを設定できないために存在します——ダッシュボードがproxyなしで直接購読できるように。


stdioは専用回線:一プロセス、一発信者、ネットワークなし。HTTP/SSE transportは交換台とラジオ放送です——POST /mcp/v1/callは単発のダイヤルと応答、GET /mcp/v1/streamは聴取、POST /mcp/v1/stream/callはリクエストを発射し結果が空中から返ってくるのを聴く。同じツールボックス、Bearer認証、60 req/minでスロットル、デフォルトでfail-closed——今やローカルCLIだけでなく、あらゆる外部HTTPクライアントが到達できます。