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?

MCP移行前にSession依存を検出するPython

0
Posted at

MCP移行前にSession依存を検出するPython

ステートレス化したMCPサーバーで、どこに残っていた状態が消えるのか。ロードバランサーの設定だけを直して終えると、ここで事故る。

7月28日に正式化予定のMCP 2026-07-28 は、プロトコル層の initialize / initializedMcp-Session-Id を外す。RCの段階では、能力確認は server/discover へ移り、各リクエストが自分のプロトコル情報を持つ形になっている。普通のラウンドロビン配下へ置きやすくなるのは確か。ただし、買い物かごや編集中の文書まで消えるわけではない。

状態を必要とするなら、ツールの戻り値で basket_id のようなハンドルを返し、次の呼び出しで引数として渡す。移行の本体は「セッションを捨てる」作業より、この契約を表に出す作業だと思う。

先に見るのはインフラではなく、ツールの引数

旧構成では、クライアントがどのサーバーへ来たかと、どの作業の続きかが Mcp-Session-Id の裏に隠れやすい。ステートレス化後もアプリケーションの状態は持てるが、ツール呼び出しだけを見て追える形にする必要がある。

旧構成と移行後の構成を図にすると、差分はここに集約される。

自分はこの手の移行で、まずIngressのsticky設定を探しがちだった。でも先に調べるべきなのは、後続ツールが何のIDも受け取らずに動いていないかだった。add_itemcheckout に作業対象の引数がなければ、接続先をどれだけきれいにしても状態は引き継げない。

以下は、JSON化したツール実行traceに対して、ハンドルの生成と受け渡しを検査する最小のPythonだ。contracts を自分のツール名とID名に置き換えて使う。

from __future__ import annotations

def audit_state_handles(calls: list[dict], contracts: dict[str, dict]) -> list[str]:
    known_handles: set[str] = set()
    errors: list[str] = []

    for index, call in enumerate(calls, start=1):
        rule = contracts.get(call["tool"], {})
        produced = rule.get("produces")
        required = rule.get("requires")

        if produced:
            value = call.get("result", {}).get(produced)
            if not value:
                errors.append(f"#{index} {call['tool']}: {produced} を返していない")
            else:
                known_handles.add(value)

        if required:
            value = call.get("arguments", {}).get(required)
            if not value:
                errors.append(f"#{index} {call['tool']}: {required} がない")
            elif value not in known_handles:
                errors.append(
                    f"#{index} {call['tool']}: {required}={value!r} は、このtraceで生成されていない"
                )

    return errors

contracts = {
    "create_basket": {"produces": "basket_id"},
    "add_item": {"requires": "basket_id"},
    "checkout": {"requires": "basket_id"},
}

ok_trace = [
    {"tool": "create_basket", "arguments": {}, "result": {"basket_id": "b-781"}},
    {"tool": "add_item", "arguments": {"basket_id": "b-781", "sku": "book-42"}},
    {"tool": "checkout", "arguments": {"basket_id": "b-781"}},
]
unknown_handle_trace = [
    {"tool": "create_basket", "arguments": {}, "result": {"basket_id": "b-781"}},
    {"tool": "add_item", "arguments": {"basket_id": "b-999", "sku": "book-42"}},
]

assert audit_state_handles(ok_trace, contracts) == []
assert audit_state_handles(unknown_handle_trace, contracts) == [
    "#2 add_item: basket_id='b-999' は、このtraceで生成されていない"
]
print(audit_state_handles(ok_trace, contracts))
print(audit_state_handles(unknown_handle_trace, contracts))

Python 3.12.13で実行した出力はこうなった。

[]
["#2 add_item: basket_id='b-999' は、このtraceで生成されていない"]

最初は「必須引数があるか」だけを見ていた。これだと basket_id="適当な文字列" が通る。作成ツールの戻り値と突き合わせる検査まで入れると、少なくともテスト用traceで状態の出所を追える。実運用では、ログにツール名、引数のハンドル、結果のハンドルを残せば同じ検査をCIへ寄せられる。

ハンドルを渡しても認可は渡さない

ここは混ぜない方がいい。basket_id は対象を指すための値であって、アクセス権ではない。checkout 側では、そのハンドルの所有者やテナントを認証済みの主体と照合する。IDを明示したことで、別ユーザーのIDを渡せるようにしてはいけない。

もう一つ、サーバーから任意のタイミングで通知する設計、購読、接続に紐付く分離が必要なら、ステートレス化を急がない。2026-07-28対応のSDKとクライアントが混在する期間は、旧プロトコルを残す経路も要る。RCは最終仕様ではないので、固定した日付で実装を決め打ちしないことも大事になる。

おわりに

今回の変更で消えるのは、MCPが面倒を見ていた接続単位の状態だ。業務上の状態までなくす必要はない。むしろ basket_iddraft_id をツール契約として出した方が、traceもテストも障害調査も楽になる。

移行前に、状態を作るツール、状態を使うツール、認可を確かめる場所を1枚に書き出す。この監査をしてからsticky sessionを外せば、ステートレス化はインフラの模様替えで終わらず、MCPのツール設計を少しまともにできる。

参考:

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?