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 SDK v2のステートレス化、旧クライアントは無改造で動いた

0
Posted at

はじめに

MCP の仕様 2026-07-28 が正式リリースされ、initialize / initialized のハンドシェイクと Mcp-Session-Id ヘッダーが撤廃されました。公式アナウンスの表現を借りると「MCP は双方向のステートフルなプロトコルから、request/response のステートレスなプロトコルへ変わる」という変更です(MCP 公式ブログ 2026-07-28)。

TypeScript SDK 側も同じ 2026-07-28 に @modelcontextprotocol/server 2.0.0 が公開され、README には「v2 is the stable release line, implementing the 2026-07-28 MCP spec」と書かれています。ただ、自前の MCP サーバーを運用している立場で気になるのはそこではありません。すでに動いているクライアントは v2 のサーバーに繋がるのか。繋がるとしたら、どの条件で壊れるのか です。

対象読者は、自作の MCP サーバーを HTTP で公開していて、SDK の v1 から v2 への移行を検討している開発者です。

この記事では v1(@modelcontextprotocol/sdk 1.30.0)と v2(@modelcontextprotocol/server 2.0.0)で最小構成のサーバーを 2 本立て、同じ HTTP リクエストを投げて挙動の差を 9 項目測りました。まず結果の一覧を置きます。

# 投げたもの v1(sdk 1.30.0) v2(server 2.0.0)
1 initialize なしの tools/list 400 -32000 Server not initialized 200・ツール一覧が返る
2 initialize 200・mcp-session-id ヘッダーを発行 200・セッションIDの発行なし
3 応答の Content-Type(レガシー経路) text/event-stream text/event-stream
4 Accept: application/json のみ(レガシー経路) 406 Not Acceptable
5 _meta2026-07-28 を宣言 モダン経路に切り替わる
6 モダン経路で Mcp-Method ヘッダーなし 400 -32020
7 tools/callMcp-Name ヘッダーなし 400 -32020
8 モダン経路の tools/list 応答 application/jsonttlMs / cacheScope 付き
9 v1 クライアントから接続 成功 成功(無改造)

結論だけ先に書くと、既存の v1 クライアントは v2 サーバーに無改造で繋がりました。壊れるのは「新仕様に乗せたつもりで、ヘッダーを 1 つ書き忘れたとき」です。

2026-07-28 で消えたもの

公式ブログが挙げている変更のうち、HTTP を直接触る実装者に効くのは次の 4 点です。

  • initialize / initialized のやり取りを廃止。プロトコルバージョン・クライアント情報・ケイパビリティは リクエストごとに載せる
  • Mcp-Session-Id ヘッダーを廃止。スティッキーセッションなしでラウンドロビンのロードバランサ配下に置ける
  • Mcp-MethodMcp-Name の HTTP ヘッダーを必須化。ゲートウェイが JSON ボディを解析せずにルーティング・計測できる
  • list / read 系の結果に ttlMscacheScope が付き、キャッシュ可能になる

Roots・Sampling・Logging と レガシーの HTTP+SSE トランスポートは非推奨になりましたが、移行期間として最低 12 か月は動作するとされています。

検証環境

npm パッケージのバージョンと公開時刻は次のとおりです(npm view で取得)。

パッケージ バージョン 公開時刻(JST)
@modelcontextprotocol/sdk 1.30.0 2026-07-28 02:56
@modelcontextprotocol/core 2.0.0 2026-07-28 08:55
@modelcontextprotocol/server 2.0.0 2026-07-28 08:55
@modelcontextprotocol/node 2.0.0 2026-07-28 08:55

v1 系列の最新版と v2 の初版は、同じ 7/28 に約 6 時間差で公開されています(npm view <pkg> time の各バージョンの登録時刻・JST 換算)。つまり v1 を使い続ける選択肢も残っている状態です。Node.js は v22.22.2、リクエストはすべて curl で直接投げました。

先に定数を覗いておきます。

node -e "const t=require('@modelcontextprotocol/sdk/types.js'); \
  console.log(t.LATEST_PROTOCOL_VERSION)"
# => 2025-11-25

node -e "const s=require('@modelcontextprotocol/server'); \
  console.log(s.LATEST_PROTOCOL_VERSION, s.DEFAULT_NEGOTIATED_PROTOCOL_VERSION)"
# => 2025-11-25 2025-03-26

v2 でも LATEST_PROTOCOL_VERSION2025-11-25 のままでした。ここだけ見ると「v2 は新仕様に未対応なのでは」と読めますが、そうではありません。新仕様ではプロトコルバージョンがハンドシェイクで交渉されるものではなくなり、リクエストごとの _meta に載る値 になったためです。実際、v2 には PROTOCOL_VERSION_META_KEY(値は io.modelcontextprotocol/protocolVersion)や legacyStatelessFallback といった、新旧を振り分けるための輸出が並んでいます。

サーバー 2 本

v1 側は従来どおり StreamableHTTPServerTransport にセッションIDジェネレータを渡した構成です。

// server-v1.mjs(@modelcontextprotocol/sdk 1.30.0)
const server = new McpServer({ name: 'v1-demo', version: '1.0.0' });
server.registerTool('ping', { description: 'ping', inputSchema: { msg: z.string() } },
  async ({ msg }) => ({ content: [{ type: 'text', text: `pong:${msg}` }] }));

const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await server.connect(transport);

v2 側は createMcpHandler にサーバーの ファクトリ を渡します。1 リクエスト 1 サーバーインスタンスという発想に変わったので、connect を自分で呼ぶ場面がなくなりました。

// server-v2.mjs(@modelcontextprotocol/server 2.0.0)
const handler = createMcpHandler(() => {
  const server = new McpServer({ name: 'v2-demo', version: '2.0.0' });
  server.registerTool('ping', { description: 'ping', inputSchema: { msg: z.string() } },
    async ({ msg }) => ({ content: [{ type: 'text', text: `pong:${msg}` }] }));
  return server;
});
http.createServer(toNodeHandler(handler)).listen(3122);

toNodeHandler が返すのは Node 標準の (req, res) ハンドラなので、今回のように素の http モジュールへ直接渡しても動きました(フレームワークを使う場合は Express なら app.all('/mcp', toNodeHandler(handler)) の形になります)。

同じ ping ツールを持つサーバーが、3111 番(v1)と 3122 番(v2)で待っている状態にしました。

実測1: initialize を飛ばして tools/list を投げる

まず、ハンドシェイクを省いていきなりツール一覧を要求します。

curl -s -X POST http://127.0.0.1:3111/ \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

v1 の応答は 400 でした。

{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}

同じリクエストを v2(3122 番)へ投げると 200 が返り、ツール一覧がそのまま得られます。

HTTP/1.1 200 OK
content-type: text/event-stream

event: message
data: {"result":{"tools":[{"name":"ping", ...}]},"jsonrpc":"2.0","id":1}

ステートレス化の実害がいちばん分かりやすいのがこの差です。v1 ではセッションを張らないとツール一覧すら引けなかったので、ヘルスチェックや疎通監視のために毎回ハンドシェイクを踏む必要がありました。v2 では 1 リクエストで完結します。

実測2: セッションIDはどうなったか

v1 に正規の initialize を投げると、応答ヘッダーにセッションIDが乗ります。

HTTP/1.1 200 OK
mcp-session-id: e5389d7f-9496-4f4e-aadd-de59353caf5d

v2 に同じ initialize を投げても 200 で応答は返りますが、mcp-session-id ヘッダーは付きませんでした。ボディの protocolVersion2025-11-25 です。

{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},
 "serverInfo":{"name":"v2-demo","version":"2.0.0"}},"jsonrpc":"2.0","id":3}

つまり v2 は「initialize を受け取ったら、旧クライアントとして応答は返すが、セッションは張らない」という振る舞いでした。v2 の輸出にある legacyStatelessFallback はこの経路のことだと読めます。

実測3: モダン経路にはどう入るのか

ではどうやって 2026-07-28 の経路に乗せるのか。答えは params._meta にプロトコルバージョンとクライアント情報を載せることでした。まず不完全なエンベロープを送ってみます。

curl -s -X POST http://127.0.0.1:3122/ \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{"_meta":{
       "io.modelcontextprotocol/protocolVersion":"2026-07-28",
       "io.modelcontextprotocol/clientInfo":{"name":"c","version":"1"}}}}'

返ってきたのは、どのキーが足りないかを名指しするエラーでした。

{"jsonrpc":"2.0","error":{"code":-32602,
 "message":"Invalid _meta envelope for protocol revision 2026-07-28: io.modelcontextprotocol/clientCapabilities: missing",
 "data":{"envelope":{"key":"io.modelcontextprotocol/clientCapabilities","problem":"missing"}}},"id":5}

protocolVersion2026-07-28 と宣言した瞬間に、clientInfoclientCapabilities が揃っていることまで検査されます。旧仕様では initialize の 1 回だけ送っていた情報が、リクエストごとに必須の付帯情報へ移ったわけです。

実測4: Mcp-MethodMcp-Name は本当に必須だった

ヘッダー側も同じくらい厳格でした。エンベロープを完全にしても、Mcp-Method ヘッダーを外すと 400 です。

{"jsonrpc":"2.0","error":{"code":-32020,
 "message":"Bad Request: the request headers and body disagree: the body names method tools/list but the required Mcp-Method header is absent",
 "data":{"mismatch":{"header":"(missing)","body":"..."}}},"id":7}

tools/call の場合はさらに Mcp-Name も要求されます。

{"jsonrpc":"2.0","error":{"code":-32020,
 "message":"Bad Request: the request headers and body disagree: the body carries params.name=\"ping\" but the required Mcp-Name header is absent"}}

エラーコードは JSON-RPC の標準外である -32020 で、メッセージは「ヘッダーとボディが食い違っている」という言い方をします。ヘッダー欠落の検査は _meta の検査より に走りました。clientInfo を外し、かつ Mcp-Method も外したリクエストでは、返ってきたのはヘッダー側のエラーだけです。移行中にエラーメッセージを見て潰していくときは、ヘッダー → エンベロープの順に直すことになります。

両方のヘッダーを付けると通ります。

curl -s -X POST http://127.0.0.1:3122/ \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -H 'Mcp-Method: tools/call' -H 'Mcp-Name: ping' \
  -d '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"ping",
       "arguments":{"msg":"modern"},"_meta":{...}}}'
{"result":{"content":[{"type":"text","text":"pong:modern"}],"resultType":"complete",
 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"v2-demo","version":"2.0.0"}}},"jsonrpc":"2.0","id":11}

実測5: Accept ヘッダーの要求が経路で違う

ここが移行でいちばん引っかかりそうだと感じた箇所です。レガシー経路(_meta なし)で Accept: application/json だけを送ると、v2 は 406 を返します。

{"jsonrpc":"2.0","error":{"code":-32000,
 "message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}

ところが、モダン経路に乗せた同じリクエストは Accept: application/json だけで 200 になり、応答も text/event-stream ではなく application/json で返ります。

HTTP/1.1 200 OK
content-type: application/json

{"result":{"tools":[...],"resultType":"complete","ttlMs":0,"cacheScope":"private",
 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"v2-demo","version":"2.0.0"}}},"jsonrpc":"2.0","id":6}

公式ブログが言っていた ttlMscacheScope は、この経路の応答に実際に入っていました(今回の最小サーバーでは ttlMs: 0 / cacheScope: "private"、つまり実質キャッシュ不可の既定値)。resultType: "complete" という新しいフィールドも付きます。

SSE を張らずに素の JSON でやり取りできるのはモダン経路だけ、という切り分けです。「v2 にしたのに SSE のままだ」と感じたら、それはレガシー経路に落ちている合図だと考えてよさそうです。

実測6: v1 クライアントは無改造で繋がるのか

最後に、いちばん知りたかった互換性です。v1 の Client + StreamableHTTPClientTransport を、v2 のサーバーへ向けました。

const c = new Client({ name: 'v1-client', version: '1.30.0' });
await c.connect(new StreamableHTTPClientTransport(new URL(process.argv[2])));
const tools = await c.listTools();
const call = await c.callTool({ name: 'ping', arguments: { msg: 'from-v1-client' } });
# v1 クライアント → v2 サーバー
OK tools= ping | call= [{"type":"text","text":"pong:from-v1-client"}]

# v1 クライアント → v1 サーバー(対照)
OK tools= ping | call= [{"type":"text","text":"pong:from-v1-client"}]

どちらも成功しました。v1 クライアントは initialize を投げてセッションIDを期待しますが、v2 側はセッションIDを返さないまま以降のリクエストを受け付けるため、結果として通ります。

リクエストがどちらの経路に落ちるかを整理すると次のようになります。

測ってみて分かったこと

今回いちばん意外だったのは、v2 が「新仕様のサーバー」ではなく「2 つの時代を同時に喋るサーバー」だった ことです。createMcpHandler に渡したファクトリは 1 つなのに、リクエストの _meta を見て応答の形式(SSE か JSON か)も検査の厳しさも変わりました。ドキュメント上は「v2 = 2026-07-28 実装」と一行で書かれていますが、実挙動は経路の切り替え装置に近いという印象です。

もう 1 つ、LATEST_PROTOCOL_VERSION が v1 と v2 で同じ 2025-11-25 だったのは、最初に定数だけ見て早合点しかけた箇所でした。ステートレス化とは「バージョン交渉をやめる」ことでもあるので、この定数はもう新旧の判断材料にならない、と考えを改める必要がありました。移行の判定に使うなら、定数ではなく実リクエストの応答(ttlMs が付くか、Accept: application/json 単独で通るか)を見るほうが確実です。

エラーメッセージの質は素直に良いと思いました。-32020 は「ヘッダーとボディが食い違っている」と言い、どのヘッダーが欠けたかを名指しします。-32602 はどのメタキーが不足かを data.envelope に構造化して返します。手で curl を叩きながら移行する作業が、想像よりずっと短時間で終わりました。

移行時に見るところ

今回の実測から、v1 → v2 の移行で確認すべき点をまとめます。

確認項目 見方
既存クライアントの疎通 v1 クライアントはそのまま繋がる。まずサーバーだけ上げる進め方が取れる
応答の Content-Type text/event-stream のままならレガシー経路。JSON にしたいならモダン経路へ
ゲートウェイ・プロキシ モダン経路では Mcp-Method / Mcp-Name を転送する必要がある。落とすと 400
クライアント実装 リクエストごとに clientInfoclientCapabilities を載せる
セッション前提の実装 mcp-session-id に紐づけて状態を持っていたなら、持ち先を変える必要がある
キャッシュ list 系の ttlMs / cacheScope を見る実装にできる(既定は ttlMs: 0

なお今回検証していないのは、MRTR(Multi Round-Trip Requests)による確認ダイアログ相当の挙動と、Tasks 拡張(io.modelcontextprotocol/tasks)、および認可まわりです。ここは別途測る必要があります。

おわりに

「ステートレス化」と聞くと大移行を身構えますが、少なくとも TypeScript SDK v2 では、サーバーを上げても既存クライアントは動き続ける ことが実測で確認できました。壊れるのは新経路に片足だけ乗せたときです。ヘッダー 2 つ(Mcp-Method / Mcp-Name)とメタキー 3 つ(protocolVersion / clientInfo / clientCapabilities)が揃って初めて新経路が成立する、という点だけ押さえておけば、移行中の 400 は落ち着いて潰せます。

自作の MCP サーバーを HTTP で公開している方は、まず Accept: application/json 単独でリクエストを投げてみてください。406 が返るならレガシー経路、ttlMs 付きの JSON が返るならモダン経路です。今どちらで喋っているかを知るのが、移行の最初の一歩になります。

関連記事

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?