SDK なしで Go から MCP サーバーを実装する — Streamable HTTP + JSON-RPC 2.0 を net/http だけで
連載「承認・履歴付 OSS SSH MCP server ssh-gate」第2回。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。エージェント(Claude Desktop や Codex など)は MCP(Model Context Protocol) 経由でこのアプリにツール呼び出しを投げ、アプリ側は実行せずに承認キューへ積みます。実機を叩くのは、人間が GUI で承認ボタンを押した後だけ。
この第2回は、その入口になる MCP サーバー部分 を扱います。題材は1ファイル、mcp_server.go(約450行)です。
強調しておきたいのは、MCP の公式 SDK を一切使っていないことです。Go の標準ライブラリ(net/http と encoding/json)だけで、MCP のサーバーは普通に書けます。MCP の正体は「Streamable HTTP の上に乗った JSON-RPC 2.0」なので、仕様を読んでハンドラを1個書けば終わりです。
この記事を読むと、次のことが分かります。
- MCP の最小単位(
initialize/tools/list/tools/call)だけ実装すれば、エージェントから見えるサーバーは成立する - HTTP ハンドラのガード順序をどう設計し、なぜその順序なのか
- ツール実行で「実行せずキューに積む」分岐をどこに入れるか
- リスク分類のような便利だが信用できないヒューリスティックを、どう扱う(自動実行に使わない)か
そして最大の持ち帰りは、第0節で先に書いておきます。MCP サーバーの自作は難しくありません。難しいのは「ツールに何をさせないか」の設計です。
1. MCP の最小理解 — 実装すべきは3メソッドだけ
MCP は「LLM クライアントとツール提供サーバーの間の標準プロトコル」です。トランスポートにはいくつか種類がありますが、ssh-gete は Streamable HTTP(HTTP の POST で JSON-RPC を1往復するスタイル)を採用しています。
クライアントとサーバーの会話は、突き詰めると次の3メソッドで回ります。
| メソッド | 役割 | サーバーが返すもの |
|---|---|---|
initialize |
接続確立・能力交換 | プロトコルバージョン、capabilities、serverInfo |
tools/list |
使えるツールの一覧 | ツール名・説明・入力スキーマ(JSON Schema) |
tools/call |
ツールを1個実行 |
content: [{type:"text", text:...}] 形式の結果 |
これに notifications/*(ID を持たない通知。返答不要)への対応を足せば、Claude Desktop のような一般的なクライアントとは問題なく繋がります。ssh-gete が実装しているのもこの範囲だけです。
JSON-RPC 2.0 のリクエストはこういう形です。
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
レスポンスは result か error のどちらかを返します。error には JSON-RPC の標準コード(-32700 parse error、-32600 invalid request、-32601 method not found、-32602 invalid params など)を使います。これだけ把握すれば、もう書けます。
2. JSON-RPC の型定義(mcp_server.go:15)
まず JSON-RPC のリクエスト・レスポンス・エラーを Go の構造体に落とします。
// mcp_server.go:15
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
ここで効いている設計判断が2つあります。
-
ID any:JSON-RPC のidは数値でも文字列でもよく、通知では存在しません。anyにしておけば型を選ばず、そのままエコーバックできます。omitemptyで通知応答時には消えます。 -
Params json.RawMessage:paramsの中身はメソッドごとに違います。ディスパッチの時点では中を見ず、生のバイト列のまま持ち回り、各ハンドラが必要になってからjson.Unmarshalする。これで型分岐が1段ネストせずに済みます。
ツール呼び出しの引数も、必要な分だけ型を切ります。tools/call のパラメータと、実行系ツールの引数はこうです。
// mcp_server.go:38
type toolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"` // ここも RawMessage
}
// mcp_server.go:43
type executeCommandArgs struct {
Host string `json:"host"`
Command string `json:"command"`
Reason string `json:"reason"`
AgentName string `json:"agent_name"`
RequestedBy string `json:"requested_by"` // agent_name の別名(後方互換)
// ...
}
Arguments も json.RawMessage です。tools/call は「ツール名でディスパッチしてから、そのツール固有の引数をデコード」という二段構えになるので、ここでも遅延デコードが効きます。
3. サーバー起動とガード順序(handleMCP、mcp_server.go:130)
3.1 起動・再起動
サーバーの組み立ては素朴です。http.ServeMux に3つのパスを生やすだけ(mcp_server.go:53 の startMCPServer)。
-
/healthz… ヘルスチェック(接続中エージェント数も返す) -
/mcp… 本体。handleMCPに丸投げ -
/audit/events… 監査用の履歴ダンプ
http.Server には ReadHeaderTimeout / ReadTimeout / MaxHeaderBytes を設定しています(mcp_server.go:90)。ローカル待受(既定 127.0.0.1:8787)とはいえ、タイムアウトと上限は最初から付けておきます。
設定変更時は restartMCPServer(mcp_server.go:113)が2秒で graceful shutdown してから再起動します。設定保存のたびにこれが呼ばれ、ポートやパスの変更が即反映されます。
3.2 ガードの順序とその理由
本体 handleMCP(mcp_server.go:130)は、リクエストを処理する前に門を順番にくぐらせます。この順序には理由があります。
1. CORS プリフライト(OPTIONS)への即応
2. エージェント記録(rememberMCPAgent)
3. POST 以外を拒否
4. Bearer トークン検証(有効時のみ)
5. ボディサイズ上限(MaxBytesReader)
6. JSON デコード
7. 通知(notifications/*)は 202 で即返し
8. dispatchMCP へ
順を追って、なぜこの並びなのかを見ます。
(1) OPTIONS を最初に返す(mcp_server.go:131)。ブラウザ系クライアントの CORS プリフライトは認証もボディも持たないので、何より先に 204 No Content で返します。ここで Access-Control-Allow-Origin を http://localhost に限定しているのもポイントです。
// mcp_server.go:131
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
w.Header().Set("Access-Control-Allow-Headers", "content-type, authorization")
w.WriteHeader(http.StatusNoContent)
return
}
(2) エージェント記録を認証より前に置く(mcp_server.go:137)。rememberMCPAgent(r) は「誰が叩きに来たか」を記録します。これは認証で弾かれる前に呼びます。未承認の接続試行も含めて観測したいからです(GUI の「被接続エージェント数」に効く)。
(3) POST 以外を拒否(mcp_server.go:138)。Streamable HTTP の本体は POST です。GET などは -32600 POST only で返します。
(4) Bearer 検証は POST と判定した後(mcp_server.go:145)。トークンが有効化されているときだけ、Authorization: Bearer <token> を厳密一致でチェックします。401 -32001 unauthorized。
// mcp_server.go:145
if settings.BearerEnabled && (token == "" || r.Header.Get("Authorization") != "Bearer "+token) {
writeJSON(w, http.StatusUnauthorized, rpcResponse{
JSONRPC: "2.0",
Error: &rpcError{Code: -32001, Message: "unauthorized"},
})
return
}
(5) ボディ上限を「認証の後・パースの前」に置く(mcp_server.go:153)。これが順序設計の肝です。
// mcp_server.go:153
r.Body = http.MaxBytesReader(w, r.Body, int64(settings.MaxBodyKB)*1024)
defer r.Body.Close()
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, rpcResponse{
JSONRPC: "2.0",
Error: &rpcError{Code: -32700, Message: "invalid json"},
})
return
}
http.MaxBytesReader で包んでから json.NewDecoder(...).Decode を回すので、巨大ボディを全部メモリに読む前に途中で打ち切れます。認証より後に置くのは、未認証の相手のためにわざわざボディを読みに行かないためです。安いチェック(メソッド・トークン)を先に、高くつくチェック(ボディ読み込み・JSON パース)を後に。順序は「コストが低く拒否確度が高いものから」が原則です。
(7) 通知は 202 で握りつぶす(mcp_server.go:163)。id が無く notifications/ で始まるメソッドは、JSON-RPC の通知なので応答を返してはいけません。202 Accepted だけ返して終わります。
// mcp_server.go:163
if req.ID == nil && strings.HasPrefix(req.Method, "notifications/") {
w.WriteHeader(http.StatusAccepted)
return
}
ここまで通った正規のリクエストだけが dispatchMCP に進み、結果は 200 OK の rpcResponse で返ります(mcp_server.go:168)。
なお、JSON-RPC のエラー(method not found 等)でも HTTP ステータスは
200 OKで返し、errorフィールドに詰めます。HTTP 層の失敗(POST 以外・未認証・JSON 不正)だけが非 200 です。この線引きは JSON-RPC の慣習に沿っています。
4. メソッドのディスパッチとツール実行
4.1 dispatchMCP(mcp_server.go:201)
メソッド名で switch するだけです。
// mcp_server.go:201
switch req.Method {
case "initialize":
// protocolVersion をエコー(無ければ "2025-06-18")、capabilities/serverInfo を返す
case "tools/list":
return map[string]any{"tools": mcpTools()}, nil
case "tools/call":
var params toolCallParams
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
return nil, &rpcError{Code: -32602, Message: "invalid tool params"}
}
return a.callMCPTool(params)
default:
return nil, &rpcError{Code: -32601, Message: "method not found"}
}
initialize ではクライアントが送ってきた protocolVersion をそのまま返し(無ければ 2025-06-18 を既定に)、capabilities.tools.listChanged: false(ツール一覧は動的に変わらない)と serverInfo(name=ssh-gete, version)を返します。最小限の握手です。
tools/list は mcpTools()(mcp_server.go:352)が返す静的なツール定義をそのまま渡すだけ。各ツールは name / description / inputSchema(JSON Schema)を持ちます。
4.2 4つのツール(callMCPTool、mcp_server.go:232)
tools/call の本体です。ツール名で再び switch します。ssh-gete のツールは4つ。
| ツール | 役割 | 副作用 |
|---|---|---|
list_hosts |
登録済み接続先の一覧 | なし(読み取り) |
get_command_history |
承認キュー+履歴の取得 | なし(読み取り) |
request_command_execution |
実行要求を承認キューへ | キューに積む |
execute_command |
(名前に反して)承認キューへ | キューに積む |
list_hosts と get_command_history は mu.RLock の下で内部状態をコピーして textToolResult で包むだけの読み取り系です。問題は実行系の2つ。
4.3 「execute_command が実行しない」という設計
ツール名は execute_command ですが、MCP 経由ではコマンドを直接実行しません。description にもそう書いてあります(mcp_server.go:365)。
execute_command:「コマンド実行要求を承認キューへ登録します。MCP経由ではLowリスクでも直接SSH実行しません。」
request_command_execution と execute_command はほぼ同一の流れで、共通の本体は executeCommandTool(mcp_server.go:305)にまとまっています。流れはこうです。
// mcp_server.go:305(executeCommandTool、抜粋)
args.Host = strings.TrimSpace(args.Host)
args.Command = strings.TrimSpace(args.Command)
if args.Host == "" || args.Command == "" {
return nil, &rpcError{Code: -32602, Message: "host and command are required"}
}
agentName := commandAgentName(args) // agent_name か requested_by
if agentName == "" {
return nil, &rpcError{Code: -32602, Message: "agent_name is required"}
}
risk := classifyRisk(args.Command)
conn, ok := a.findConnection(args.Host)
if !ok {
return nil, &rpcError{Code: -32602, Message: "host not found"}
}
if conn.connectionType() == "Serial" {
return nil, &rpcError{Code: -32602,
Message: "serial connections are interactive-only; not executable via MCP"}
}
if a.agentApprovalBypassEnabled(agentName) {
req := a.executeCommandAndLog(conn, args.Command, args.Reason, agentName, risk)
return textToolResult(map[string]any{"auto_executed": true, /* stdout/stderr/duration */}), nil
}
req := a.queueCommand(args.Host, args.Command, args.Reason, agentName, risk)
return textToolResult(map[string]any{
"queued": true,
"request_id": req.ID,
"risk": risk,
"reason": "approval_required",
}), nil
順番に、ガードと分岐を確認します。
-
host/command必須。空なら-32602。 -
agent_name必須(無ければrequested_byを拾う。commandAgentName、mcp_server.go:345)。匿名のエージェントには実行させないための門です。 -
classifyRiskでリスク判定(後述)。 -
findConnectionで接続先を解決。無ければhost not found。 - シリアル接続先なら拒否(後述)。
-
承認バイパスの有無で分岐:
-
agentApprovalBypassEnabled(agentName)がtrue(=人間がそのエージェントを明示的に信頼登録済み)→executeCommandAndLogで即実行し、結果(stdout/stderr/duration)をauto_executed: trueで返す。 - そうでなければ →
queueCommandで承認キューへ積むだけ。レスポンスはqueued: trueとrequest_id・riskのみ。stdout は返らない。
-
ここが ssh-gete の核です。信頼登録されていないエージェントには、コマンドの出力すら返しません。エージェント側から見えるのは「キューに積みました」という事実だけ。人間が GUI で承認して初めて実行され、結果が履歴に載ります。デフォルトは「実行しない」、例外は「人間が意図的に開けた穴だけ」。
テストでもこの不変条件を固定しています。whoami(Low リスク)ですら承認キュー行きになることを TestMCPExecuteCommandQueuesLowRisk(mcp_server_test.go:116)が、sudo systemctl restart nginx が High で 承認待ち に積まれることを TestMCPRequestCommandExecutionQueues(mcp_server_test.go:78)が、agent_name 欠落時に agent_name is required を返してキューに積まないことを TestMCPCommandRequiresAgentName(mcp_server_test.go:154)が保証します。
補足:
AutoExecuteLowRiskという「Low リスクは自動実行」フラグは構造体に存在しますが、sanitizeMCPSettingsで常にfalseに固定され、UI からも有効化できません(解説.md 14.1)。設定のどの経路を通っても最終的に false に倒れます。つまり唯一の自動実行経路は、上の「エージェント単位の明示的な承認バイパス」だけです。
5. list_hosts は type を返す — シリアル接続先は MCP から実行拒否
ssh-gete は SSH だけでなくシリアル接続先も管理します(第6回)。しかしシリアルは「コマンド→終了コード」モデルを持たない対話ストリームなので、承認キュー(=コマンド単位の承認)には乗りません。シリアルは GUI の対話端末専用です。
この役割分担を、MCP 側でもコードで強制しています。
まず、エージェントが SSH とシリアルを区別できるよう、list_hosts は各接続先の type を返します。
// mcp_server.go:234(list_hosts)
hosts = append(hosts, map[string]any{
"name": conn.Name,
"type": conn.connectionType(), // "SSH" | "Serial"
"host": conn.Host,
"port": conn.Port,
"user": conn.User,
"auth_method": conn.AuthMethod,
"tags": conn.Tags,
"status": conn.Status,
})
connectionType()(app.go:71)は、種別が "Serial" のときだけ "Serial" を返し、それ以外(種別未設定の旧レコード含む)は "SSH" 扱いにする後方互換ヘルパーです。
そして実行系ツールでは、第4節で見たとおり、解決した接続先がシリアルなら即拒否します(mcp_server.go:271 と 321 の2か所)。
if conn.connectionType() == "Serial" {
return nil, &rpcError{Code: -32602,
Message: "serial connections are interactive-only; not executable via MCP"}
}
このガードが無いと、ホストが空のシリアル接続先に対して SSH 実行を試み、無駄な失敗履歴が残るだけでした。TestMCPRejectsSerialConnection(mcp_server_test.go:190)が回帰テストとしてこれを守ります。種別 Serial の接続先に execute_command を投げると、
- レスポンスの
error.Messageにserialが含まれること(拒否) - 承認キューに1件も積まれないこと(
GetInitialData().Requestsが 0)
の両方を検証しています。「拒否する」だけでなく「副作用を一切残さない」ことまで確認しているのがポイントです。
6. リスク分類 classifyRisk のヒューリスティックと、その限界
実行系ツールはコマンド文字列を classifyRisk(mcp_server.go:427)に通し、High / Medium / Low のラベルを付けます。
// mcp_server.go:427
func classifyRisk(command string) string {
lower := strings.ToLower(command)
highPatterns := []string{
" rm ", "rm -", "rm\t", "mkfs", "dd if=", "shutdown", "reboot", "halt",
"systemctl restart", "systemctl stop", "service restart", "service stop",
"iptables", "ufw ", "userdel", "passwd ", "chown -r", "chmod -r",
}
for _, pattern := range highPatterns {
if strings.Contains(" "+lower+" ", pattern) {
return "High"
}
}
mediumPatterns := []string{
"sudo ", "apt ", "apt-get ", "yum ", "dnf ", "brew ", "docker rm",
"docker stop", "kubectl delete", "mv ", "cp -r", "truncate",
}
// ... Medium 判定 ...
return "Low"
}
やっていることは単純で、小文字化して前後に空白を足し、部分文字列マッチするだけです。rm -rf や mkfs、shutdown は High、sudo や apt、kubectl delete は Medium、それ以外は Low。
ここで正直に書いておくべき限界があります。これは構文解析ではなく文字列マッチのヒューリスティックなので、簡単に騙せます。
-
r""m -rf /(クオートで分断)、/bin/rm(パターンrmに当たらない) - エイリアスや環境変数経由(
$DESTRUCT、alias x=rm) - base64 デコードしてパイプ、など
だからこそ、ssh-gete は classifyRisk の結果を「実行可否の判断」には使いません。リスクラベルはあくまで**人間が承認画面で判断するときの目安(色分け・優先度)**として表示するだけです。実行可否を決めるのは「人間の承認」と「エージェント単位の明示的なバイパスポリシー」であって、文字列マッチではありません。
これは設計上とても重要な割り切りです。「危険なコマンドを自動で弾く」方向に倒すと、ヒューリスティックの抜け穴がそのままセキュリティホールになります。ssh-gete は逆向きで、何も信用せず全部キューに積み、人間に見せる。classifyRisk が間違えても(High を Low と誤判定しても)、承認の門は閉じたまま——だから安全側に倒れます。信用できない分類器を、信用しない場所に置く設計です。
7. rememberMCPAgent と未申告エージェントの扱い
第3節で触れたとおり、rememberMCPAgent(mcp_server.go:177)は認証より前に呼ばれ、接続元を記録します。
// mcp_server.go:177
func (a *App) rememberMCPAgent(r *http.Request) {
key := r.Header.Get("User-Agent")
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
key = host + " " + key // IP + User-Agent をキーに
}
if strings.TrimSpace(key) == "" {
key = r.RemoteAddr
}
a.mu.Lock()
if a.mcpAgents == nil {
a.mcpAgents = map[string]time.Time{}
}
a.mcpAgents[key] = time.Now() // 最終観測時刻を更新
a.mcp.ConnectedAgents = a.connectedAgentsLocked()
a.mu.Unlock()
}
mcpAgents は「キー(IP + User-Agent)→ 最終観測時刻」のマップです。connectedAgentsLocked(app.go:485)は直近5分以内に観測されたキーを数え、それが GUI の「被接続エージェント数」になります。HTTP リクエストの観測(接続の事実)と、ツール呼び出し時の agent_name(自己申告のアイデンティティ)は別物である点に注意してください。
ここで「未申告エージェント」という概念が出てきます。実行系ツールは agent_name(または requested_by)を必須にしていますが(commandAgentName、mcp_server.go:345)、これはエージェントの自己申告にすぎません。ssh-gete はこの申告名を「信頼登録(承認バイパス)」のキーとして使います。
つまり扱いは2段構えです。
-
接続の観測(
rememberMCPAgent):認証前から無条件に記録。誰が叩きに来たかを可視化する監査目的。 -
アイデンティティの申告(
agent_name):ツール呼び出しごとに必須。これが無いコマンド要求は弾く(agent_name is required)。そして、人間が GUI で特定のagent_nameに承認バイパスを与えたときだけ、そのエージェントは自動実行できる。
匿名(agent_name 空)では、そもそもコマンド要求が -32602 で弾かれます。未申告エージェント のような名前に承認バイパスを与えることはできません(第3回で扱う承認 UI 側でも、未申告には信頼付与不可)。「名乗らないエージェントには何もさせない」——これも安全側の既定です。
まとめ — 難しいのは「ツールに何をさせないか」
この記事で見たことを整理します。
-
MCP サーバーの実体は JSON-RPC 2.0 over Streamable HTTP。
net/http+encoding/jsonだけで、initialize/tools/list/tools/callの3メソッドを書けば成立する。SDK は要らない。 - 型は
ID anyとParams/Arguments json.RawMessageの遅延デコードが効く。ディスパッチの時点で中を見ない。 - HTTP ハンドラのガード順序は「コストが低く拒否確度が高いものから」。OPTIONS 即応 → エージェント記録(認証前)→ POST 限定 → Bearer → ボディ上限 → JSON パース → 通知握りつぶし → dispatch。未認証の相手のためにボディを読みに行かない。
- 4ツールのうち実行系(
execute_command/request_command_execution)は実行せずキューに積む。出力すら返さない。唯一の自動実行は「人間が明示的に承認バイパスを与えたエージェント」だけ。 - シリアル接続先は対話専用なので、
list_hostsでtypeを見せつつ、実行系ではコードで拒否(TestMCPRejectsSerialConnection)。 -
classifyRiskは信用できないヒューリスティック。だから実行可否には使わず、人間への表示にとどめる。誤判定しても承認の門は閉じたまま、という安全側設計。
MCP サーバーを動かすだけなら、半日もあれば書けます。本当に時間をかけるべきは、その先——ツールに何をさせて、何をさせないかの境界設計です。ssh-gete の場合、それは「デフォルトで実行しない」「匿名には何もさせない」「危険判定を信用しない」という3つの『させない』に凝縮されています。
次回(第3回)は、承認ボタンが押された瞬間から SSH 実行・監査ログまでの心臓部を追います。承認キューに積まれたコマンドが、どう実機に届くのか。
参照コード(本記事の引用元)
-
mcp_server.go:15— JSON-RPC 型定義(rpcRequest/rpcResponse/rpcError) -
mcp_server.go:38/mcp_server.go:43—toolCallParams/executeCommandArgs(json.RawMessage遅延デコード) -
mcp_server.go:53/mcp_server.go:113—startMCPServer/restartMCPServer -
mcp_server.go:130—handleMCP(ガード順序) -
mcp_server.go:177—rememberMCPAgent(接続観測・認証前) -
mcp_server.go:201—dispatchMCP(メソッドswitch) -
mcp_server.go:232—callMCPTool(4ツールの分岐、list_hostsのtype、シリアル拒否) -
mcp_server.go:305—executeCommandTool(必須引数・シリアル拒否・承認バイパス分岐) -
mcp_server.go:345—commandAgentName(agent_name/requested_by) -
mcp_server.go:352—mcpTools(ツール定義 / JSON Schema) -
mcp_server.go:427—classifyRisk(リスク分類ヒューリスティック) -
app.go:71—connectionType()(後方互換) -
app.go:485/app.go:627—connectedAgentsLocked/agentApprovalBypassEnabled -
app.go:638/app.go:668—queueCommand/executeCommandAndLog -
mcp_server_test.go:78/116/154/190— キュー投入・Low リスクもキュー・agent_name必須・TestMCPRejectsSerialConnection
逐行の詳細は
docs/解説.md第7章「MCP サーバーmcp_server.goの詳説」、付録B「逐行コードリーディング」、第11章「テスト」を参照。
この記事はオープンソース ssh-gate の紹介記事です。
関連記事
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。
