はじめに
Rust で MCP サーバーを書くとき、公式 SDK の rmcp には legacy_session_mode という真偽値の設定があります。既定は false で、この状態のサーバーは initialize を要求しません。仕様の流れに沿った素直な既定に見えますが、この既定のまま Arc<Mutex<i32>> でカウンターを持つサーバーを書くと、何度呼んでも戻り値は 1 のままでした。
対象読者は、Rust で MCP サーバーを実装している、あるいは rmcp を 2.x から 3.x へ上げようとしている開発者です。
rmcp 3.2.0 で同じサーバーを 2 モードで起動し、同じリクエストを投げて挙動の差を測りました。測定は 2026-09-02(JST)、rustc 1.94.1 / cargo 1.94.1 の Linux 環境です。
2モードの実測比較
先に結果表を置きます。サーバーコードは同一で、違いは StreamableHttpServerConfig::default().with_legacy_session_mode(bool) の引数だけです。
| # | 測定軸 | 既定(legacy_session_mode = false) |
legacy_session_mode = true |
|---|---|---|---|
| 1 |
initialize なしの tools/call
|
HTTP 200・ツールが実行される | HTTP 422 Unexpected message, expect initialize request
|
| 2 |
initialize 応答の Mcp-Session-Id
|
付かない | UUID が付く(例 ea815da5-50ad-4e15-a74d-c7b7a79b1946) |
| 3 |
increment を 3 回連続実行 |
1 → 1 → 1
|
1 → 2 → 3
|
| 4 | セッション ID なしの 2 回目以降 | そもそも不要 | HTTP 422 で弾かれる |
| 5 |
initialize で 2026-07-28 を要求 |
2025-11-25 が返る |
同左 |
| 6 | レスポンスの Content-Type |
text/event-stream(既定) |
text/event-stream(既定) |
軸 3 が本記事の主題です。同じコード・同じツール呼び出しで、既定モードだけ状態が積み上がりませんでした。
検証に使ったサーバー
依存は rmcp と axum だけの最小構成です。カウンターを 1 つ持ち、increment ツールが加算後の値を返します。
[dependencies]
rmcp = { version = "3.2.0", features = ["server", "macros", "transport-streamable-http-server"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "signal"] }
tokio-util = "0.7"
axum = "0.8"
anyhow = "1"
#[derive(Clone)]
struct Counter {
counter: Arc<Mutex<i32>>,
tool_router: ToolRouter<Counter>,
}
#[tool_router]
impl Counter {
#[tool(description = "Increment the counter by 1")]
async fn increment(&self) -> Result<CallToolResult, McpError> {
let mut c = self.counter.lock().await;
*c += 1;
Ok(CallToolResult::success(vec![ContentBlock::text(c.to_string())]))
}
}
サーバーの組み立てはこの 3 行が中心です。legacy を環境変数から受けて、2 モードを別ポートで同時に起動しました。
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(legacy)
.with_json_response(json_response)
.with_cancellation_token(ct.child_token());
let service = StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
config,
);
let router = axum::Router::new().nest_service("/mcp", service);
ServerInfo は #[non_exhaustive] なので、構造体リテラルで書くと error[E0639]: cannot create non-exhaustive struct using struct expression になります。ServerInfo::default() を作ってからフィールドを代入する形に直すとコンパイルが通りました。
軸1・2: initializeとセッションIDの要否
既定モードのサーバー(ポート 8011)に、initialize を一切送らずいきなり tools/call を投げます。
curl -s -X POST http://127.0.0.1:8011/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"increment","arguments":{}}}'
HTTP=200
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"1"}],"isError":false}}
同じリクエストを legacy_session_mode = true のサーバー(ポート 8012)へ投げると、こちらは拒否されます。
HTTP=422
Unexpected message, expect initialize request
initialize を送った場合のレスポンスヘッダーにも差が出ました。既定モードは Mcp-Session-Id を返さず、レガシーモードだけが UUID を発行します。レガシー側は 2 回目以降のリクエストにこのヘッダーを付けないと、同じ 422 で弾かれました。
軸3: 状態が続くかどうか
既定モードのサーバーへ increment を 3 回続けて投げた結果です。
"text":"1"
"text":"1"
"text":"1"
レガシーモードで initialize してセッション ID を取り、同じ ID を付けて 3 回投げた結果はこうなります。
"text":"1"
"text":"2"
"text":"3"
原因はサーバーの組み立て方にあります。StreamableHttpService::new の第 1 引数はクロージャで、これが「サービスインスタンスの生成関数」です。セッションがない既定モードでは、リクエストのたびにこのクロージャが呼ばれて Counter::new() が走ります。Arc<Mutex<i32>> はそのリクエストの中だけで生き、応答を返した時点で捨てられます。
つまり 1 が返り続けるのはバグではなく、状態を持たない設計に対して状態を持つツールを書いたことによる不一致です。2.x 系のセッション前提で書いたサーバーを 3.x にそのまま載せると、コンパイルは通り、ツールも正常応答を返し、値だけが静かに間違うという形で現れます。
軸5: プロトコルバージョンのネゴシエーション
initialize の protocolVersion を変えて、サーバーが何を返すかを確認しました。
| クライアントが要求 | サーバーの応答 |
|---|---|
2026-07-28 |
2025-11-25 |
2025-11-25 |
2025-11-25 |
2025-06-18 |
2025-06-18 |
2024-11-05 |
2024-11-05 |
9999-01-01 |
2025-11-25 |
rmcp 3.2.0 が名乗る最新は 2025-11-25 で、2026-07-28 を要求しても引き下げられます。セッションを廃した既定は、2026-07-28 を実装した結果ではなく SDK 側が先行して採った既定だと読めます。仕様側では 2026-07-28 の告知が initialize/initialized の往復と Mcp-Session-Id ヘッダーを引退させ、各リクエストがプロトコルバージョン・クライアント識別・ケイパビリティを _meta に載せて単独で飛ぶ形に変わると説明しています。
ビルドコスト
トランスポートを変えたときのビルド差分も測りました。いずれも cargo clean 後の debug ビルドで、同一マシンでの計測です。
| 構成 | 依存クレート数 | ビルド時間 | バイナリサイズ |
|---|---|---|---|
rmcp[server, transport-io](stdio のみ) |
58 | 18 秒 | 約 3.9 MB |
rmcp[server, macros, transport-streamable-http-server] + axum |
93 | 27 秒 | 約 74.8 MB |
バイナリサイズの差はデバッグ情報を含んだ値なので、そのままリリース時のサイズにはなりません。それでも、stdio だけで足りるツールに HTTP トランスポートを足すと依存が 35 クレート増える点は、コンテナイメージを絞りたい場合の判断材料になります。なお rmcp は 2025-03-16 に初版が出て以来、累計 2354 万ダウンロード・直近 90 日で 1194 万ダウンロードを記録しています(crates.io API の 2026-09-02 時点の値)。
共通していたパターン
3 つの軸を並べると、差はすべて「サービスインスタンスがどこで作られるか」に還元されます。
状態を持ちたい場合の逃げ道は 2 つあります。1 つは legacy_session_mode を true に戻す方法で、旧クライアントとの互換も同時に確保できますが、スティッキーセッションが必要になるためロードバランサーの前提が変わります。もう 1 つは仕様側が推奨する方法で、2026-07-28 の告知は「状態を跨いで持ちたいならツールから明示的なハンドルを発行し、モデルに引数として返させる」と書いています。カウンターの例なら、increment が値ではなくカウンター ID を返し、次の呼び出しでその ID を受け取る設計になります。
筆者が実際に書いていて危ないと感じたのは、この不一致がコンパイルエラーにならない点です。Arc<Mutex<T>> を持った構造体は既定モードでも問題なくビルドでき、ツールも HTTP 200 を返します。テストがツールの成功可否だけを見ていると通ってしまい、値が積み上がらないことに気づけません。移行時は「同じツールを 2 回叩いて 2 回目の値が変わるか」を最初に確認するのが確実でした。
まとめ
rmcp 3.2.0 で確認できたのは次の 4 点です。
- 既定(
legacy_session_mode = false)はinitializeなしでtools/callが通り、Mcp-Session-Idも発行しません - 既定モードではリクエストごとにサービスインスタンスが作り直され、
Arc<Mutex<i32>>の値は積み上がりません(3 回叩いて1,1,1) -
legacy_session_mode = trueにするとinitializeが必須になり、セッション ID を付けたリクエストの間は状態が保持されます(1,2,3) - rmcp 3.2.0 が返す最新プロトコルは
2025-11-25で、2026-07-28を要求しても引き下げられます
本記事の結果は 2026-09-02 時点・rmcp 3.2.0 での挙動です。rmcp は 2026-08-31 に更新されたばかりで、プロトコル対応も動いている最中なので、バージョンを上げたら同じ 3 リクエストで再確認することをおすすめします。
関連記事
- MCP SDK v2のステートレス化、旧クライアントは無改造で動いた
- go1.27でuuid標準化、google/uuidよりゼロアロケーションで22%速い
- Go 1.27のjson/v2で重複キーを黙って上書きしなくなった