MCPサーバーのtool一覧やresourceが動的に変わると、クライアント側のキャッシュをいつ捨てるかが問題になります。2025-eraのGET streamやresources/subscribeをそのまま残すと、2026-07-28のstatelessな通信と通知経路が二重になり、切断時にどの変更を失ったか判断できません。
この記事は、変更通知をsubscriptions/listenへ移す設計に限定します。仕様全体の破壊的変更は MCP 2026-07-28 移行ガイド、旧GET streamとMcp-Session-Idの実装は MCP Streamable HTTP 移行ガイド と MCP Session 管理ガイド を参照してください。長時間jobのstatus通知はcoreの変更通知と混ぜず、MCP Tasks extension 設計ガイド の対応確認を先に行います。
先に結論: 通知は差分配送ではなくキャッシュ無効化に使う
subscriptions/listenで受け取る通知は、「何が変わったかを完全に再現するイベントログ」ではありません。基本方針は次の3点です。
- クライアントが必要な通知だけを明示的に購読する
- 通知を受けたら対応する一覧またはresourceを再取得する
- streamが切れたら再購読し、依存dataを再取得する
2026-07-28ではSSEのevent IDとLast-Event-IDによる再送が外れました。切断中の通知を取り戻すreplayはありません。したがって、通知本文だけを正としてローカル状態を組み立てる設計は避けます。
3種類の通知経路を分ける
MCPの通知をすべてsubscriptions/listenへ流すわけではありません。
| 通知の目的 | 経路 | 例 |
|---|---|---|
| server全体の変更 | subscriptions/listen | tool / prompt / resource一覧の変更 |
| 特定resourceの変更 | subscriptions/listen | notifications/resources/updated |
| 実行中requestの進捗 | そのrequestのresponse stream | notifications/progress |
| request単位のlog | そのrequestのresponse stream | notifications/message |
progressやlogをlisten streamへ移すと、元のリクエストとの相関が壊れます。逆にtool一覧の変更を個々のtools/list response streamへ流すと、一覧リクエストが終わった後に通知できません。
旧方式から何が変わるか
| 2025-era | 2026-07-28 |
|---|---|
| HTTP GETでserver-to-client streamを開く | subscriptions/listenをPOSTし、responseをSSEで保つ |
resources/subscribe / unsubscribe | resourceSubscriptions filter |
| list changeを未要求でも受信しうる | 種類ごとに明示opt-in |
| sessionに通知先を結び付ける | listen request IDでsubscriptionを識別 |
| SSE event IDで再開 | replayなし。再購読して再取得 |
移行時は旧GET endpointを残したまま新streamも開かないようにします。同じ変更を二重に受け、cache invalidationや再取得が連続する原因になります。
listenリクエストで必要な通知だけを指定する
クライアントはnotifications fieldで購読対象を指定します。HTTPではMCP-Protocol-Version、Mcp-Methodなどの標準headerも必要です。
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: subscriptions/listen
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": "listen-catalog-01",
"method": "subscriptions/listen",
"params": {
"notifications": {
"toolsListChanged": true,
"resourcesListChanged": true,
"resourceSubscriptions": [
"inventory://tenant-a/products",
"policy://tenant-a/agent-rules"
]
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
指定できるcore filterは次の4つです。
toolsListChangedpromptsListChangedresourcesListChangedresourceSubscriptions
省略またはfalseにした種類をサーバーは送れません。「念のため全部購読する」のではなく、クライアントが実際に再取得できる対象だけに絞ります。
最初のacknowledgementを待ってから有効とみなす
HTTPのレスポンスはSSE streamです。サーバーが最初に送るJSON-RPC messageはnotifications/subscriptions/acknowledgedでなければなりません。
{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-catalog-01"
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["inventory://tenant-a/products"]
}
}
}
重要なのは、要求したfilterと承認されたfilterが同じとは限らない点です。上の例ではresourcesListChangedと1つのresource URIが承認されていません。クライアントはリクエスト送信時ではなく、このacknowledgementを受信した時点でsubscriptionを有効と記録します。stdioでは別subscriptionのmessageが先に流れる場合があるため、channel全体ではなく各subscriptionIdについてackより前の通知を拒否します。
const requested = new Set(requestedFilters);
const honored = new Set(acknowledgedFilters);
const missing = [...requested].filter((filter) => !honored.has(filter));
if (missing.length > 0) {
await fallBackToPolling(missing);
}
大事なのは、空のackを成功扱いして通知を待ち続けないことです。
notificationはlisten request IDへ関連付ける
1つのクライアントが複数subscriptionを同時に開けます。各通知の_metaに入るio.modelcontextprotocol/subscriptionIdは、元のsubscriptions/listen request IDです。
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-catalog-01"
}
}
}
特にstdioは1本のchannelへ複数subscriptionの通知が流れるため、このIDで振り分けます。unknownまたは終了済みのsubscription IDを受けたらdataを更新せず、protocol errorとして記録します。
request IDをprocess内の連番だけにすると、再起動後のlogと衝突します。接続単位の識別子と組み合わせ、次のような観測eventを残します。
{
"event": "mcp_subscription_notification",
"connection_id": "conn_01JX9A",
"subscription_id": "listen-catalog-01",
"notification": "tools_list_changed",
"action": "invalidate_tools_cache"
}
通知後はinvalidateして再取得する
list change通知には新しい一覧全体は含まれません。通知を受けたら該当キャッシュをstaleにし、tools/list、prompts/list、resources/listを再取得します。
async function onToolsListChanged() {
toolsCache.markStale();
await refetchTools({ dedupeKey: "tools/list" });
}
同じ通知が短時間に集中する場合、毎回upstreamへリクエストを送らず、短いdebounceとsingle-flightでまとめます。ただし、固定間隔のpollingへ完全に戻すのではなく、切断時のfallbackとして使います。
2026-07-28のlist resultにはttlMsとcacheScopeもあります。通知とTTLを競合させず、次のように扱います。
- TTL内でも変更通知を受けたらstaleにする
- 通知が来なくてもTTLを超えたら再取得する
- userやtenantで結果が変わる場合は
cacheScope: "private"にする - shared cache keyへ認証主体を混ぜない
切断後はre-listenとrefetchをセットにする
予期しない切断後に、最後に受けた通知の続きから再開する仕組みはありません。クライアントはbackoffしてlisten requestを送り直し、その後に依存dataを再取得します。
stream lost
-> mark subscription unhealthy
-> backoff with jitter
-> send new subscriptions/listen with new request ID
-> wait for acknowledgement
-> refetch subscribed lists and resources
-> mark healthy
再取得をackより前に行うと、取得直後の変更通知を受けられないwindowが残ります。先に新しいstreamを確立し、その後でsnapshotを取り直します。通知とsnapshotが競合する場合は、stale flagを残してもう一度だけ再取得します。
再接続には上限とcircuit breakerを置きます。認証errorや未対応versionをnetwork errorとして無限再接続してはいけません。
HTTPとstdioで終了方法を分ける
| transport | clientが終了する方法 | serverからの終了 |
|---|---|---|
| HTTP | SSE response streamを閉じる | 元のlisten requestへの空responseを送りstreamを閉じる |
| stdio | listen request IDを指定したnotifications/cancelled | 元のlisten requestへの空responseを送る |
HTTPでは旧sessionのDELETEや別のunsubscribe requestを送るのではなく、対象response streamを閉じます。stdioでnotifications/cancelledを送るときは、他の実行中request IDと取り違えないようsubscription registryから参照します。
サーバーがgracefulに終了する場合は、transportにかかわらず元のlisten requestへのJSON-RPC responseを返します。
{
"jsonrpc": "2.0",
"id": "listen-catalog-01",
"result": {
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-catalog-01"
}
}
}
HTTPではresponse送信後にSSE streamを閉じます。stdioではこのresponseが論理subscriptionの終了を示します。server側の切断だけで終了扱いにせず、responseを受け取れなければ予期しない切断として再購読と再取得を行います。
graceful closeと予期しない切断は監視上も分けます。deployや計画停止によるgraceful closeで即時alertを出す必要はありませんが、短時間にremote disconnectが繰り返される場合は通知欠落riskとして扱います。
multi-process serverではevent busを共有する
statelessなHTTP handlerを複数instanceで動かすと、tool catalogの変更を検知したinstanceとlisten streamを保持するinstanceが別になる場合があります。process内のevent emitterだけでは通知が届きません。
本番では、次の情報を共有event busへ流します。
type McpChangeEvent =
| { kind: "tools_list_changed"; tenantId: string }
| { kind: "prompts_list_changed"; tenantId: string }
| { kind: "resources_list_changed"; tenantId: string }
| { kind: "resource_updated"; tenantId: string; uri: string };
購読時と配信時の両方でtenantとprincipalを確認します。resourceSubscriptionsに推測した別tenantのURIを入れられても、ackへ含めず、更新の存在も漏らさない設計にします。
extension notificationは別の対応確認gateで確認する
coreのsubscriptions/listenが動いても、extension固有の通知まで流せるとは限りません。2026年8月15日時点のTypeScript SDKには、Tasksのnotifications/tasksをlisten streamへ載せられない未解決issueがあります。
Tasks statusをpushで受けたい場合は、次を別々に検証します。
- coreのlist / resource通知が通る
- extension filterがparse後も残る
- acknowledgementがextension filterを承認する
- extension notificationをserver event busが配送できる
- 受信できない場合も
tasks/getpollingで完了できる
core通知が通っただけで「Tasks notification対応」と判定してはいけません。
本番導入checklist
- 旧GET streamと
resources/subscribeの利用箇所を棚卸しした - requestしたfilterとacknowledged filterを比較する
- 空または一部だけのackにfallbackを用意した
-
subscriptionIdをlisten request IDへ照合する - 通知後にcacheをinvalidateして再取得する
- TTL切れでも再取得できる
- 切断後にre-listenしてsnapshotを取り直す
- HTTPとstdioの終了処理を分けた
- multi-process用の共有event busを用意した
- tenant / principalごとに購読と配信を認可する
- retry上限、backoff、切断率を監視する
- extension notificationをcoreと別に検証した
まとめ
subscriptions/listenは、旧GET streamのURLをPOSTへ変えるだけの機能ではありません。クライアントが通知を明示的に選び、サーバーが承認した範囲を返し、listen request IDで複数streamを識別する契約です。
私なら最初にtoolsListChangedだけを有効にし、ack、cache invalidation、切断後の再取得を検証します。その後にresource URI、multi-process event bus、extension通知の順で広げます。replayがない前提でsnapshotを取り直せる設計にすれば、通知欠落をlocal stateの破損へつなげずに済みます。