GoでMCPサーバーを自作する—go-sdk v1.7でClaude Codeに繋ぐまで

read:  {"jsonrpc":"2.0","id":"server-discover-probe-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"claude-code","version":"2.1.234"}, ...}}}
write: {"jsonrpc":"2.0","id":"server-discover-probe-1","result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"], ...}}
read:  {"jsonrpc":"2.0","id":"listen:0","method":"subscriptions/listen","params":{...}}
read:  {"jsonrpc":"2.0","id":0,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28", ...}}}

Go で書いた MCP サーバーを Claude Code v2.1.234 に繋いで、サーバー側でワイヤ上の JSON-RPC を記録したものです。MCP の入門記事で必ず出てくる initialize がどこにもありません。代わりに server/discover で対応バージョンを探り、そのまま tools/list を投げてきています。

これは 2026-07-28 に公開された MCP 仕様の大改定(通称ステートレス化)の結果で、公式 Go SDK github.com/modelcontextprotocol/go-sdk は、仕様公開に合わせてタグが打たれた v1.7.0(2026-07-27)で追従しました。ステートレス化そのものは概念として知っていたのですが、Go で実際にサーバーを立ててみると「同じバイナリなのに、クライアントによって旧仕様の initialize で来たり新仕様の server/discover で来たりする」「Streamable HTTP では Stateless: true にしないと新仕様のリクエストが 400 で弾かれる」など、ドキュメントを読んだだけでは分からない挙動がいくつもありました。

環境は Go 1.26.4(darwin/arm64)、go-sdk v1.7.0、クライアントは Claude Code v2.1.234 です。サーバー実装 → stdio で Claude Code に接続 → Streamable HTTP で公開 → クライアントが何を送ってくるかの観察、という順で書きます。

目次

公式 Go SDK を選ぶ理由—mcp-go との違い

サジェストに「mcp go vs go sdk」が出るくらいには迷うポイントらしいので、先に整理しておきます。Go の MCP 実装は長らく github.com/mark3labs/mcp-go がデファクトでしたが、2025 年に Anthropic と Google が共同で公式 SDK を出し、v1.0.0 以降は API の後方互換が約束されています。

対応する MCP 仕様バージョン

modelcontextprotocol/go-sdkmark3labs/mcp-go
最新版(2026-08 時点)v1.7.0(2026-07-27)v0.58.0(2026-08-11)
対応する最新 MCP 仕様2026-07-28(v1.7.0 以降)2025-11-25
Go の最低バージョン1.25.01.25.5
ツール定義ジェネリクスで struct から JSON Schema を推論ビルダー関数で 1 引数ずつ宣言
Streamable HTTP のステートレスモードStreamableHTTPOptions.Statelessあり(WithStateLess
API 安定性v1 系は後方互換を維持v0 系(v1.0.0-beta.1 あり)

README の「Version Compatibility」表によると、go-sdk は v1.7.0 で 2026-07-28 に対応し、2024-11-05 まで 5 世代の仕様を同時に喋れます。今から新規に書くなら公式 SDK 一択で、この記事もそちらだけを扱います。

導入は go get と 3 つの API

使うのは mcp.NewServermcp.AddToolserver.Run の 3 つだけです。

go mod init example.com/mcpgo
go get github.com/modelcontextprotocol/go-sdk@v1.7.0
go: downloading github.com/modelcontextprotocol/go-sdk v1.7.0
go: added github.com/modelcontextprotocol/go-sdk v1.7.0

間接依存として入るのは google/jsonschema-go(スキーマ推論)、segmentio/encodingyosida95/uritemplate の 3 つで、ビルドしたバイナリは 12.3MB(-ldflags="-s -w" で 8.4MB)でした。

ツールを 1 本生やす—server.AddTool と mcp.AddTool の違い

題材は「Go モジュールの最新バージョンを proxy.golang.org から引く」ツールにしました。go get する前に「今の最新は何か」を Claude Code に聞ける、という Go 屋には地味に便利なやつです。

低レベル API はスキーマを自分で書く(書かないと panic する)

まずハマりやすい書き方から。Server にはメソッドの server.AddTool と、パッケージ関数の mcp.AddTool の 2 つがあり、名前が同じなので混同しがちです。メソッド版にスキーマなしでツールを渡すとこうなります。

// NG: 低レベル API にスキーマ無しで登録
server.AddTool(&mcp.Tool{Name: "gomod_latest", Description: "..."},
    func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        var args struct{ Module string `json:"module"` }
        if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
            return nil, err
        }
        // ...
        return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "v?"}}}, nil
    })
panic: AddTool "gomod_latest": missing input schema

goroutine 1 [running]:
github.com/modelcontextprotocol/go-sdk/mcp.(*Server).AddTool(...)
	.../go-sdk@v1.7.0/mcp/server.go:282 +0x680

ソースのコメントには「空スキーマで誤魔化すと、LLM が変な引数を送ってきた実行時まで問題に気づけないので、あえて panic させる」と書いてあります。server.AddToolToolHandlerreq.Params.Argumentsjson.RawMessage で渡ってくるだけで、公式ドキュメント docs/server.md の「Tools」節が列挙しているとおり、入出力スキーマの用意・引数の検証・アンマーシャル・StructuredContentContent の両方への書き込み・エラー時の IsError 設定を全部自分でやることになります。カスタムトランスポートや動的なツール生成でもない限り、こちらを使う理由はありません。

ジェネリック版は struct タグからスキーマを推論する

本命は mcp.AddTool[In, Out] です。入力と出力を Go の struct で宣言し、jsonschema タグで説明を付けるだけで、JSON Schema の生成から検証までやってくれます。

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"

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

type LatestInput struct {
	Module string `json:"module" jsonschema:"Go module path, e.g. github.com/quic-go/quic-go"`
}

type LatestOutput struct {
	Module  string    `json:"module"`
	Version string    `json:"version" jsonschema:"latest tagged version"`
	Time    time.Time `json:"time" jsonschema:"publish time of the version"`
}

func latestVersion(ctx context.Context, req *mcp.CallToolRequest, in LatestInput) (*mcp.CallToolResult, LatestOutput, error) {
	url := "https://proxy.golang.org/" + in.Module + "/@latest"
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, LatestOutput{}, err
	}
	resp, err := http.DefaultClient.Do(httpReq)
	if err != nil {
		return nil, LatestOutput{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, LatestOutput{}, fmt.Errorf("proxy.golang.org returned %d for %s", resp.StatusCode, in.Module)
	}
	var out LatestOutput
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, LatestOutput{}, err
	}
	out.Module = in.Module
	return nil, out, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "gomod-latest", Version: "v0.1.0"}, nil)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "gomod_latest",
		Description: "Look up the latest version of a Go module on proxy.golang.org",
	}, latestVersion)

	// stdio: クライアントが子プロセスとして起動し、stdin/stdout で JSON-RPC をやり取りする
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}

戻り値の 1 つ目 *mcp.CallToolResult は普段 nil で構いません。SDK が Out を JSON にして structuredContentcontent[0].text の両方に詰めてくれます。実際に tools/list が返すスキーマがこれです(requiredadditionalProperties: false が自動で付いています)。

{
  "name": "gomod_latest",
  "description": "Look up the latest version of a Go module on proxy.golang.org",
  "inputSchema": {
    "type": "object",
    "properties": {
      "module": {"type": "string", "description": "Go module path, e.g. github.com/quic-go/quic-go"}
    },
    "required": ["module"],
    "additionalProperties": false
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "module":  {"type": "string"},
      "version": {"type": "string", "description": "latest tagged version"},
      "time":    {"type": "string", "description": "publish time of the version"}
    },
    "required": ["module", "version", "time"],
    "additionalProperties": false
  }
}

jsonschema-go の推論ルールでは omitemptyomitzero の付いていないフィールドは全部 required になる(ポインタ型にしても外れない)ので、省略可能にしたければ json:"source,omitempty" のようにタグで明示します。struct タグの読まれ方そのものは Go structタグを読み解く—omitempty・json・reflectの仕組み に書いたとおりで、jsonschema タグも同じ reflect.StructTag の仕組みに乗っています。

error を返すと isError: true、引数の検証も SDK 側で落ちる

ハンドラから普通の error を返すと、JSON-RPC のエラーではなく「ツール実行は成功したが結果がエラー」という isError: true の result になります。これは MCP の仕様どおりで、LLM がエラー文を読んでリトライや別ツール選択を判断できる形です。存在しないモジュールを渡した実物と、引数のキーを typo した実物を並べます。

// module に example.com/does/not/exist を渡した
{"content":[{"type":"text","text":"proxy.golang.org returned 404 for example.com/does/not/exist"}],"isError":true,"resultType":"complete"}

// arguments のキーを "modul" と typo した(ハンドラには到達しない)
{"content":[{"type":"text","text":"validating \"arguments\": validating root: unexpected additional properties [\"modul\"]"}],"isError":true,"resultType":"complete"}

2 つ目はハンドラが呼ばれる前に SDK の検証で弾かれています。additionalProperties: false が効いているので、LLM が引数名を間違えても黙って無視されず、何が悪いかがエラー文として返ります。

stdio で Claude Code に繋ぐ—claude mcp add からツール呼び出しまで

ローカルの MCP サーバー、いわゆる「ローカル mcp サーバー 作り方」で検索して辿り着く形がこれです。ビルドしたバイナリを Claude Code に登録すると、Claude Code が子プロセスとして起動し、stdin/stdout で JSON-RPC をやり取りします。

登録と疎通確認

go build -o gomod-latest ./cmd/server
claude mcp add --scope local gomod-latest -- /path/to/gomod-latest
claude mcp list
Added stdio MCP server gomod-latest with command: /path/to/gomod-latest to local config
File modified: /Users/moha/.claude.json [project: /private/tmp/mcpgo/proj]

gomod-latest: /path/to/gomod-latest - ✔ Connected

--scope local だと ~/.claude.jsonprojects.<dir>.mcpServers 配下に書かれ、そのディレクトリで起動したときだけ有効になります。チームで共有したいなら --scope project でリポジトリ直下の .mcp.json に書く方が向いていて、この使い分けは Claude Code MCPのスコープ管理—.mcp.jsonでチーム共有する設計 にまとめてあります。

-p で呼ぶと mcp__サーバー名__ツール名 になる

ツールは mcp__gomod-latest__gomod_latest という名前で見えます。claude -p から明示的に呼んでみました。

claude -p "mcp__gomod-latest__gomod_latest ツールで github.com/quic-go/quic-go の最新バージョンを調べ、バージョンと公開日時だけを1行で答えて" \
  --allowedTools "mcp__gomod-latest__gomod_latest" --output-format json
tool_use:    ToolSearch {"query": "select:mcp__gomod-latest__gomod_latest"}
tool_use:    mcp__gomod-latest__gomod_latest {"module": "github.com/quic-go/quic-go"}
tool_result: {"module":"github.com/quic-go/quic-go","time":"2026-07-21T13:50:28Z","version":"v0.61.0"}
result:      v0.61.0(2026-07-21T13:50:28Z 公開)
cost_usd: 0.445  duration_ms: 9100  num_turns: 3

会話ログを見ると、ツール本体を呼ぶ前に ToolSearch でスキーマを引いています。tool search が有効な環境では MCP ツールの定義がコンテキストに常駐せず、必要になった時点で取り込まれる挙動で、これが Claude Code MCP tool search—実測7万トークン削減の設定判断 で扱った tool search そのものです。自作サーバーでもこの仕組みに乗るので、ツールを 30 本生やしても起動時のトークンは膨らみません。

標準出力にログを書いてはいけない

stdio トランスポートでは stdout が JSON-RPC 専用です。デバッグのつもりで fmt.Println すると、Claude Code 側は改行区切りの JSON として読もうとして接続が壊れます。ログは log パッケージや slog のデフォルト出力先である stderr へ流します。

ワイヤの中身そのものを見たいときは、docs/troubleshooting.md の「Collecting MCP logs」節にある mcp.LoggingTransport でトランスポートを包みます。冒頭のログもこれで取りました。

f, _ := os.OpenFile("/tmp/mcp-wire.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
var t mcp.Transport = &mcp.StdioTransport{}
t = &mcp.LoggingTransport{Transport: t, Writer: f}
server.Run(ctx, t)

Streamable HTTP で公開する—Stateless=true が新仕様の必須条件

チーム内のリモート MCP サーバーとして立てるなら Streamable HTTP です。docs/protocol.md の「Streamable Transport」節にあるとおり、mcp.NewStreamableHTTPHandlerhttp.Handler を返すので、あとは普通の net/http に載せるだけです。

NewStreamableHTTPHandler と 2 つのモード

handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
	return server // リクエストごとに Server を選べる。ここでは 1 つを使い回す
}, &mcp.StreamableHTTPOptions{Stateless: *stateless})

mux := http.NewServeMux()
mux.Handle("/mcp", handler)
log.Fatal(http.ListenAndServe(":8080", mux))
Stateless: false(既定)Stateless: true
Mcp-Session-Id発行・検証する読まない・返さない
GET(SSE ストリーム)/ DELETE受け付ける405 Method Not Allowed
サーバー→クライアントのリクエスト可能不可(応答の戻り先がない)
2026-07-28 のリクエスト400 で拒否受理
2025-11-25 以前の initialize受理受理(後方互換)
水平スケールセッションを同一プロセスに固定する必要ありどのインスタンスでも処理できる

2026-07-28 のリクエストは stateless でしか通らない

ここが一番の「触らないと分からない」ポイントでした。既定のステートフルなハンドラに、新仕様の形(_meta に protocolVersion を載せた tools/list)を curl で投げると 400 が返ります。

curl -i -X POST http://127.0.0.1:8080/mcp \
  -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 '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8

Bad Request: protocol version "2026-07-28" is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)

エラー文が親切なので迷いはしないのですが、注意したいのは これが JSON-RPC ではなく text/plain で返ることです。レスポンスを無条件に JSON パースするクライアントだと、この 400 は握り潰されて「なぜか繋がらない」に化けます。

加えて、同じステートフルなサーバーに server/discover を投げると supportedVersions から 2026-07-28 が消えていました。ドキュメントには「transport-filtered list」とだけ書かれている部分で、Server 自体は新仕様を知っていても、トランスポートが載せられないバージョンは広告しない、という作りです。後述する Claude Code のフォールバックはこの広告を見て決まります。

通すには Mcp-* ヘッダ 3 つと _meta が要る

Stateless: true にして同じリクエストを投げれば通るかというと、tools/call はまだ 400 でした。SEP-2243 で標準化された HTTP ヘッダを SDK が検証しているためで、欠けているものごとにエラーが違います。全部踏んでみた結果を表にします。

欠けていたものHTTPJSON-RPC エラー
Mcp-Protocol-Version ヘッダ400-32020 “Mcp-Protocol-Version header is required for requests carrying io.modelcontextprotocol/protocolVersion”
Mcp-Method ヘッダ400-32020 “missing required Mcp-Method header”
Mcp-Method と body の method 不一致400-32020 “header mismatch: Mcp-Method header value ‘tools/list’ does not match body value ‘tools/call'”
Mcp-Name ヘッダ(tools/call のとき)400-32020 “missing required Mcp-Name header for method tools/call”
_meta.io.modelcontextprotocol/clientCapabilities400-32602 “missing or invalid _meta field io.modelcontextprotocol/clientCapabilities”

-32020 は 2026-07-28 で新設された HeaderMismatchError です。仕様の changelog によると、以前のドラフトで -32001 だったものが「-32020-32099 を MCP 仕様予約」というエラーコード割り当てポリシーに合わせて振り直されました。ここまで揃えると、やっと tools/call が返ります。

curl -X POST http://127.0.0.1:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' -H 'Mcp-Name: gomod_latest' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"gomod_latest","arguments":{"module":"golang.org/x/net"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"gomod-latest","version":"v0.1.0"}},"content":[{"type":"text","text":"{\"module\":\"golang.org/x/net\",\"time\":\"2026-08-12T17:41:32Z\",\"version\":\"v0.58.0\"}"}],"structuredContent":{"module":"golang.org/x/net","time":"2026-08-12T17:41:32Z","version":"v0.58.0"},"resultType":"complete"}

curl で手打ちすると面倒に見えますが、SDK のクライアント(mcp.StreamableClientTransport)や Claude Code はこのヘッダを自動で付けるので、普段意識するのはプロキシや WAF がヘッダを落としていないかを疑うときくらいです。なお、ステートレスにしても initialize から始まる旧仕様のリクエストは今までどおり受理されるので、クライアントを待たずにサーバー側から先に切り替えられます。

クライアントは何を送ってくるか—Claude Code のフォールバックを覗く

ステートフルとステートレスの 2 つのサーバーを立て、Claude Code に claude mcp add --transport http で両方登録して、サーバー側で受け取った HTTP リクエストを記録しました。

server/discover で探り、対応していれば新仕様、していなければ initialize

--- Stateless: true のサーバーが受け取った順 ---
POST /mcp Mcp-Protocol-Version="2026-07-28" Mcp-Method="server/discover"     Mcp-Session-Id=""
POST /mcp Mcp-Protocol-Version="2026-07-28" Mcp-Method="subscriptions/listen" Mcp-Session-Id=""
POST /mcp Mcp-Protocol-Version="2026-07-28" Mcp-Method="tools/list"          Mcp-Session-Id=""

--- Stateless: false のサーバーが受け取った順 ---
POST /mcp Mcp-Protocol-Version="2026-07-28" Mcp-Method="server/discover"     Mcp-Session-Id=""
POST /mcp Mcp-Protocol-Version=""           Mcp-Method=""  body={"method":"initialize","params":{"protocolVersion":"2025-11-25",...
POST /mcp Mcp-Protocol-Version="2025-11-25" Mcp-Method=""  Mcp-Session-Id="4YMXFPZ6FKASVJJEVH5NSNSY3T" body={"method":"notifications/initialized"}
GET  /mcp Mcp-Protocol-Version="2025-11-25" Mcp-Method=""  Mcp-Session-Id="4YMXFPZ6FKASVJJEVH5NSNSY3T"
POST /mcp Mcp-Protocol-Version="2025-11-25" Mcp-Method=""  Mcp-Session-Id="4YMXFPZ6FKASVJJEVH5NSNSY3T" body={"method":"tools/list","jsonrpc":"2.0","id":1}

Claude Code v2.1.234 はどちらにもまず server/discover(id は server-discover-probe-1)を投げ、返ってきた supportedVersions2026-07-28 があれば subscriptions/listen_meta 付きリクエストへ、なければ initializeMcp-Session-Id → GET で SSE ストリーム、という旧仕様の手順に切り替えています。go-sdk のクライアント側 Client.Connect も「discovery が失敗するか最新版に非対応なら legacy な initialize にフォールバック」と docs/protocol.md の「Discovery」節に明記されていて、同じ動きでした。

つまりサーバー実装者の立場では、両方の世代を同時にさばくコードは書かなくてよく、SDK が握手の違いを吸収するということです。旧仕様のクライアントしかいない環境でも Stateless: true にしておけば、クライアントが更新された日から勝手に新仕様で繋がります。逆にステートフルのままだと、新仕様しか喋らないクライアントが現れた時点で server/discover の応答から 2026-07-28 が落ちているために接続できません。

stdio / HTTP / stateless の往復時間

ついでに、引数をそのまま返す echo ツールを 1000 回呼んで 1 往復あたりの時間を測りました。Apple M5 Pro、ループバック、go-sdk のクライアントからの呼び出しで、3 回走らせた中央値です。

トランスポートConnect にかかった時間1 往復あたり
stdio(子プロセス起動込み)約 5ms約 80µs
Streamable HTTP(Stateless: false)約 0.9ms約 100µs
Streamable HTTP(Stateless: true)約 0.4ms約 150µs

ステートレスが 1.5 倍かかるのは、streamable.goserveStateless のコメントにあるとおり「リクエストごとに一時セッションを作って、完了時に閉じる」実装だからです。とはいえ差は 50µs で、ツール本体が外部 API を叩けば桁が 3 つ違います。トランスポート選びで速度を気にする必要はなく、ローカルで完結するなら stdio、複数人・複数インスタンスで共有するなら Streamable HTTP のステートレス、という機能面だけで決めてよさそうです。

まとめ

  • 公式 Go SDK modelcontextprotocol/go-sdk は v1.7.0 で MCP 仕様 2026-07-28 に対応。ツールは mcp.AddTool[In, Out] に struct を渡すだけで JSON Schema の推論・引数検証・isError の設定まで済む
  • メソッド版の server.AddTool はスキーマ必須で、渡さないと missing input schema で panic する。低レベル API が要る場面以外はジェネリック版を使う
  • 2026-07-28 では initialize ハンドシェイクが消え、server/discoversubscriptions/listen → 各リクエストに _meta 同梱、という流れになる。Claude Code v2.1.234 は discover の結果を見て新旧を自動で切り替える
  • Streamable HTTP で新仕様を受けるには StreamableHTTPOptions.Stateless: true が必須。ステートフルのままだと text/plain の 400 で拒否され、server/discover の広告からも 2026-07-28 が消える
  • 新仕様のリクエストは Mcp-Protocol-VersionMcp-MethodMcp-Name ヘッダと _meta.clientCapabilities が揃って初めて通る。欠けると -32020 / -32602 で理由が返る
  • stdio では stdout を JSON-RPC 専用にし、ログは stderr へ。ワイヤを見たいときは mcp.LoggingTransport
  • 往復時間は stdio 約 80µs、HTTP 約 100µs、ステートレス約 150µs。速度でなく共有形態でトランスポートを選ぶ
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次