0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

rmcp 3.2のMCPサーバー、既定モードだとカウンターが毎回1に戻った

0
Posted at

はじめに

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 リクエストで再確認することをおすすめします。

関連記事

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?