🔌

なぜMCPは「セッション」を捨てたのか ─ 2026-07-28 改訂とステートレスコアの設計思想

に公開

はじめに

「MCP サーバーが動くようになった。ツールも一通り呼べる。でも、AI がこれをちゃんと使いこなせているかは自信がない」──そう感じたことがある人は多いはずだ。

tools/call が通ればサーバーは「動いた」ことになる。しかし AI エージェントが実行前にツール定義だけを根拠に呼び出しを組み立て、失敗しても人間に確認を取らず自分で立て直せることまで求めると、話は変わってくる。「動く」ことと「AI が迷わず使いこなせる」ことの間には、思っているより大きな距離がある、というのがこの本の一貫した立場だ。

そこへ 2026-07-28 の仕様改訂が来た。initialize ハンドシェイクと Mcp-Session-Id が仕様から消え、MCP は明示的にステートレスコアへ移行した。既存実装を移行する側からすると大きな変更だが、これは設計原則そのものが変わったというより、「状態はプロトコルの接続にではなく、アプリケーション側の永続ハンドルに持たせる」という、もともとこの本が推していた考え方が、仕様の要求として追認された結果だと捉えるほうが実態に近い。

本記事では、この改訂で MCP サーバーの実装がどう変わるのかを、Node.js + TypeScript の実装例つきで整理する。元になっているのは、MCP を知るところから Node.js での実装、リモート提供・OAuth・運用まで 全 35 章・5 部構成 でまとめた本だ。

TL;DR(6項目)
  1. 2026-07-28 改訂で MCP はステートレスコアになった。initialize ハンドシェイクと Mcp-Session-Id削除され、各リクエストが _meta にバージョンと capabilities を運び、server/discover が新設された。
  2. 状態を跨がせたいならセッションではなく永続ハンドルを使う。サーバーが不透明な ID を発行し、クライアントが通常のツール引数として渡し返すという設計だ。
  3. 60 秒は仕様の値ではないDEFAULT_REQUEST_TIMEOUT_MSEC は TypeScript SDK のクライアント側デフォルトにすぎない。長時間処理の正規ルートは公式拡張 Tasks(resultType: "task"tasks/get ポーリング → tasks/update / tasks/cancel)だが、オプトインなのでフォールバックは必須になる。
  4. サーバー発の JSON-RPC リクエストは新規設計では使えなくなりMRTR(resultType: "input_required" を返し、クライアントが再投函する)に置き換わった。Sampling / Roots / Logging / Elicitation は非推奨で、削除は最短でも 2027-07-28 以降になる。
  5. 新拡張 MCP Apps では、ツールが ui:// リソースを宣言し、ホストがサンドボックス iframe 内でインタラクティブ UI を描画できる。ただし UI 非対応ホストのために、contentstructuredContent からなる従来の2 層レスポンスは必ず返す設計にする。
  6. 認可はOIDC Discovery 対応が MUST・RFC 8707 resource が MUST・RFC 9207 iss 検証が MUST・CIMD 推奨で DCR は非推奨という方向へ強化された。Token Passthrough は「良くない設計」から「仕様違反」になった。

1. 2026-07-28 改訂の要点

モダン期は 2026-07-28 以降、レガシー期はそれより前の各バージョン(直近は 2025-11-25)を指す。今回の改訂で一番効いてくるのは、「開いた接続を会話の継続性とみなさない」という前提が明文化されたことだ。

仕様は、開いた接続がそのまま会話やセッションを意味するわけではないとしている。同一の stdio プロセスやストリームの上に、無関係なリクエストが混在してもよい、という世界観だ。これは 1 回の呼び出しを完結させ、状態はファイルや DB に置くという、CLI の実行モデルにかなり近い。

削除済み モダン期で取るべき行動
initialize / notifications/initialized per-request _meta を読む
Mcp-Session-Id ツール引数の永続ハンドルへ置き換える
GET ストリーム / Last-Event-ID 再開 切断時は新しい request ID で再送する
logging/setLevel 仕様にない前提を置かない
新規設計でのサーバー発 JSON-RPC リクエスト MRTR を使う(Sampling はレガシー互換のみ非推奨として存続)
新設・再編 種別 何のためか
server/discover 新設 対応バージョン・capabilities・identity を返す。サーバーは MUST 実装
subscriptions/listen 再編 通知をクライアントの明示的な購読へ統合する
resultType / CacheableResult 新設 結果の完了状態とキャッシュ鮮度・共有範囲を表す
extensions 新設 Tasks などを両側の合意で有効化する
MRTR 新設 サーバーが待たず、入力付き再投函で対話する
Mcp-Method / Mcp-Name 新設 HTTP ゲートウェイが JSON を読まずに観測・ルーティングできる

「削除済み」と「非推奨」は別軸だと考えたほうがいい。Roots / Sampling / Logging / Elicitation / DCR は非推奨だが、最低 12 か月の猶予がある。一方、モダン期で initializeMcp-Session-Id を新規実装する余地はもうない。

ここで紛らわしいのが Logging だ。リクエスト logging/setLevel はモダン期で完全に削除済みで、代替は _metaio.modelcontextprotocol/logLevel になる。一方、ログ送出という機能区分そのものは非推奨カテゴリに属し、12 か月の猶予期間がある。「個別リクエストの削除」と「機能区分の非推奨」は別の話として整理しておきたい。

_meta で文脈を渡す

リクエストごとに文脈を渡す形は、次のようになる。

// examples/tools-list-request.json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "extensions": {} },
      "io.modelcontextprotocol/clientInfo": { "name": "example-host", "version": "1.0.0" }
    }
  }
}

サーバーは全レスポンスの _metaio.modelcontextprotocol/serverInfo を返す。能力を起動時に一度だけ読んで保持しておく設計は、モダン期ではおそらく誤りになる。同一の stdio プロセスやストリームの上に別クライアントや無関係な要求が混在しうる以上、起動時点で読んだ能力は途中で古くなっていてもおかしくないからだ。


2. 永続ハンドルという設計

2 フェーズパターンは、この改訂後もそのまま使える。get_prompt でデータとプロンプトを返し、クライアント側の AI が考え、store_result で結果を保存するという設計だ。サーバーは検証済みのデータと操作だけを担い、解釈と意思決定はクライアント側の AI に委ねる──これが本書の言う「推論はクライアントに委譲する」という原則にあたる。

以下は本書のサンプルプロジェクト sa(CLI)/ sa-mcp(MCP サーバー)── スタートアップ分析ツールの共通コア ── での実装例だ。

// src/mcp/tools/analyze-dimension.ts
if (phase === "get_prompt") {
  const data = await loadFromDB(session_id, dimension);
  const prompt = buildAnalysisPrompt(data, dimension);
  return toolResult({
    prompt_for_ai: prompt,
    data,
    next_step: {
      tool: "sa_analyze_dimension",
      arguments: { session_id, dimension, phase: "store_result" },
    },
  }, `## 分析プロンプト準備完了(${dimension})`);
}

ここでの session_id はプロトコルセッションではない。サーバーが発行し、クライアントが通常の引数として渡し返す永続ハンドルだ。名前は歴史的に session_id でも、意味は「接続に結び付いた状態」ではない。

永続ハンドルには 3 つの条件があると考えている。

  1. 推測困難な高エントロピー値にする
  2. サーバーが発行し、クライアントが次のツール引数として返す
  3. 外部ストアで TTL と所有者を検証し、再起動・再送・並列実行に耐える

Mcp-Session-Id を受信し、プロセス内 Map のキーにして状態を復元する

session_id を返し、次の tools/call の引数から受け取り、DB で所有者・有効期限を確認する

この差は、切断に強いというだけの話ではない。同じ要求が再送されても、UPSERT のようなべき等な書き込みで応答できる。状態の置き場所をアプリケーション側へ戻しておくと、プロトコルの改訂そのものに振り回されにくくなる、というのがここでの実感だ。


3. 60 秒制約の実態と Tasks 拡張

ここは訂正しておきたい点がある。「60 秒が実効上限」という理解がよく流布しているが、仕様には 60000msDEFAULT_REQUEST_TIMEOUT_MSEC も登場しない。60 秒は TypeScript SDK のクライアント側デフォルトタイムアウトであって、プロトコル要件ではない。

Progress 通知も、プロトコル上は任意の状態通知にすぎない。多くの SDK が内部タイマーをリセットすることはあり得るが、タイムアウト延長の意味論そのものは仕様化されていない。

それでも、ツール分割を第一選択にする方針自体は変わらない。AI が中間結果を見て判断でき、並列実行に耐え、失敗を小さい単位に閉じ込められるからだ。同期処理を守る withTimeout も、特定の仕様値に追従するものというより、アプリケーション側の安全マージンとして持たせておくものだと考えるほうが素直だ。

// src/mcp/utils/with-timeout.ts
export async function withTimeout<T>(fn: () => Promise<T>, ms = 50_000): Promise<T> {
  return Promise.race([
    fn(),
    new Promise<never>((_, reject) => {
      setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms);
    }),
  ]);
}

ここでの 50_000 は、主要クライアントの既定値(TypeScript SDK の 60 秒)を下回るように置いた一例だ。値そのものは配布先のクライアント実装や処理特性から個別に決めるべきもので、仕様が定める上限ではない。

Tasks 拡張は「待つ」機構ではない

【拡張】Tasks は、長時間の仕事を接続上で待つ機構ではない。仕事を永続化して taskId を返し、クライアントが tasks/get でポーリングし、必要なら tasks/update、中止時は tasks/cancel を使う仕組みだ。既定はポーリングで、通知は任意になる。tasks/update は実行中のタスクへ追加入力を渡すためのタスク専用の経路であり、元のツール呼び出しをまるごと再投函する MRTR(次節)とは別物として扱う必要がある。

最重要のルールは、拡張対応を宣言していないクライアントにタスクを返してはならないという点だ。能力はリクエストごとの _meta から読む。

// src/mcp/tools/collect-data.ts
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
  const capabilities = extra._meta?.[
    "io.modelcontextprotocol/clientCapabilities"
  ] as { extensions?: Record<string, boolean> } | undefined;
  const supportsTasks = Boolean(
    capabilities?.extensions?.["io.modelcontextprotocol/tasks"],
  );
  const args = req.params.arguments;

  if (isLargeTarget(args) && supportsTasks) {
    const task = await taskStore.createAndCommit(args); // 永続化(プロセス再起動後も tasks/get で参照できる)
    return { resultType: "task", taskId: task.id, status: "working",
      ttlMs: task.ttlMs, pollIntervalMs: 1_000 };
  }

  if (isLargeTarget(args) && !supportsTasks) {
    return splitSuggestionError(args); // 構造化エラーでツール分割を促す
  }

  return withTimeout(() => collectSynchronously(args));
});

Tasks 非対応なら、同期で完了させるか、ツール分割を促す構造化エラーを返す。対応の有無で意味が変わる拡張ほど、通常経路の劣化対応を先に設計しておくほうが安全だ。ちなみに上のコードの task.ttlMs は Task 自体の有効期限であり、CacheableResultttlMs(キャッシュ鮮度)とは別物になる。同名でも意味が違う点は覚えておきたい。


4. MRTR と MCP Apps

MRTR ── サーバーは待たず、いったん返す

MRTR(Multi Round-Trip Requests)は、サーバーが追加の入力を必要とするときの現行の形だ。以前のサーバー発リクエストでは、サーバーが開いたストリーム上で入力を待つ形をとっていた。MRTR はその逆で、サーバーは resultType: "input_required" を返して終了し、クライアントが入力を集め、元の要求の params 直下に inputResponses を加えて再投函する。inputResponses はツール引数ではなくプロトコルのフィールドなので、ハンドラは第 2 引数の extra.inputResponses から読むことになる。

// src/mcp/tools/synthesize-report.ts
export async function handleSynthesizeReport(args, extra) {
  const hasExistingReport = await reportExists(args.session_id);
  const confirmation = extra.inputResponses?.confirmOverwrite;

  if (hasExistingReport && confirmation === undefined) {
    return {
      resultType: "input_required",
      content: [{ type: "text", text: "既存の分析レポートを上書きしてよいか確認してください。" }],
      inputRequests: {
        confirmOverwrite: {
          description: "既存の分析レポートを上書きします。続けますか?",
          schema: { type: "boolean" },
        },
      },
    };
  }

  if (hasExistingReport && confirmation !== true) {
    return toolResult({ cancelled: true, reason: "declined" }, "既存レポートは上書きしませんでした。");
  }

  const report = await synthesizeAndUpsertReport(args.session_id);
  return toolResult({ report }, "## レポートを保存しました");
}

❌ 開いたストリームで await server.elicitInput() する

✅ いったん返し、同じ引数で再度呼ばれても正しく動くハンドラにする

再投函される以上、ハンドラはべき等でなければならない。ここでも効いてくるのが永続ハンドルと UPSERT だ。

【拡張】MCP Apps ── 2 層レスポンスに UI という 3 層目を足す

MCP Apps では、ツール定義の _meta.ui.resourceUriui:// リソースを指す。ホストは HTML / JS / CSS を取得し、サンドボックス化した iframe で描画し、アプリとは postMessage で通信する。

要素 役割
_meta.ui.resourceUri ツールが UI リソースを宣言する
ui:// ホストがバンドルを取得する
sandbox iframe 親 DOM や Cookie から UI を隔離する
postMessage アプリとホストが ui/ メッセージを交換する

ホストがサーバー作者を完全には信頼せずに描画できる、というのが iframe サンドボックスの意味だ。ホストは呼び出せるツールや sendOpenLink(ホストの UI からリンクを開かせる許可)の可否も制御できる。

ただし UI はあくまで拡張であり、これがないと成立しない設計にはできない。UI 対応ホストでなくても、ツールは contentstructuredContent を返して使える必要がある。2 層レスポンスを捨てる話ではなく、2 層 + UI の 3 層目を足す話として理解しておくのがよさそうだ。


5. 認可のハードニング

認可の土台は OAuth 2.1 draft + PKCE のままだが、接続先を推測で信じない方向へ強化された。

項目 2026-07-28 の位置付け
RFC 8414 / OIDC Discovery 1.0 クライアントが両方に MUST 対応
RFC 8707 resource 認可要求・トークン要求の両方で MUST
RFC 9207 iss 認可コード交換前の検証が MUST
Protected Resource Metadata リソースサーバーが MUST 実装
CIMD(Client ID Metadata Document) 推奨されるクライアント登録方式。HTTPS URL の client_id から AS が JSON メタデータを取得する
DCR(RFC 7591) 【非推奨(2026-07-28)】後方互換のために温存

Token Passthrough は仕様違反になった

受信した Bearer Token を、そのまま下流 API へ中継してはいけない。リソースサーバーは正規の resource を基準に audience を検証し、それ以外のトークンは受理も中継もしてはならない、というのがこの改訂の立場だ。

// src/mcp/auth/verify-token.ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const EXPECTED_AUDIENCE = "https://mcp.example.com";
const EXPECTED_ISSUER = "https://auth.example.com";
const ALLOWED_ALGORITHMS = ["RS256", "ES256"]; // `alg: "none"` や HMAC への降格を拒む
// 簡略化のため JWKS URL を直書き。実運用では RFC 8414 / OIDC Discovery が返す jwks_uri を解決して使う
const JWKS = createRemoteJWKSet(new URL(`${EXPECTED_ISSUER}/.well-known/jwks.json`));

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    audience: EXPECTED_AUDIENCE, issuer: EXPECTED_ISSUER, algorithms: ALLOWED_ALGORITHMS,
  });
  if (!payload.sub) throw new Error("token missing sub claim");
  if (!payload.jti) throw new Error("token missing jti claim"); // 失効チェックを素通りさせない
  if (await jtiStore.exists(`revoked:${payload.jti}`)) throw new Error("token revoked");
  return {
    userId: payload.sub,
    scopes: String(payload.scope ?? "").split(" ").filter(Boolean),
    clientId: typeof payload.client_id === "string" ? payload.client_id : undefined,
    tenantId: typeof payload.tenant_id === "string" ? payload.tenant_id : undefined,
    claims: payload, // プラン判定や監査ログはここから読む
  };
}

algorithms の許可リストと jti 失効チェックを省くと、それぞれアルゴリズム混同攻撃と失効済みトークンの再利用を防げなくなる。戻り値も userIdscopes だけに削らないほうがいい。クライアント別レート制限やプラン判定・監査ログが clientId / tenantId / claims を参照するため、ここで落としてしまうとそれらの制御が丸ごと無効化される。

この方向は、OIDC ネイティブなエンタープライズ IdP との接続を重視したものと読める。DCR を許可しない認可サーバーも現実に存在するため、CIMD を第一選択にし、DCR は CIMD 非対応時の互換用に限定しておくのが無難だ。

なお、リモート提供では OAuth スコープをそのままサービス提供条件にもできる。プラン別・ツール別・テナント別に公開範囲を設計すれば、認可とマネタイズを同じ境界で扱える、というのが本書の第 V 部の主張だ。ただしそれより先に守るべきは、resourceaudiss・署名・有効期限の検証であることに変わりはない。


6. 仕様の外側にある Claude プラットフォームの機能

ここからはコア仕様とは別の、Claude 側の製品・エコシステムの動きになる。サーバーの相互運用性を確保するだけなら知らなくてもよいが、Claude を配布先の一つにするなら判断材料にはなる。

  • MCP Tunnels はリサーチプレビューだ。公開エンドポイントやファイアウォール変更なしで、社内ツールを外向き専用接続により Claude から到達可能にする構想で、mTLS、Anthropic 管理の暗号化層、MCP OAuth を重ねている
  • オブザーバビリティダッシュボードはパブリックベータだ。アクティブユーザー、ツール呼び出し数、ディレクトリ順位、ヘルススコア、エラー率、レイテンシを追える。ただし見えるのは Claude 経由の利用に限られる
  • Enterprise-Managed Auth は Okta の Cross App Access(XAA)を基盤に、管理者が選んだコネクタへ IdP グループからゼロタッチでアクセスさせる仕組みだ。ローンチ時は Asana、Atlassian、Canva、Figma、Granola、Linear、Supabase の 7 コネクタで、稼働確認済みの IdP は Okta のみ。Entra ID は方向性として名指しされている段階にとどまる

Anthropic は、Claude のコネクタディレクトリに MCP サーバーが 950 超掲載され、MCP SDK の月間ダウンロードは 4 億超・年内 4 倍になったと案内している(参考の "Bringing MCP 2026-07-28 to Claude")。この勢い自体は無視できないが、コア仕様と特定ホストの製品機能をひとつの必須要件として混ぜないことが、長く使えるサーバーを作る条件だと考えている。


7. 仕様が変わっても腐らない設計判断

ここまで見てきた変更の多くは、仕様のバージョンが上がるたびに書き換わる部分だ。だからといって、次の改訂が来るたびに設計をゼロから見直す必要があるかというと、そうでもないと思っている。

動詞ファーストのツール名と enum によるパラメータ設計、content / structuredContent の 2 層レスポンス、error_code / expected / suggestion による構造化エラー、stdout を JSON-RPC 専用チャネルとして守る規律──これらは仕様のどの条項にも直接は書かれていない。にもかかわらず、この改訂の前後でまったく揺らいでいない。理由はおそらく単純で、これらは「MCP の利用者は AI エージェントである」という前提から直接導かれる設計判断であって、プロトコルの状態管理方式(セッションか永続ハンドルか)とは別のレイヤーにあるからだ。

逆に言えば、今回消えたもの(initializeMcp-Session-Id、サーバー発リクエスト)はすべて「接続に何かを預ける」設計だった。状態をアプリケーション側に持たせ、AI からの入力を前提にツールとレスポンスを設計するという考え方に立っていれば、今回の改訂は既存の設計をひっくり返すというより、答え合わせに近い体験になったはずだ。


まとめ

MCP という規格自体は今後も仕様変更が続く。しかし「利用者は AI である」という前提から導かれる設計判断の多くは、仕様が変わっても通用する。

2026-07-28 の改訂は、この見立てを裏付ける格好の材料になった。最新の API 名を覚えるだけでなく、なぜその制約があるのかを読めるようになることが、次の改訂にも耐える近道だと思う。

本は、次の読者を想定して書いている。

  • CLI の兄弟インターフェースを持つ stdio MCP サーバーを作りたい人は、Chapter 08〜Chapter 11(「stdout / stderr の規律とロガー設計」〜「CLI という兄弟インターフェース」)
  • AI が使いこなせるツール、2 フェーズ、永続ハンドル、MRTR、Tasks、MCP Apps を設計したい人は、Chapter 13〜Chapter 22(「開発の出発点」〜「MCP Apps 拡張」。Chapter 19「永続ハンドルとべき等性」、Chapter 21「Tasks 拡張」を含む)
  • リモート MCP、OAuth / OIDC、運用、マネタイズ、Claude 製品機能を判断したい人は、第 V 部の Chapter 26〜Chapter 34(「なぜリモート MCP 化するのか」〜「Claude プラットフォーム機能」。Chapter 28「OAuth 2.1 + OIDC の実装」、Chapter 31「マネタイズのフックポイント設計」を含む)

Chapter 01「はじめに ― 本書の歩き方」は無料で読める。まずそこで本書の全体構成を押さえ、必要な章から読み進めるのがおすすめだ(本書は全 35 章・1,000 円)。


関連記事

https://zenn.dev/yun_bow/books/f0b23ef3093ddf

参考

Discussion