MCP の仕様 2026-07-28 が出ました。@ClaudeDevs の告知では、ステートレス化によってリモートサーバを配置し、台数を増やしやすくなったと紹介されています(2026-07-29 取得)。

サーバを運用する側としては、接続先を同じインスタンスに固定しなくて済むのかが気になりました。最小のサーバを書き、2 インスタンスへ交互にリクエストを送るところまで試しています。以下は自作サーバと curl を使った実験で、既存クライアントとの接続や本番環境での検証はしていません。

セッションと接続の扱いが変わった

changelogを読むと、セッション管理だけでなく、通信方法やサーバからの要求も変わっていました(2026-07-29 取得)。

Mcp-Session-Id ヘッダと、initialize / notifications/initialized によるハンドシェイクがなくなりました。各リクエストが _meta にプロトコルのバージョンとクライアントの対応機能を載せます。キーは io.modelcontextprotocol/protocolVersionio.modelcontextprotocol/clientCapabilities です。

HTTP では GET のストリーム用エンドポイントが削除され、POST を受ける単一のエンドポイントになります。SSE はリクエストの処理中に使うものとして残りますが、Last-Event-ID による再開はなくなり、切れたら新しい request ID で送り直します。

サーバからクライアントに情報を求める roots/listsampling/createMessage は、Multi Round-Trip Requests (MRTR) に置き換わりました。サーバが resultType: "input_required" を返し、クライアントが必要な応答を添えて元の要求を送り直す方式です。

新しく加わった server/discover は、サーバでの実装が必須です。

旧仕様 2025-11-25でも、セッション ID の発行と GET の SSE ストリームの利用は任意でした(2026-09-12 確認)。図の上段は、その両方を使う構成例です。

従来の構成例(2025-11-25) initialize で接続を開始 Mcp-Session-Id でセッションを識別 GET で SSE ストリームを開く 2026-07-28 POST の各リクエストに必要情報を載せる SSE はそのリクエストの処理中に使う 追加情報は MRTR でやりとりする

ほかにも互換性に関わる変更がある

changelog の major change は 9 件あります。ステートレス化に加え、次の変更も確認が必要です。

  • pinglogging/setLevelnotifications/roots/list_changed の削除
  • resources/subscribe / unsubscribe から subscriptions/listen への置き換え
  • すべての結果で resultType が必須になる ("complete" / "input_required")
  • Tasks がコアから公式拡張 io.modelcontextprotocol/tasks へ移る

minor change にも、実装時に対応する項目があります。Mcp-Method ヘッダは全リクエストで必須、Mcp-Nametools/call / resources/read / prompts/get で必須になりました。ヘッダの検査は、後半で試しています。

Roots・Sampling・Logging は非推奨になりました。OAuth 2.0 のクライアント登録は Client ID Metadata Documents (CIMD) が優先され、Dynamic Client Registration (DCR) は CIMD 未対応の認可サーバとの互換性のために残ります。機能を非推奨にしてからの期間は最低 12 か月と決まりました。

認証・認可は以前からある仕組みですが、今回も変更があります。認可サーバが返す iss パラメータの検証 (RFC 9207)、DCR での application_type 指定、クレデンシャルを発行元の認可サーバに紐付けて別の認可サーバで使い回さないことが加わっています(同 changelog、2026-07-30 再取得)。

サーバ構成を簡単にできそう

個人的に期待しているのは、セッションを維持するための設定を減らせることです。複数インスタンスで動かすときに、接続先を固定するスティッキーセッションや、セッション情報を退避する Redis が不要になるかもしれません。

ただし、アプリケーション独自の状態は別です。MCP 公式ブログでも、状態が必要ならサーバが識別用のハンドルを発行し、通常の tool 引数として渡す方法を示しています(2026-07-29 取得)。既存の構成を見直すなら、その状態が MCP の接続管理に必要なのか、アプリケーションの処理に必要なのかを分ける必要がありそうです。

セッションを気にせず台数を増やせるなら、過負荷への対処もしやすくなりそうです。1 リクエストの処理自体が軽くなるわけではないので、性能が上がったかは別に測る必要があります。

Functions や Container Apps のような実行環境にも載せやすそうですが、コールドスタートや実行時間の上限を含め、そこはまだ試していません。

SDK が未対応だったので、手で書いた

2026-07-29 時点の TypeScript SDK の最新は 1.30.0 で、公開日は 2026-07-27 でした。npm のレジストリから取得して、対応する仕様を見ます。

$ 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 に対応し、処理したインスタンス名を返す whoami ツールだけを持たせています。全体で 96 行でした。

処理の中心は次の部分です。リクエスト間でセッション情報を保存していません。

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 を検証して handle に渡す部分です。それ以外のメソッドとパスには 405 を返します。

前に置いたロードバランサは、2 つの接続先を交互に選ぶだけです。セッションやヘッダによる振り分けはしません。

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')

最初に server/discover を呼ぶ

initialize を送らず、最初から server/discover を呼びます。応答の JSON は読みやすいように整形しています。

$ 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 インスタンスに交互に送る

構成は次のとおりです。

curl から 6 回送信 セッションヘッダなし ロードバランサ :3000 順番に接続先を選ぶ A :3001 1・3・5 回目 B :3002 2・4・6 回目 別プロセスが交互に処理し、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" }
    }
  }
}

次に、応答からインスタンス名を取り出しながら 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

A と B の別プロセスが交互に処理し、すべて応答が返りました。事前にどちらかとセッションを作る処理もありません。

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

Streamable HTTP の仕様では、中継するロードバランサやゲートウェイが本文を解析せずに経路を選んだり検査したりできるよう、ヘッダを必須にしたと説明されています(2026-07-29 取得)。

別に、ヘッダと本文が食い違う場合は、サーバが HTTP 400 と JSON-RPC エラーで拒否することも必須です。中継側がヘッダを見て許可した処理と、サーバが本文を見て実行する処理が違うと問題になるためです。

自作サーバにも検査を入れ、Mcp-Name と本文のツール名をずらしました。

$ 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 は 405 を返す

GET 用のエンドポイントは作っていないので、こちらも試しました。

$ curl -s -o /dev/null -w 'GET /mcp -> HTTP %{http_code}\n' http://127.0.0.1:3000/mcp
GET /mcp -> HTTP 405

ここまで試して分かったこと

ハンドシェイクとセッション管理がない分、試すためのサーバは小さく書けました。リクエストを 2 インスタンスへ交互に送る構成も動いています。

ただ、仕様を読んで自分で書いたサーバなので、仕様への適合性を証明した実験ではありません。公式の適合性テストは実行しておらず、使ったクライアントも curl だけです。localhost の 2 プロセスで試したため、本番のロードバランサ、ネットワーク分断、コールドスタートは確認していません。

接続先を固定しなくてもよい構成を手で動かしてみて、変更内容はつかめました。実際のサービスからスティッキーセッションや Redis を外せるかは、対応クライアントとアプリケーションを合わせて確かめる必要があります。SDK が対応したら、その組み合わせでも試したいです。