MCP の新しい仕様 2026-07-28 が出ました。告知の一文が「MCP is now stateless, making it easier to deploy and scale remote servers」です(@ClaudeDevs、2026-07-29 取得)。

リモート MCP サーバを自分で運用する側から読むと、これは仕様の話というより配置の話でした。実際に最小のサーバを書いて確かめたので、その結果まで書きます。

何が変わったか

ステートレス化は3層にまたがっている

changelog(2026-07-29 取得)を読むと、「ステートレス」が指しているものは1か所ではありませんでした。

セッション管理Mcp-Session-Id ヘッダがプロトコルレベルで削除されました。さらに initialize / notifications/initialized のハンドシェイク自体が無くなり、各リクエストが _meta に自分でプロトコル版とクライアント capability を載せます(io.modelcontextprotocol/protocolVersionio.modelcontextprotocol/clientCapabilities)。

トランスポート — GET のストリームエンドポイントが削除されました。サーバが公開するのは POST を受ける単一エンドポイントだけです。SSE は「そのリクエストにスコープされたストリーム」としてのみ残り、Last-Event-ID による再開機能も削除されています。切れたら新しい request ID で送り直す、という規定になりました。

双方向性roots/listsampling/createMessage のようなサーバ起点のリクエストは Multi Round-Trip Requests(MRTR)に置き換わりました。サーバは resultType: "input_required" を返し、クライアントが元のリクエストをリトライする際に応答を添えます。常時開いた双方向ストリームが要らなくなる、という形です。

かわりに server/discover が追加され、こちらはサーバの実装が MUST です。

ステートレス化「だけ」ではない

ここが読み違えていたところです。changelog の major change は9件あって、破壊的なものが並んでいます。

  • pinglogging/setLevelnotifications/roots/list_changed の削除
  • resources/subscribe / unsubscribesubscriptions/listen へ置換
  • 全ての結果に resultType フィールドが必須("complete" / "input_required"
  • Tasks がコアから公式拡張 io.modelcontextprotocol/tasks へ移動

minor change のほうにも効くものがあります。Mcp-Method ヘッダが全リクエストで必須になりました。Mcp-Name のほうは全部ではなく、tools/call / resources/read / prompts/get の3つで必須です。これは後ろで試します。

非推奨も入っていて、Roots・Sampling・Logging が Deprecated になりました。OAuth 2.0 の Dynamic Client Registration も、クライアント登録の方式としては Client ID Metadata Documents が優先されます。ただし DCR が使えなくなるわけではなく、CIMD に対応しない認可サーバとの後方互換のために残る、と書かれています。あわせて feature lifecycle のポリシーが採用され、非推奨期間は最低12か月と決まりました。ここは移行計画を立てるうえで効きそうです。

「最大の更新」と言っているのは誰か

小さい話ですが、書きながら気になったので残しておきます。同じリリースについて、主体ごとに表現の強さが違いました。

主体 表現
ツイート largest update to the protocol since launch
claude.com のブログ one of the most significant spec releases to date
MCP 公式ブログ(Anthropic 側の引用) most important since remote MCP first launched over a year ago

いずれも 2026-07-29 取得です。ツイートが一番強い言い方をしています。引用するときにどれを引いたか書かないと、実際より強い主張になってしまうところでした。

普及の数字も同日に2つあります。claude.com が「400M monthly SDK downloads, a 4x increase this year」、MCP 公式ブログが「close to half-a-billion downloads a month」。コネクタ数は claude.com 側の「over 950 MCP servers」だけですが、これはエコシステム全体の数ではなく、Claude の connectors directory に載っている数です。

手元にどう効きそうか

ここから先は推測です。まだ本番で試していません。

セッション固定の仕掛けが要らなくなるのでは? リモート MCP サーバを複数インスタンスで動かすとき、これまでは接続の生存期間をどこかが持つ必要がありました。ロードバランサのスティッキーセッション、あるいは外部ストアへの状態退避です。プロトコルレベルのセッションが消えたなら、素のラウンドロビンで足りるはずです。→ これは下で実測しました。

構成をシンプルにできるのでは? 個人的にはこちらのほうが大きい気がしています。スティッキーセッションの設定も、セッション情報を置いていた Redis も要らなくなるかもしれません。インスタンスを増やすときに気にすることも減ります。

ただ、すでに動いているものから外すとなると、足すときより判断は重そうです。

サーバーレスに載せやすくなるのでは? Functions や Container Apps のような、インスタンスの生存を前提にしない実行環境との相性は良くなりそうです。ただしこれは未検証で、実際にはコールドスタートや実行時間の上限のほうが効く可能性があります。

なお、ステートレスになったからといってアプリケーションが状態を持てなくなるわけではありません。MCP 公式ブログは、状態が要るならサーバが明示的なハンドルを発行し、それを通常の tool 引数として渡す、という書き方を示しています。プロトコルが状態を持たなくなっただけ、という整理です。

試した結果

SDK はまだ追いついていません

まずここで詰まりました。TypeScript SDK の最新は 1.30.0 で(2026-07-29 時点)、公開日は仕様の前日である 2026-07-27 です。公式レジストリから直接落として確かめました。

$ curl -s https://registry.npmjs.org/@modelcontextprotocol/sdk/1.30.0 \
    | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).dist.tarball"
https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz

$ curl -sL https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz | tar xz
$ grep -n "LATEST_PROTOCOL_VERSION\|SUPPORTED_PROTOCOL_VERSIONS" package/dist/esm/types.js
2:export const LATEST_PROTOCOL_VERSION = '2025-11-25';
4:export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];

2025-11-25 止まりでした。というわけで、SDK を使わずに手で書きました。

そしてこれが今回いちばん実感したところなのですが、ハンドシェイクもセッションもない仕様は、手で書けるくらい小さいです。依存ゼロの Node で、server/discovertools/listtools/call を持つサーバが 96 行で書けました。処理したインスタンス名を返すだけの whoami ツールを1本だけ持たせています。

以下、貼ってあるのは全部この自作サーバの応答です。「既存の実装で確かめた」ではなく「仕様を読んで書いたものが、そのとおりに動いた」という話として読んでください。

肝の部分だけ載せます。ハンドラは分岐だけで、状態を持つ変数がひとつもありません。

const VERSION = '2026-07-28'

const ok = (id, result) => ({
  jsonrpc: '2.0', id,
  result: {
    resultType: 'complete', ...result,
    _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'stateless-demo', version: '0.1.0' } },
  },
})
const err = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })

function handle(body, headers) {
  const { id, method, params = {} } = body

  // 標準リクエストヘッダの検証。欠落・不一致は 400 と -32020 HeaderMismatch
  if (headers['mcp-protocol-version'] !== VERSION)
    return [400, err(id, -32020, `MCP-Protocol-Version header missing or not ${VERSION}`)]
  if (headers['mcp-method'] !== method)
    return [400, err(id, -32020, `Mcp-Method header '${headers['mcp-method']}' does not match body value '${method}'`)]
  if (method === 'tools/call' && headers['mcp-name'] !== params.name)
    return [400, err(id, -32020, `Mcp-Name header '${headers['mcp-name']}' does not match body value '${params.name}'`)]

  // 各リクエストが自己記述する。ハンドシェイクは無い
  const client = params._meta?.['io.modelcontextprotocol/protocolVersion']
  if (client && client !== VERSION)
    return [400, err(id, -32022, `UnsupportedProtocolVersion: ${client}`)]

  switch (method) {
    case 'server/discover':
      return [200, ok(id, {
        supportedVersions: [VERSION], capabilities: { tools: {} },
        ttlMs: 3600000, cacheScope: 'public',
      })]
    case 'tools/list':
      return [200, ok(id, { tools: TOOLS })]
    case 'tools/call':
      if (params.name !== 'whoami') return [200, err(id, -32602, `Unknown tool: ${params.name}`)]
      return [200, ok(id, {
        content: [{ type: 'text', text: `instance=${INSTANCE} port=${PORT} pid=${process.pid}` }],
      })]
    default:
      return [200, err(id, -32601, `Method not found: ${method}`)]
  }
}

残りは createServer で POST /mcp だけ受けて(Origin を検証し、それ以外のメソッドとパスには 405 を返す)、この handle に渡すだけです。

前に置くロードバランサは素のラウンドロビンです。スティッキーセッションなし、セッション参照なし、ヘッダも見ません。

const BACKENDS = [3001, 3002]
let n = 0
createServer((req, res) => {
  const port = BACKENDS[n++ % BACKENDS.length]
  const up = request({ host: '127.0.0.1', port, path: req.url, method: req.method, headers: req.headers },
    (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res) })
  req.pipe(up)
}).listen(3000, '127.0.0.1')

ハンドシェイクなしでいきなり呼べる

initialize を送らず、最初のリクエストが server/discover です。

$ curl -s -X POST http://127.0.0.1:3000/mcp \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'MCP-Protocol-Version: 2026-07-28' \
    -H 'Mcp-Method: server/discover' \
    -d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"}}}}'
{
  "jsonrpc": "2.0",
  "id": "d1",
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": { "tools": {} },
    "ttlMs": 3600000,
    "cacheScope": "public",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "stateless-demo", "version": "0.1.0" }
    }
  }
}

スティッキーセッションなしで2インスタンスに散っても通る

見立ての中核はここでした。同じ「会話」のつもりで6回連続して tools/call を投げます。セッションヘッダは付けていません。

まず1回分の生の応答です。

$ curl -s -X POST http://127.0.0.1:3000/mcp \
    -H 'Content-Type: application/json' \
    -H 'MCP-Protocol-Version: 2026-07-28' \
    -H 'Mcp-Method: tools/call' -H 'Mcp-Name: whoami' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}' | jq .
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "content": [
      { "type": "text", "text": "instance=A port=3001 pid=51723" }
    ],
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "stateless-demo", "version": "0.1.0" }
    }
  }
}

content のテキストだけ取り出して6回まわします。

$ for i in 1 2 3 4 5 6; do
    curl -s -X POST http://127.0.0.1:3000/mcp \
      -H 'Content-Type: application/json' \
      -H 'MCP-Protocol-Version: 2026-07-28' \
      -H 'Mcp-Method: tools/call' -H 'Mcp-Name: whoami' \
      -d '{"jsonrpc":"2.0","id":'$i',"method":"tools/call","params":{"name":"whoami","arguments":{}}}' \
    | jq -r '"req#\(.id) \(.result.content[0].text)"'
  done
req#1 instance=A port=3001 pid=51723
req#2 instance=B port=3002 pid=51725
req#3 instance=A port=3001 pid=51723
req#4 instance=B port=3002 pid=51725
req#5 instance=A port=3001 pid=51723
req#6 instance=B port=3002 pid=51725

毎回違うインスタンス(別プロセス)に落ちていますが、全部通ります。事前のハンドシェイクが無いので、そもそも「どのインスタンスとハンドシェイクしたか」という概念がありません

ヘッダと本文の不一致は拒否する

ヘッダを必須にした理由として仕様が挙げているのは、中継(ロードバランサ・ゲートウェイ・監視ツール)が本文をパースせずに経路決定と検査をできるようにするため、です。

そのうえで別に、ヘッダと本文が食い違う要求はサーバが拒否する MUST になっています。こちらの理由は、ロードバランサはヘッダ値で経路を決め MCP サーバは本文の値で実行する、というように構成要素ごとに信頼する情報源が違うと脆弱性になりうるから、と書かれています(Streamable HTTP の仕様、2026-07-29 取得)。拒否するときは HTTP 400 と JSON-RPC エラーを返すことも MUST です。

自作サーバでもそのとおりに実装したので、試しにずらしてみます。

$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:3000/mcp \
    -H 'MCP-Protocol-Version: 2026-07-28' \
    -H 'Mcp-Method: tools/call' -H 'Mcp-Name: whoami' \
    -d '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"drop_table"}}'
{"jsonrpc":"2.0","id":9,"error":{"code":-32020,"message":"Mcp-Name header 'whoami' does not match body value 'drop_table'"}}
HTTP 400

-32020 は仕様が HeaderMismatch に割り当てているエラーコードです(-32001 からの変更)。ヘッダには無害な名前を書いて本文で別のツールを呼ぶ、という経路が塞がれています。

ただし塞いでいるのはサーバの実装です。上の応答も自作サーバが返したもので、仕様がそう決めているというだけでは何も起きません。ヘッダでルーティングや監査をする構成なら、この一致検証は自分で書く必要があります。

GET は無い

これも自作サーバの挙動ですが、仕様どおりに書くとこうなる、という話です。

この実験で確かめられていないこと

書いておかないとフェアではないので、限界を並べます。

実際の MCP クライアントでは試していません。 使ったのは curl だけです。2026-07-28 に対応した SDK がまだ無いので、試せるクライアントがありません。上で「同じ会話のつもりで6回投げる」と書いたのは、あくまで人力での模擬です。

サーバは自作で、適合性は検証していません。 仕様を読んで書いた96行であって、公式の適合性テストに通したわけではありません。「仕様どおりに動いた」は、自分の読解どおりに自分の実装が動いた、という意味しかありません。

本番相当ではありません。 localhost で Node のプロセスを2本立てただけで、実際のロードバランサもネットワーク分断もコールドスタートも見ていません。

そしていちばん正直に書くべきところですが、この実験はやや循環しています。 ステートレスなサーバを自分で書いて、それが固定を要らないことを見せているので、ある意味では当然の結果です。実演であって証明ではありません。

そのうえで、実験が本当に示していると思うのは次の2つです。

  • 仕様が手で書けるくらい小さくなったこと。 これは実感として本物でした
  • プロトコルにセッション識別子が存在しないので、ロードバランサが固定する対象がそもそも無いこと。 ただしこれは仕様を読めば分かる話で、手を動かしたのは腑に落とすためです
$ curl -s -o /dev/null -w 'GET /mcp -> HTTP %{http_code}\n' http://127.0.0.1:3000/mcp
GET /mcp -> HTTP 405

公開するのは POST の1本だけになりました。

まとめ

  • ステートレス化はセッション・トランスポート・双方向性の3層にまたがっていて、Mcp-Session-Id もハンドシェイクも GET ストリームも無くなりました
  • 変更はそれだけではなく、major だけで9件あります。Roots・Sampling・Logging が非推奨、OAuth のクライアント登録は CIMD 優先になりました。非推奨期間は最低12か月と決まっています
  • 自作サーバを2インスタンス並べて素のラウンドロビンに通す、というところまでは動きました。 ただし実クライアントでも本番相当でもないので、スティッキーセッションを外せるかどうかは自分の環境で確かめる必要があります
  • SDK はまだ 2025-11-25 止まりですが、仕様が小さくなったので手で書けます

実際のクライアントでの検証、本番相当の環境、サーバーレスに載せた場合は、いずれもまだ試せていません。SDK が追いついたら書きます。