MCP サーバーを Go で作るなら、公式の MCP Go SDK(github.com/ modelcontextprotocol/ go-sdk)を使うのが近道です。Go の関数を 1 つ書いて mcp.AddTool で登録するだけで、入力と出力の JSON スキーマが構造体から自動で作られ、引数の検証も SDK が行います1 。ビルドしたバイナリを claude mcp add で登録すれば、Claude Code からツールとして呼べます2 。
この記事では、TODO を追加・一覧・完了にする小さな MCP サーバーを作ります。ビルド、テスト、標準入力から JSON-RPC を送っての動作確認、Claude Code への接続までを、筆者が 2026 年 10 月 5 日に実際に動かした結果で説明します。
MCP(Model Context Protocol)サーバーは、AI エージェントに「ツール」「リソース」「プロンプト」を提供するプログラムです。Claude Code などの MCP クライアントがサーバーを起動し、JSON-RPC でツールの一覧を取得して、必要なときに呼び出します。
Claude Code が Go の MCP サーバーを子プロセスとして起動し、標準入出力の JSON-RPC でツールを呼ぶ流れ 図: 筆者作成
公式 Go SDK は次のパッケージでできています1 。
パッケージ 役割 mcpクライアントとサーバーを作る主要な API jsonrpc独自のトランスポートを実装する人向け auth / oauthexOAuth の補助(HTTP で公開するサーバー向け)
stdio(標準入出力)のサーバーは、手元の PC で Claude Code が子プロセスとして起動する形です。ネットワークに公開しないので、社内ツールや手元のデータを AI に触らせる入口として始めやすい方式です。
理由は 3 つあります。公式 SDK が安定版になって更新が続いていること、仕様の大きな改訂に SDK がすでに追従していること、Go 自体のツールが MCP に対応し始めていることです。
公式 SDK が安定している : Go チームは 2025 年 11 月の Go 公式ブログで、MCP Go SDK v1.0.0 を Anthropic と共同でリリースしたと書いています3 。2026 年 10 月時点の最新は v1.8.0(2026-09-14 公開)です1
最新の仕様に対応している : MCP 仕様の 2026-07-28 版では initialize の手順がなくなり、リクエストごとにバージョンを送る「ステートレス」な形に変わりました4 。Go SDK は v1.7.0 以降でこの版に対応し、古い版(2025-11-25 など)も引き続き扱えます1
Go の開発ツールも MCP を話す : Go の言語サーバー gopls には実験的な MCP サーバーがあり、claude mcp add gopls -- gopls mcp で Claude Code につなげます5
Google トレンド(日本・過去 90 日、2026-10-05 取得)の相対値でも、「MCP サーバー」は直近 4 週の平均が 5.0、その前の 8 週の平均が 4.8 で、関心が落ちずに続いています6 。
公式 SDK の GitHub スター数を言語別に比べると、Go 版は Python 版・TypeScript 版より後発のぶん少なめです。Go の MCP ライブラリには非公式の mark3labs/ mcp-go もありますが、仕様への追従と長期の保守を考えると、新しく作るなら公式 SDK を選ぶのが無難です。
MCP の SDK・ライブラリの GitHub スター数(2026-10-05 時点) データを表で見る MCP の SDK・ライブラリの GitHub スター数(2026-10-05 時点) リポジトリ スター数(個) python-sdk(公式) 24,486 typescript-sdk(公式) 13,519 mark3labs/mcp-go(非公式) 9,153 go-sdk(公式) 5,185
出典: 筆者集計(元データ: GitHub REST API の stargazers_count、2026-10-05 取得)
ここからは、TODO を扱う 3 つのツール(add_todo・list_todos・complete_todo)を持つサーバーを作ります。前提は Go 1.26.2、macOS、MCP Go SDK v1.8.0 です。SDK は Go のサポート中のバージョンだけを対象にしています1 。
mkdir todo-mcp && cd todo-mcp
go mod init example.com/todo-mcp
go get github.com/modelcontextprotocol/go-sdk@v1.8.0
ツールの引数と戻り値を構造体で定義します。json タグがプロパティ名に、jsonschema タグが説明文になります。ハンドラは func(ctx, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) の形にします7 。
package main
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type Todo struct {
ID int `json:"id" jsonschema:"TODO の番号"`
Title string `json:"title" jsonschema:"TODO の内容"`
Done bool `json:"done" jsonschema:"完了したか"`
}
type store struct {
mu sync.Mutex
todos []Todo
}
func newStore () *store { return &store{} }
type AddInput struct {
Title string `json:"title" jsonschema:"追加する TODO の内容"`
}
type ListInput struct {
IncludeDone bool `json:"include_done,omitempty" jsonschema:"true なら完了済みも含める"`
}
type ListOutput struct {
Todos []Todo `json:"todos" jsonschema:"TODO の一覧"`
}
type DoneInput struct {
ID int `json:"id" jsonschema:"完了にする TODO の番号"`
}
func (s *store) add(_ context.Context, _ *mcp.CallToolRequest, in AddInput) (*mcp.CallToolResult, Todo, error ) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil , Todo{}, errors.New("title が空です" )
}
s.mu.Lock()
defer s.mu.Unlock()
t := Todo{ID: len (s.todos) + 1 , Title: title}
s.todos = append (s.todos, t)
return nil , t, nil
}
func (s *store) list(_ context.Context, _ *mcp.CallToolRequest, in ListInput) (*mcp.CallToolResult, ListOutput, error ) {
s.mu.Lock()
defer s.mu.Unlock()
out := ListOutput{Todos: []Todo{}}
for _, t := range s.todos {
if t.Done && !in.IncludeDone {
continue
}
out.Todos = append (out.Todos, t)
}
return nil , out, nil
}
func (s *store) complete(_ context.Context, _ *mcp.CallToolRequest, in DoneInput) (*mcp.CallToolResult, Todo, error ) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.todos {
if s.todos[i].ID == in.ID {
s.todos[i].Done = true
return nil , s.todos[i], nil
}
}
return nil , Todo{}, fmt.Errorf("id %d の TODO はありません" , in.ID)
}
func newServer (s *store) *mcp.Server {
server := mcp.NewServer(&mcp.Implementation{Name: "todo" , Version: "v0.1.0" }, nil )
mcp.AddTool(server, &mcp.Tool{Name: "add_todo" , Description: "TODO を 1 件追加する" }, s.add)
mcp.AddTool(server, &mcp.Tool{Name: "list_todos" , Description: "TODO の一覧を返す" }, s.list)
mcp.AddTool(server, &mcp.Tool{Name: "complete_todo" , Description: "TODO を完了にする" }, s.complete)
return server
}
mcp.AddTool は、次の処理をまとめて引き受けます7 。
In の型から入力スキーマを、Out の型から出力スキーマを作る
呼び出し時に、引数をスキーマで検証してから In に詰める
Out を JSON にして StructuredContent と Content の両方に入れる
ハンドラが普通の error を返したら、IsError: true の結果に変える
4 のおかげで、「タイトルが空」のような失敗はプロトコルのエラーではなくツールの実行結果としてモデルに届きます。モデルはエラー文を読んで、引数を直して呼び直せます。
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main () {
server := newServer(newStore())
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
go vet ./...
go build -o todo-mcp .
できたバイナリは 1 ファイル(今回の環境で約 11MB)です。Node.js や Python の実行環境を入れなくても、このファイルを置くだけで動きます。
SDK の mcp.NewInMemoryTransports() を使うと、プロセスを起動せずにサーバーとクライアントをメモリ上でつなげます8 。go test だけで、ツールの一覧・呼び出し・エラーまで確かめられます。
package main
import (
"context"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func connect (t *testing.T) *mcp.ClientSession {
t.Helper()
ctx := context.Background()
st, ct := mcp.NewInMemoryTransports()
if _, err := newServer(newStore()).Connect(ctx, st, nil ); err != nil {
t.Fatal(err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "test" , Version: "v0.0.1" }, nil )
cs, err := client.Connect(ctx, ct, nil )
if err != nil {
t.Fatal(err)
}
t.Cleanup(func () { cs.Close() })
return cs
}
func TestTools (t *testing.T) {
ctx := context.Background()
cs := connect(t)
var names []string
for tool, err := range cs.Tools(ctx, nil ) {
if err != nil {
t.Fatal(err)
}
names = append (names, tool.Name)
}
if len (names) != 3 {
t.Fatalf("tools = %v, want 3 tools" , names)
}
if _, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "add_todo" , Arguments: map [string ]any{"title" : "README を書く" }}); err != nil {
t.Fatal(err)
}
if _, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "add_todo" , Arguments: map [string ]any{"title" : "テストを足す" }}); err != nil {
t.Fatal(err)
}
if _, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "complete_todo" , Arguments: map [string ]any{"id" : 1 }}); err != nil {
t.Fatal(err)
}
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "list_todos" , Arguments: map [string ]any{}})
if err != nil {
t.Fatal(err)
}
got := res.StructuredContent.(map [string ]any)["todos" ].([]any)
if len (got) != 1 || got[0 ].(map [string ]any)["title" ] != "テストを足す" {
t.Errorf("list_todos = %v, want only 未完了の「テストを足す」" , got)
}
}
func TestAddTodoEmptyTitle (t *testing.T) {
cs := connect(t)
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "add_todo" , Arguments: map [string ]any{"title" : " " }})
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Error("IsError = false, want true(ツールの実行エラーとして返る)" )
}
}
$ go test -v ./...
=== RUN TestTools
--- PASS: TestTools (0.01s)
=== RUN TestAddTodoEmptyTitle
--- PASS: TestAddTodoEmptyTitle (0.00s)
PASS
ok example.com/todo-mcp 4.515s
テストがあると、Claude Code にツールを足させるときも「go test ./ ... が通るまで直して」と頼めます。AI に任せる範囲を広げやすくなります。
Claude Code につなぐ前に、サーバー単体で応答を確かめておくと、問題の切り分けが楽になります。stdio の MCP は 1 行に 1 つの JSON-RPC メッセージを送る形なので、ファイルに書いてパイプで流し込めます。
cat > req.jsonl <<'EOF'
{"jsonrpc" :"2.0" ,"id" :1,"method" :"initialize" ,"params" :{"protocolVersion" :"2025-11-25" ,"capabilities" :{},"clientInfo" :{"name" :"manual" ,"version" :"0.0.1" }}}
{"jsonrpc" :"2.0" ,"method" :"notifications/initialized" ,"params" :{}}
{"jsonrpc" :"2.0" ,"id" :2,"method" :"tools/list" ,"params" :{}}
{"jsonrpc" :"2.0" ,"id" :3,"method" :"tools/call" ,"params" :{"name" :"add_todo" ,"arguments" :{"title" :"README を書く" }}}
{"jsonrpc" :"2.0" ,"id" :4,"method" :"tools/call" ,"params" :{"name" :"add_todo" ,"arguments" :{"title" :"" }}}
EOF
(cat req.jsonl; sleep 1) | ./todo-mcp
実際の出力(tools/ list の結果は長いので jq で名前だけにしたもの)は次のとおりです。
todo-mcp に initialize・tools/list・tools/call を送り、応答とツール名、空タイトルの isError を確かめるターミナル操作 筆者の環境(macOS・Go 1.26.2・go-sdk v1.8.0)で実行した出力
initialize には protocolVersion: "2025-11-25" と serverInfo が返る
tools/ list には 3 つのツールが、構造体から作られた inputSchema と outputSchema 付きで返る
空タイトルの tools/ call には "isError": true と「title が空です」が返る
2026-07-28 版の仕様では initialize を送らず、各リクエストの _meta にプロトコルのバージョンを入れます4 。同じバイナリに、新しい形のリクエストも送ってみました。
M='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
(echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{$M }}" ; sleep 1) | ./todo-mcp
{ "jsonrpc" : "2.0" , "id" : 1 , "result" : { "resultType" : "complete" , "_meta" : { "io.modelcontextprotocol/serverInfo" : { "name" : "todo" , "version" : "v0.1.0" } } , "ttlMs" : 0 , "cacheScope" : "public" , "supportedVersions" : [ "2026-07-28" , "2025-11-25" , "2025-06-18" , "2025-03-26" , "2024-11-05" ] , "capabilities" : { "logging" : { } , "tools" : { "listChanged" : true } } } }
server/ discover に対応バージョンの一覧が返りました。新旧どちらの形でも、コードは 1 行も変えていません。仕様の違いは SDK が吸収しています8 。
ビルドしたバイナリを claude mcp add で登録します。stdio のサーバーは -- の後ろに起動コマンドを書きます。-- より前は Claude Code のオプション、後ろはサーバーにそのまま渡る引数です2 。
claude mcp add todo -- /path/to/todo-mcp
claude mcp list
claude mcp get todo
筆者の環境では claude mcp list に todo: / tmp/ .../ todo-mcp - ✔ Connected と出ました。登録先は 3 種類のスコープから選べます2 。
スコープ 使える範囲 保存先 向いている用途 local(既定) 今のプロジェクトだけ・自分だけ ~/ .claude.json試作・個人用 project 今のプロジェクト・チーム全員 プロジェクト直下の .mcp.json チームで同じツールを使う user 自分のすべてのプロジェクト ~/ .claude.jsonどこでも使う汎用ツール
--scope project で登録すると、次の .mcp.json ができます(実際に生成されたもの)。
{
"mcpServers" : {
"todo" : {
"type" : "stdio" ,
"command" : "/tmp/write-article/go-mcp-server-sdk/demo/todo-mcp" ,
"args" : [ ] ,
"env" : { }
}
}
}
project スコープのサーバーは、安全のため Claude Code を対話モードで起動したときに承認を求められます2 。承認前の claude mcp get todo は「Pending approval」と表示されました。
Claude Code の中では、ツールは mcp__サーバー名__ツール名 の名前で見えます2 。今回なら mcp__todo__add_todo です。ヘッドレス実行(claude -p)で、実際に TODO を操作させてみました。
claude -p "todo の MCP ツールで「README を書く」と「テストを足す」を追加し、1 番を完了にしてから、未完了の一覧を表示して" \
--allowedTools "mcp__todo__add_todo" "mcp__todo__complete_todo" "mcp__todo__list_todos"
claude mcp add で登録し、claude mcp list で Connected を確認してから、claude -p で MCP ツールを 4 回呼ばせた結果 筆者の環境(Claude Code 2.1.289、--model sonnet を付けて実行)の出力。コメント行のツール呼び出しは stream-json 出力から抜き出したもの
--output-format stream-json で呼び出しを記録すると、add_todo を 2 回、complete_todo を 1 回、list_todos を 1 回、この順で呼んでいました。最後に「未完了は 2: テストを足す の 1 件」と答えています。
自作の MCP サーバーが効くのは、「AI に触らせたいが、そのまま渡すのは危ない・面倒なもの」に、決まった入口を作りたい場面です。
社内 API の読み取り口 : 社内の管理 API をそのまま叩かせず、「顧客を ID で引く」「直近の注文を 10 件返す」など、読み取り専用のツールに絞って渡す
DB の安全な照会 : SQL を自由に書かせる代わりに、決まったクエリだけをツールにする。引数は構造体で型が決まり、SDK が検証する
運用作業の定型化 : 「ステージングのログを直近 100 行取る」「機能フラグの状態を見る」など、手順書にある操作をツールにする
Go のコード理解を助ける : 自作ではありませんが、gopls の MCP サーバーを足すと、型やシンボルの情報を Claude Code が引けます5
Go で作る利点は、配布が楽なことです。GOOS・GOARCH を変えてクロスコンパイルすれば、チームの Mac・Linux 向けのバイナリを 1 台で作れます。Python のように仮想環境をそろえる必要がありません。
関連する記事として、HTTP で公開するリモート MCP サーバーは Cloudflare Workers にリモート MCP サーバーをデプロイする で、Go でエージェント側を作る方法は Go で AI エージェントを自作する:anthropic-sdk-go 入門 で扱っています。
stdio の MCP サーバーで多い失敗は、標準出力の扱い・パス・権限の 3 つです。
症状 原因 対処 接続直後に切れる・JSON の解析エラー fmt.Println などで標準出力にログを書いたログは標準エラーへ。log パッケージの既定の出力先は標準エラーなので、log.Printf はそのまま使える パイプで送っても何も返らない 標準入力が閉じた時点でサーバーが終了した 確認用には (cat req.jsonl; sleep 1) | ./ todo-mcp のように入力を少し開けておく claude mcp list で Failed になる起動コマンドのパスが違う・バイナリが無い 絶対パスで登録し、そのパスで ./ todo-mcp が起動するか先に確かめる project スコープのツールが使えない 承認していない 対話モードの claude で承認する。選び直すには claude mcp reset-project-choices ツールの出力が切られる 出力が大きすぎる 既定の上限は 25,000 トークン、10,000 トークンを超えると警告が出る。MAX_MCP_OUTPUT_TOKENS で変えられる2
もう 1 つ、仕様の変化にも注意してください。2026-07-28 版で Roots・Sampling・Logging の 3 機能が非推奨になりました4 。少なくとも 12 か月は動きますが、新しく作るサーバーでは使わないほうが安全です。代わりに、ディレクトリはツールの引数で受け取り、ログは stdio なら標準エラーに書きます4 。
セキュリティの面では、ツールは「AI が自由に呼べる関数」だと考えて作ります。書き込みや削除をするツールは分けて名前で分かるようにし、Claude Code の --allowedTools や権限設定で、自動で許すツールを読み取り系に絞るのがおすすめです。
公式 MCP Go SDK(2026 年 10 月時点で v1.8.0)なら、mcp.AddTool に Go の関数を渡すだけでツールになる
入出力のスキーマは構造体の json・jsonschema タグから作られ、引数の検証と IsError への変換も SDK が行う
mcp.NewInMemoryTransports() で、プロセスを起動せずに go test でツールを試せる
claude mcp add <名前> -- <絶対パス> で Claude Code につながり、ツールは mcp__<名前>__<ツール名> で呼ばれる
2026-07-28 版の仕様(initialize なし)にも旧版にも、同じコードのまま対応できる
次は、自分の業務で「AI に渡したいが直接は触らせたくないもの」を 1 つ選び、読み取り専用のツールを 1 つだけ作ってみてください。
チームが普段使う言語で作るのがいちばんです。Go を選ぶ利点は、依存のない 1 つのバイナリで配れることと、型付きの構造体からスキーマが自動で作られることです。公式 SDK は Go・TypeScript・Python のどれにもあり、Go 版は v1.0.0 から Go チームと Anthropic が共同で開発しています3 。
新しく作るなら公式 SDK をおすすめします。仕様の改訂(2026-07-28 版など)への追従が README のバージョン表で明示されていて1 、長期の保守も見込めるためです。既存のコードが mcp-go で書かれていて問題なく動いているなら、急いで移す必要はありません。
まず claude mcp list で接続状態を見ます。Failed ならコマンドのパスとビルドを、Pending approval なら対話モードでの承認を確かめます。次に、この記事のように JSON-RPC を標準入力から送り、サーバー単体で tools/ list が返るかを確認すると、原因がサーバー側か設定側かを切り分けられます。
作れます。SDK には Streamable HTTP のトランスポートがあり、ステートレスモードも用意されています8 。ただし外部に公開する場合は認証が必要になり、SDK の auth パッケージや OAuth の設計を考える必要があります。まずは stdio で作ってツールを固め、あとから HTTP に載せ替えるのが手堅い進め方です。
動作します。この記事のサーバーも説明文は日本語で、Claude Code は正しくツールを選んで呼べました。説明文はモデルがツールを選ぶ手がかりになるので、言語よりも「何をするか・いつ使うか・引数の意味」を具体的に書くことが大切です。