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新仕様がGo SDKで通らない、Stateless=trueに加え必須が3つあった

0
Posted at

TL;DR

  • MCP の新仕様 2026-07-28 は初期化ハンドシェイクを廃止したステートレスモデルです。Go SDK の公式ドキュメントには「StreamableHTTPOptions.Stateless = true のときだけ受け付ける」と書かれています。
  • ところが Stateless = true にしただけでは新仕様のリクエストは通りませんでした。実際には Mcp-Protocol-Version ヘッダ・Mcp-Method ヘッダ・_meta のクライアント能力宣言 という3つの追加要件があり、1つ欠けるごとに別のエラーで弾かれます。
  • 失敗の返り方が2種類に割れます。サーバー設定ミス(Stateless = false)は JSON-RPC ですらないプレーンテキストの HTTP 400、リクエスト不備は JSON-RPC エラー(-32020 / -32602 でした。クライアント側で JSON パースを前提にしていると、前者だけ握り潰されます。
  • 一方で後方互換は壊れていませんでした。Stateless = true のサーバーに旧仕様(2025-11-25)の initialize を投げても HTTP 200 で応答します。移行は「ステートレスに倒す」一方向で進められます。

はじめに

MCP サーバーをリモートで動かしていると、セッション ID をどのインスタンスが持っているかという問題がつきまといます。スティッキーセッションが必要な限り、ロードバランサの後ろに素直に並べられません。2026-07-28 の仕様改訂はこの前提を外し、リクエスト単位で完結するステートレスモデルへ移行しました。

対象読者は、Go で MCP サーバーを実装していて新仕様への移行を検討している開発者です。

公式ドキュメントを読む限り、移行の入り口はフラグ1つに見えます。StreamableHTTPOptions.Statelesstrue にする、それだけです。実際に最小サーバーを立てて新仕様のリクエストを投げてみたところ、そのフラグを立てた状態でも 400 が返り続けました。何が足りなかったのかを、条件を変えながら10通り試して切り分けます。

検証環境

項目
Go SDK github.com/modelcontextprotocol/go-sdk v1.7.0
Go 1.24.7(go.mod の要求により 1.25.13 へ自動切替)
OS Linux x86_64
検証日 2026-08-16

最初の躓きはビルドより手前にありました。go get した時点で toolchain が入れ替わります。

$ go get github.com/modelcontextprotocol/go-sdk@latest
go: downloading github.com/modelcontextprotocol/go-sdk v1.7.0
go: github.com/modelcontextprotocol/go-sdk@v1.7.0 requires go >= 1.25.0; switching to go1.25.13
go: downloading go1.25.13 (linux/amd64)
go: upgraded go 1.24.7 => 1.25.0
go: added github.com/modelcontextprotocol/go-sdk v1.7.0

Go 1.24 系で固定している CI では、この自動ダウンロードが走らずに requires go >= 1.25.0 で失敗します。SDK を上げる前に toolchain の許容範囲を確認しておくと事故が減ります。

何が変わったのか

公式の仕様アナウンス(The 2026-07-28 Specification)によると、この改訂は双方向ステートフルなプロトコルからリクエスト・レスポンス型のステートレスコアへの移行を含みます。TypeScript / Python SDK の累計ダウンロードが10億を超えた規模で、プロトコルの土台を差し替えた形です。

Go SDK 側の説明(go-sdk/docs/protocol.md)は、この変更を次のように書いています。

A stateless model introduced in 2026-07-28 by SEP-2575, in which there is no initialize/notifications/initialized handshake.

ハンドシェイクが無くなった代わりに、各リクエストが自分でプロトコルバージョンとクライアント能力を運びます。同ドキュメントは HTTP トランスポートの制約も明記しています。

Required for 2026-07-28: the streamable HTTP transport accepts requests at protocol version 2026-07-28 only when Stateless = true.

読み取れるのはここまでです。「Stateless = true にすれば受け付ける」と解釈するのが自然ですが、実際の受理条件はもう少し細かく積み上がっていました。

実験: 最小サーバーを2条件で立てる

Stateless の値だけが違うサーバーを2つ、別ポートで起動します。ツールは echo が1つあれば十分です。

package main

import (
	"context"
	"log"
	"net/http"
	"os"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type EchoArgs struct {
	Text string `json:"text" jsonschema:"the text to echo"`
}

func echo(ctx context.Context, req *mcp.CallToolRequest, args EchoArgs) (*mcp.CallToolResult, any, error) {
	return &mcp.CallToolResult{
		Content: []mcp.Content{&mcp.TextContent{Text: "echo: " + args.Text}},
	}, nil, nil
}

func newServer() *mcp.Server {
	s := mcp.NewServer(&mcp.Implementation{Name: "probe", Version: "0.0.1"}, nil)
	mcp.AddTool(s, &mcp.Tool{Name: "echo", Description: "echo back text"}, echo)
	return s
}

func main() {
	addr := os.Args[1]
	stateless := os.Args[2] == "true"

	handler := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return newServer() },
		&mcp.StreamableHTTPOptions{Stateless: stateless},
	)
	log.Printf("listening on %s stateless=%v", addr, stateless)
	log.Fatal(http.ListenAndServe(addr, handler))
}
$ ./probe 127.0.0.1:8931 false &   # 従来どおりのステートフル
$ ./probe 127.0.0.1:8932 true &    # ステートレス

投げるのは tools/list です。新仕様では params._meta にプロトコルバージョンを載せます。

REQ='{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'

結果: 10条件の実測

同じ tools/list を、サーバー設定・ヘッダ・_meta の組み合わせを変えて投げた結果です。

# サーバー 送ったもの HTTP 返ってきた内容
A Stateless=false 新仕様 + Mcp-Protocol-Version 400 プレーンテキスト(JSON-RPC ではない)
B Stateless=true 新仕様 + Mcp-Protocol-Version 400 -32020 missing required Mcp-Method header
C Stateless=true 新仕様(ヘッダなし) 400 -32020 Mcp-Protocol-Version header is required
D Stateless=false 旧仕様 initialize(2025-11-25) 200 正常応答
E Stateless=true 新仕様 + 両ヘッダ(能力宣言なし) 400 -32602 missing or invalid _meta clientCapabilities
F Stateless=true 新仕様 + Mcp-Method が本文と不一致 400 -32020 header mismatch
G Stateless=true 新仕様 + 両ヘッダ + 能力宣言 200 ツール一覧(cacheScope 付き)
H Stateless=false G と完全に同じリクエスト 400 プレーンテキスト(A と同一)
I Stateless=true GET リクエスト 405 Method Not Allowed
J Stateless=true 旧仕様 initialize(2025-11-25) 200 正常応答
K Stateless=true 旧仕様 tools/list_meta なし) 200 ツール一覧

条件 A のレスポンスは、そのまま設定ミスの診断メッセージになっています。

$ curl -sS -X POST http://127.0.0.1:8931 \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Mcp-Protocol-Version: 2026-07-28' -d "$REQ"
Bad Request: protocol version "2026-07-28" is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)

そして Stateless = true に切り替えても、まだ通りません。

$ curl -sS -X POST http://127.0.0.1:8932 \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Mcp-Protocol-Version: 2026-07-28' -d "$REQ"
{"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"missing required Mcp-Method header"}}

Mcp-Method を足すと、今度は _meta の中身を要求されます。

$ curl -sS -X POST http://127.0.0.1:8932 \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Mcp-Protocol-Version: 2026-07-28' \
    -H 'Mcp-Method: tools/list' -d "$REQ"
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"missing or invalid _meta field \"io.modelcontextprotocol/clientCapabilities\""}}

4つ目の要素を足して、ようやく 200 になりました。

FULL='{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
  "io.modelcontextprotocol/protocolVersion":"2026-07-28",
  "io.modelcontextprotocol/clientCapabilities":{}}}}'
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"probe","version":"0.0.1"}},"ttlMs":0,"cacheScope":"public","tools":[{"description":"echo back text","inputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the text to echo"}},"required":["text"],"additionalProperties":false},"name":"echo"}]}}

受理までの3段ゲート

整理すると、新仕様のリクエストは以下の順で検査されます。

Mcp-Method ヘッダは本文の method と一致していなければなりません。条件 F では、本文が tools/list なのにヘッダを tools/call にしただけで拒否されました。

{"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"header mismatch: Mcp-Method header value 'tools/call' does not match body value 'tools/list'"}}

このヘッダはロードバランサやプロキシが JSON 本文をパースせずにルーティングできるようにするためのものです。中身と食い違えばルーティング判断が壊れるので、一致検査は理にかなっています。逆に言うと、クライアント実装側で本文だけ組み立てて送る素朴なコードは、新仕様では動きません。

著者視点の発見ポイント

実際に10通り叩いてみて、ドキュメントを読むだけでは気づけなかった点が3つありました。

1つ目は、エラーの返り方が2系統に割れることです。 サーバー設定ミス(条件 A・H)だけが http.Error によるプレーンテキストで返り、それ以外はすべて JSON-RPC エラーオブジェクトでした。クライアント側でレスポンスを常に JSON としてパースする実装だと、最も頻度が高いであろう設定ミスのケースだけ「パース失敗」という別の顔になって現れます。筆者は最初これに引っかかり、原因の切り分けに余計な往復をしました。移行時のエラーハンドリングは、HTTP ステータスと Content-Type を先に見る作りにしておくのが安全です。

2つ目は、Stateless = true が旧仕様を締め出さないことです。 条件 J・K のとおり、ステートレスサーバーは旧仕様の initialize にも _meta なしの tools/list にも 200 を返しました。つまりフラグを立てても既存クライアントは壊れません。「新旧を同時に受けるために2つサーバーを並べる」といった構えは不要で、ステートレスに倒しておけば新旧どちらも受けられます。移行の順序は、サーバーを先にステートレス化し、クライアントを後から新仕様へ寄せる、で成立します。

3つ目は、成功レスポンスに cacheScopettlMs が付いてくることです。 条件 G・K の応答には "ttlMs":0,"cacheScope":"public" が入っていました。tools/list の結果をクライアントがキャッシュできるようにするための情報です。今回の最小サーバーは TTL を設定していないため 0 でしたが、ツール定義が安定しているサーバーではここを効かせる余地があります。ステートレス化の狙いがスケールアウトである以上、毎リクエストで一覧を引き直さずに済む経路が用意されているのは筋が通っています。

なお Stateless = true のサーバーは GET を 405 で返します(条件 I)。SSE ストリームを開きに行く既存の監視スクリプトがある場合は、ここも移行対象です。

移行チェックリスト

Go SDK v1.7.0 で 2026-07-28 に対応するとき、実測から確認できた必要条件は次のとおりです。

対象 やること 確認方法
toolchain Go 1.25.0 以上を許容する go getswitching to go1.25.x が出るか
サーバー StreamableHTTPOptions.Stateless = true 条件 A のプレーンテキスト 400 が消えるか
クライアント Mcp-Protocol-Version ヘッダを送る -32020 header required が消えるか
クライアント Mcp-Method ヘッダを本文の method と一致させる -32020 header mismatch が出ないか
クライアント _metaclientCapabilities を入れる -32602 invalid params が消えるか
監視系 GET でのストリーム接続をやめる 405 が返っていないか

サーバー側の作業は1行ですが、クライアント側は3点あります。既存のクライアントを手書きしている場合、この3点を足すまでは 400 が返り続けます。SDK 同梱のクライアント(Client.Connect)を使っていれば、server/discover でバージョンを交渉し、必要なヘッダと _meta を自動で組み立てるため、この作業は不要です。手組みの HTTP クライアントだけが影響を受けます。

まとめ

Stateless = true は必要条件であって十分条件ではありませんでした。新仕様のリクエストが 200 になるまでには、サーバー設定1つとリクエスト側の3要素、合わせて4つが揃う必要があります。そして揃っていないときの返り方が2系統に分かれるため、切り分けは「まず Content-Type を見る」から始めるのが早道です。

救いは後方互換が保たれている点です。ステートレス化したサーバーは旧仕様のクライアントをそのまま受け続けるので、サーバーを先に倒してからクライアントを順次移す、という段取りが取れます。移行を止めている理由が「既存クライアントが壊れそう」であれば、その心配だけは実測上ありませんでした。

関連記事

参考リンク

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?