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でRoots/Samplingが即例外、Loggingだけ無傷

0
Posted at

TL;DR

  • MCP の 2026-07-28 仕様は Roots / Sampling / Logging を deprecated にし、公式ブログは「12か月は動き続ける」と書いている
  • しかし @modelcontextprotocol/server@2.0.0 を実際に動かすと、2026-07-28 era のリクエストを処理している最中は roots/listsampling/createMessageping が即座に例外METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION)になる
  • 一方で sendLoggingMessage だけは例外にならない。3 つまとめて deprecated と読むと、移行時に壊れる箇所を読み違える
  • v2.0.0 の LATEST_PROTOCOL_VERSION2025-11-25 のまま。2026-07-28 は initialize のバージョン交渉ではなく、リクエストごとの _meta エンベロープで判定される

対象読者は、Roots / Sampling / Logging を使った MCP サーバーを運用していて、v1(@modelcontextprotocol/sdk)から v2 への移行を検討している開発者です。

何を確かめたかったか

MCP の 2026-07-28 リリース告知 は、Roots・Sampling・Logging の 3 つを deprecated にしたうえで、こう書いています。

They still work, and they'll keep working for at least twelve months. New implementations shouldn't adopt them.

「12 か月は動く」と読めば、移行は急がなくてよさそうに見えます。ただ「動く」の主語が仕様なのか SDK 実装なのかは、この一文からは決まりません。そこで、実際に npm から取得した SDK で 3 つの機能が本当に同じ壊れ方をするのか を確かめました。

検証環境は Node.js v22.22.2 / npm 10.9.7、パッケージは @modelcontextprotocol/server@2.0.0@modelcontextprotocol/client@2.0.0・比較用に @modelcontextprotocol/sdk@1.30.0 です。

前提: v2 はパッケージが分かれ、バージョン番号も別系統

まず npm 上の状態を確認します。

$ npm view @modelcontextprotocol/sdk dist-tags
{ latest: '1.30.0' }

$ npm view @modelcontextprotocol/server version
2.0.0

v1 の @modelcontextprotocol/sdk は 1.30.0 として今も更新が続いており、v2 は @modelcontextprotocol/server / @modelcontextprotocol/client という別パッケージの 2.0.0 です。@modelcontextprotocol/sdk@2.x を探しても存在しません。README にも「v2 is the stable release line, implementing the 2026-07-28 MCP spec」と明記されています。

ところが、その v2 が公開している定数はこうなっていました。

import * as S from '@modelcontextprotocol/server';
console.log(S.LATEST_PROTOCOL_VERSION);
console.log(JSON.stringify(S.SUPPORTED_PROTOCOL_VERSIONS));
2025-11-25
["2025-11-25","2025-06-18","2025-03-26","2024-11-05","2024-10-07"]

2026-07-28 は交渉可能なバージョン一覧に入っていません。ではどこで era が決まるのか。ランタイム(dist/*.mjs)を grep すると 2026-07-28 は 113 箇所に現れ、そのうちの一つがこう言っています。

Request is missing the required _meta envelope for protocol revision 2026-07-28
(io.modelcontextprotocol/protocolVersion, ...)

つまり 2026-07-28 は initialize のバージョン交渉ではなく、リクエストごとの _meta エンベロープで分類される。ステートレス化した仕様に合わせて、era 判定もリクエスト単位に移っています。SDK 内部の型もこれを type ProtocolEra = 'legacy' | 'modern' として持っていました。

対照実験: legacy era と modern era で同じ 4 つを叩く

同じサーバー実装に対して、era だけを変えて listRoots() / createMessage() / sendLoggingMessage() / ping() を呼びます。

条件A: legacy era(InMemoryTransport で 2025-11-25 を交渉)

import { Server, InMemoryTransport } from '@modelcontextprotocol/server';
import { Client } from '@modelcontextprotocol/client';

const [st, ct] = InMemoryTransport.createLinkedPair();
const server = new Server({ name: 'dep-probe', version: '0.0.1' },
  { capabilities: { logging: {}, tools: {} } });
const client = new Client({ name: 'probe-client', version: '0.0.1' },
  { capabilities: { roots: {}, sampling: {} } });

client.setRequestHandler('roots/list', async () => ({
  roots: [{ uri: 'file:///tmp', name: 'tmp' }] }));
client.setRequestHandler('sampling/createMessage', async () => ({
  model: 'stub', role: 'assistant', content: { type: 'text', text: 'ok' } }));

await Promise.all([server.connect(st), client.connect(ct)]);

結果です。

negotiated = 2025-11-25
OK   roots/list  (listRoots) -> {"roots":[{"uri":"file:///tmp","name":"tmp"}]}
OK   sampling    (createMessage) -> {"model":"stub","role":"assistant",...}
OK   logging     (sendLoggingMessage) -> undefined
OK   ping -> {}
logging notifications received = 1

4 つとも通ります。deprecated ではあるが動く、という告知どおりの挙動です。

条件B: modern era(_meta エンベロープに 2026-07-28 を載せる)

同じ 4 つを、createMcpHandler 経由の HTTP リクエストとして、_meta に 2026-07-28 を明示した状態で呼びます。

const envelope = {
  [S.PROTOCOL_VERSION_META_KEY]: '2026-07-28',       // io.modelcontextprotocol/protocolVersion
  [S.CLIENT_INFO_META_KEY]: { name: 'probe-client', version: '0.0.1' },
  [S.CLIENT_CAPABILITIES_META_KEY]: {},
};
const req = new Request('http://localhost/mcp', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'Mcp-Method': 'tools/call',
    'Mcp-Name': 'probe',
  },
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
    params: { name: 'probe', arguments: {}, _meta: envelope } }),
});
const res = await handler.fetch(req);

結果はこうなりました。

HTTP 200
roots/list              SdkError[METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION]
sampling/createMessage  SdkError[METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION]
ping                    SdkError[METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION]
notifications/message (logging)    OK

例外メッセージは次のとおりで、代替手段まで書かれています。

Server-to-client requests are not available on protocol revision 2026-07-28:
'roots/list' cannot be sent while serving a request on that revision.
Return inputRequired({ ... }) from the handler instead — the client fulfils
the embedded requests and retries the original request
(multi round-trip requests).
API legacy era(2025-11-25) modern era(2026-07-28)
listRoots() 成功 即例外
createMessage()(Sampling) 成功 即例外
ping() 成功 即例外
sendLoggingMessage() 成功 例外にならない

Logging だけ扱いが違う理由

ランタイム側には _assertPushApiInServedEra(method) というガードがあり、その呼び出し箇所は 4 つだけ でした。

 - ping
 - sampling/createMessage
 - elicitation/create
 - roots/list

いずれも「サーバーがクライアントへリクエストを送る」方向の API です。2026-07-28 がステートレスコアへ寄せた結果、常時開いた双方向ストリームを前提とするこれらは era 内で成立しなくなり、代わりに Multi Round-Trip Requests(inputRequired(...))へ誘導されます。

対して Logging は notifications/message という 通知 であり、リクエスト/レスポンスの往復を必要としません。だからガードの対象外で、例外にもならない。ここが「3 つまとめて deprecated」という読み方では抜け落ちる部分です。

型定義側も確認しました。.d.mts から @deprecated ブロックを機械的に抽出すると、2026-07-28 起因が 40 箇所、2025-11-25 の wire vocabulary 起因が 18 箇所。メソッドでは createMessage(3 オーバーロード)・listRootssendLoggingMessage(2 箇所)・elicitInputpinggetClientCapabilities が該当します。sendLoggingMessage も型では deprecated 扱い なので、IDE の取り消し線だけを見て「同じ壊れ方をする」と判断すると実挙動を取り違えます。

なお、今回の最小構成では modern era で sendLoggingMessage() を呼んでもレスポンスストリームに notifications/message は現れませんでした(io.modelcontextprotocol/logLevel_meta に載せた場合も同じ)。SDK のソース上は modern era のとき _meta の log level をしきい値として読み、未設定なら送出せず戻る分岐になっていますが、この点は今回のハーネスでは「例外にならない」ところまでしか観測できていません。

移行時に効いた副作用: ヘッダーと本文の一致チェック

検証中に一度 HTTP 400 で弾かれました。

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

2026-07-28 era のリクエストは Mcp-Method に加えて Mcp-Name ヘッダーを要求し、本文と食い違うと処理前に落とします。ステートレス化の目的が「普通の HTTP インフラでルーティングできること」なので、ロードバランサやプロキシがボディを開かずに振り分けられるよう、ヘッダー側にも同じ情報を要求する設計です。自前のクライアントや中継を書いている場合は、ここが移行時の最初のつまずきになります。

実際に移行するときの判断材料

今回の実測から言えることを整理します。

  1. v2 に上げただけでは壊れない。v2.0.0 の交渉ラダーは 2025-11-25 までで、クライアントが _meta に 2026-07-28 を載せてこない限り legacy era のままです。移行の第一歩としてのパッケージ差し替えは、Roots / Sampling / Logging を使っていても即死しません
  2. 壊れるのはクライアントが 2026-07-28 で話しかけてきたとき。そのときサーバー起点リクエストは例外になるので、listRoots() / createMessage() に依存したツールハンドラは inputRequired(...) への書き換えが必要です
  3. Logging は移行の優先度が下がる。例外にはならないため、Roots / Sampling の置き換えを先に片付けてよい。ただし modern era では送出条件が変わるので、ログが届いているかは別途確認したほうが安全です
  4. ping の扱いに注意。公式ブログの deprecated 3 点セットには入っていませんが、実装上は同じガードで弾かれます。ヘルスチェックに ping を使っている監視系があるなら、そこも移行対象です

「12 か月動き続ける」は仕様レベルの約束であって、SDK が era を切り替えた瞬間の挙動とは別の話でした。移行計画を立てるときは、告知の文面ではなく どの era のリクエストを受けるのか を基準にするのが確実です。

関連記事

参考リンク

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?