Open9

コードリーディングその2 - 外部プロセスとSSE通信を仲介するセッション型中継サーバ

ta.toshiota.toshio

このコード(mcp-stdio-proxy)は、「Model Context Protocol (MCP) に準拠した外部プロセスと、HTTP経由で通信するクライアントを接続するための中継サーバ」です。以下のような構成になっています。


🗂️ 概要

機能 内容
外部接続 任意の外部コマンド(LLMやMCP対応アプリ)を起動
通信方式 クライアント→HTTP POST、サーバ→SSE(Server-Sent Events)
セッション管理 UUIDで接続セッションを管理。セッションごとに外部プロセスが立ち上がる
Graceful Shutdown SIGTERMでの終了通知、Lameduck猶予、timeout付きの安全な停止

🔁 全体の流れ(クライアントから見た場合)

  1. クライアントGET /sse で接続し、セッションが開始
  2. 外部コマンドが1つ起動し、その標準入力・出力がHTTPと接続
  3. POST /messages?sessionId=... にリクエストを送ると、外部プロセスの stdin に流れる
  4. 外部プロセスの stdout がSSEでクライアントに送信される

🧵 セッションごとのプロセス生成とIO中継

cmd := exec.Command(name, arg...)
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Start()
  • 各クライアント(GET /sse)のたびにプロセスが生成されます。
  • bufio.Scanner(stdout) で標準出力を1行ずつ取り出して responseQueue に送信。
  • requestQueuestdin.Write(data) によってクライアントの入力をプロセスに流し込む。

📩 /messages → stdin(POST)

var rawMessage json.RawMessage
json.NewDecoder(r.Body).Decode(&raw)
s.requestQueue <- raw
s.requestQueue <- []byte("\n")
  • クライアントからのPOSTリクエストは JSON-RPC 形式。
  • パースして method を取り出し、必要ならログ出力。
  • 改行で終端することで、標準入力のパーサが1行ずつ処理可能。

📤 /sse → stdout(SSE)

fmt.Fprintf(w, "event: message\ndata: %s\n\n", data)
flusher.Flush()
  • stdoutから受け取った行は event: message というSSE形式でクライアントに送られる。
  • 最初にセッション専用のエンドポイントを event: endpoint で送信。

🛑 Graceful Shutdown 処理

<-quit
time.Sleep(time.Duration(lameduck) * time.Second)
ctx, cancel := context.WithTimeout(...)
server.Shutdown(ctx)
  • SIGTERM を受け取ると lameduck 秒だけ待ち、クライアントに処理完了の猶予を与える。
  • その後 http.Server.Shutdown() を呼び、安全に終了。

✅ 利用用途の想定

  • LLMなどの「標準入出力ベース」でやり取りするAPIやアプリをHTTPサーバ化したい場合
  • MCPプロトコル準拠のプロセスと簡単に通信したい場合
  • Webクライアントとの非同期通信(SSE)とPOST APIを同時に提供したい場合

💡 改善・応用アイデア

観点 改善内容
冗長なプロセス起動 セッションごとのプロセスではなく、プロセスプールで再利用可能に
標準エラー出力の取り扱い stderrPipe を追加してエラーログを記録
クォータ制限 リクエストサイズ、セッション数の制限機構
WebSocket対応 SSEの代わりに WebSocket による双方向通信対応も可

もう少し詳しく


🧱 1. グローバル定義・構造体定義

🔹 MCPMethod 定義

type MCPMethod string
const (
  InitializeRequest MCPMethod = "initialize"
  ...
)
  • MCPプロトコルにおける メソッド名の列挙子
  • JSON-RPCメッセージの "method" に対応
  • 型安全のため MCPMethod 型にしている

🔹 MCPMessage 構造体

type MCPMessage struct {
  JSONRPC string    `json:"jsonrpc"`
  Method  MCPMethod `json:"method"`
  ID      any       `json:"id,omitempty"`
}
  • クライアントが送るMCPメッセージの構造
  • id は任意(通知なら省略可能)

🔹 session 構造体

type session struct {
  id            string
  responseQueue chan []byte
  requestQueue  chan []byte
}
  • セッション単位の送受信チャネルを管理
  • 1接続ごとに外部プロセスと1セッションが紐付く

🧱 2. main関数:フラグ解析と外部コマンド設定

flag.StringVar(&address, "address", "0.0.0.0:8080", "")
...
flag.Parse()
  • コマンドライン引数を解析(listenアドレス、終了猶予など)
  • flag.Args()実行すべき外部MCP対応コマンドを取得(例: ./github-mcp-server stdio
args := flag.Args()
name := args[0]
arg := args[1:]

🧱 3. HTTPハンドラ定義とセッションマップ

sessions := &sync.Map{}
mux := http.NewServeMux()
  • sync.Map でスレッドセーフにセッションを管理
  • mux は3つのエンドポイントを定義します:

GET /sse

mux.HandleFunc("GET /sse", func(w http.ResponseWriter, r *http.Request) { ... })

処理の流れ:

  1. SSE用ヘッダーを設定
  2. exec.Command(name, arg...) で外部プロセスを起動
  3. stdin / stdout にパイプ接続
  4. session を作成し sessions に保存
  5. stdout を逐次 responseQueue に流す Goroutine 起動
  6. 初期レスポンスとして "event: endpoint" を返す(POST先を知らせる)
    w.Header().Set("Content-Type", "text/event-stream")

    _, _ = fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n",
    fmt.Sprintf("http://%s/messages?sessionId=%s", r.Host, s.id))
    flusher.Flush()
  1. メインループ:

    • requestQueue → stdin へ書き込み(クライアント → 外部プロセス)
    • responseQueue → SSEで返送(外部プロセス → クライアント)
select {
  case data := <-s.requestQueue:
    stdin.Write(data)
  case data := <-s.responseQueue:
    fmt.Fprintf(w, "event: message\ndata: %s\n\n", data)
    flusher.Flush()
  case <-r.Context().Done():
    return
}

この select ブロックは、セッションが生きている間、ずっとI/O中継を続けるループです。

分岐 処理内容
s.requestQueue にデータが来た場合 → 外部プロセス(stdin)に書き込む(クライアント→プロセス)
s.responseQueue にデータが来た場合 → SSEでクライアントに送る(プロセス→クライアント)
r.Context().Done() クライアントがSSE接続を切断したらループ終了

終了時の後始末:

defer func() {
  sessions.Delete(s.id)
  _ = cmd.Process.Kill()
  _ = cmd.Wait()
  close(s.requestQueue)
  close(s.responseQueue)
}()

POST /messages

mux.HandleFunc("POST /messages", func(w http.ResponseWriter, r *http.Request) { ... })

処理の流れ:

  1. URLの sessionId を取得
  2. 該当セッションを sessions.Load() で取得
  3. リクエストボディから MCPメッセージを取得(JSON-RPC)
  4. ログ出力(オプション)
  5. requestQueuerawMessage を送信し、さらに改行を追加(改行区切りの入力)
s.requestQueue <- rawMessage
s.requestQueue <- []byte("\n")
  • リクエストボディをそのまま外部プロセス(stdin)に流すため、キューに入れる。
  • MCPの stdio トランスポート仕様により、メッセージは改行で区切る必要があるため、末尾に \n を追加。
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
// http.StatusText(202) この関数は、HTTPステータスコードに対応する人間向けのメッセージを返します。
// http.StatusText(http.StatusAccepted) // => "Accepted"
_, _ = w.Write([]byte(http.StatusText(http.StatusAccepted))) // つまり []byte("Accepted")
  • ステータスコード:202 Accepted
  • Content-Type:application/json
  • レスポンスボディ:Accepted

余談
この w.Write([]byte(http.StatusText(...))) は あってもなくても動作上は問題ない ことが多いです。
なぜなら:
HTTPクライアントは 202 のステータスコードで処理の受理を判断する
Accepted という文字列がボディにあっても、たいていは無視される


GET /healthz

mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { ... })
  • ヘルスチェック用。常に 200 OK を返す。

🧱 4. HTTPサーバの起動

listener, err := net.Listen("tcp", address)
server := &http.Server{ Handler: mux }
  • 通常のHTTPサーバを起動
go func() {
  if err := server.Serve(listener); ... // パニック保護付き
}()

🧱 5. Graceful Shutdown 処理

signal.Notify(quit, syscall.SIGTERM)
<-quit
time.Sleep(time.Duration(lameduck) * time.Second)
  • SIGTERM を受けたら:

    1. lameduck 秒だけ新規処理停止猶予を与える
    2. context.WithTimeout() で安全に Shutdown()
ctx, cancel := context.WithTimeout(...)
server.Shutdown(ctx)

✅ まとめ:このプログラムの本質

機能 説明
外部コマンド起動 MCP準拠のサーバ(例: github-mcp-server stdio)を起動
入力 クライアントから POST /messages によるMCPメッセージ受信
出力 外部プロセスの stdout を SSE でクライアントへ配信
セッション管理 クライアントごとにセッション+プロセスが紐づく
安全な終了 SIGTERM → lameduck → graceful shutdown 対応
ta.toshiota.toshio

https://github.com/github/github-mcp-server


🧠 github-mcp-server とは何か?

GitHub MCP Server は:

GitHub が定義・採用している Model Context Protocol (MCP) に準拠したサーバプロセス

であり、MCP対応のクライアントや中継(今回の mcp-stdio-proxy)と接続して、GitHubに関する操作を行う バックエンドエージェント です。


🔧 動作モードとしての stdio

このサーバは複数の起動モード(インターフェース)をサポートしています:

モード 説明
stdio 標準入出力で通信(今回使用)
http / grpc ネットワーク経由で通信
$ ./github-mcp-server stdio

と実行すると:

  • stdin から受け取った JSON-RPC メッセージ(MCP形式)を解釈し、
  • stdout に結果(または通知)を出力します。

🔄 実際のやり取りのイメージ

クライアントが POST /messages で以下のようなメッセージを送る:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "resources/list"
}

mcp-stdio-proxystdin に流し込む

github-mcp-server は GitHub API を呼び、結果を stdout に出力:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resources": [
      { "id": "pr:123", "title": "Fix bug", "kind": "pull_request", ... }
    ]
  }
}

mcp-stdio-proxy が SSE を通じてブラウザに返す


✅ なぜ github-mcp-server + mcp-stdio-proxy を使うのか?

この構成によって:

  • GitHubの機能(PR一覧、issue取得など)を、LLMから安全・抽象的に使える
  • スタンドアロンのプロセスとして安全に分離
  • WebクライアントからHTTPで簡単に接続できる(SSEとPOST)

というメリットがあります。

ta.toshiota.toshio

記事内

FROM ghcr.io/lifull/keel/mcp-stdio-proxy:main AS mcp-stdio-proxy

FROM ghcr.io/github/github-mcp-server@sha256:fdf04e33b437c523d2f091b7bf8dc3724c88dbf9913d6568f12f0fcf48aaff95

COPY --link --from=mcp-stdio-proxy /usr/local/bin/mcp-stdio-proxy /usr/local/bin/mcp-stdio-proxy

ENTRYPOINT ["/usr/local/bin/mcp-stdio-proxy", "/server/github-mcp-server", "stdio"]
ENTRYPOINT ["/usr/local/bin/mcp-stdio-proxy", "/server/github-mcp-server", "stdio"]

これはコンテナ起動時に、以下のコマンドが実行されることを意味します:

/usr/local/bin/mcp-stdio-proxy /server/github-mcp-server stdio

🔍 つまり、cmdnamearg に何が渡るか?

Goコード内のこの部分:

args := flag.Args()
name := args[0]
arg := args[1:]
cmd := exec.Command(name, arg...)

に当てはめると、次のようになります:

変数
args ["/server/github-mcp-server", "stdio"]
name "/server/github-mcp-server"
arg ["stdio"]

そしてこの結果、以下の外部プロセスが起動されます:

exec.Command("/server/github-mcp-server", "stdio")

つまり、

mcp-stdio-proxy/server/github-mcp-server stdio を起動し、その stdin/stdout を中継する

という構成です。


💡 stdio の意味は?

この "stdio" は、github-mcp-server 側が 「通信を stdin/stdout 経由で行う」 モードに切り替えるための引数です。

多くの MCP 対応プログラムは、以下のようなモード切替を持っています:

  • stdio: 標準入出力で通信する(mcp-stdio-proxy との連携用)
  • http: 自分で HTTP サーバとして立ち上がる(プロキシ不要)

mcp-stdio-proxystdio モードを前提としているので、外部ツールに対して "stdio" の引数を渡す必要があります。


✅ まとめ

  • cmd := exec.Command(name, arg...) に渡るのは:

    • name: /server/github-mcp-server
    • arg: ["stdio"]
  • 結果として github-mcp-server が stdio モードで起動され、プロキシがその I/O を中継します。

ta.toshiota.toshio

脱線その1

クライアントとは

✅ 質問1:「クライアント」とは誰か?

ここでいう「クライアント」とは、MCPプロトコルに従って mcp-stdio-proxy に HTTP リクエストを送る存在です。
具体的には、次のようなものです:

🔸 実体の例

クライアントの種類 実装例
AIエディタ Cursor, Windsurf, Cline など
Web UI ブラウザ上の開発支援ダッシュボード(内部ツールなど)
CLIツール MCP対応のコマンドラインツール(例: keel
LLMエージェント MCPクライアントを内部に持つAIチャットボット/LangChain系

これらはすべて、POST /messages?sessionId=... というHTTP APIを呼び出すHTTPクライアントです。


✅ 質問2:「JSON-RPCフォーマットにしているのは誰か?」

結論:上記のクライアント自身が、JSON-RPC形式にして送信しています。

🔸 なぜか?

mcp-stdio-proxy は、単なる 中継・変換レイヤ です。
以下の変換しかしていません:

HTTP POST (JSON-RPC message) → stdin(外部プロセス)
stdout(レスポンス) → SSEでクライアントへ

したがって、JSON-RPCのメッセージ構造はクライアントが責任を持って作る必要があります。


✅ JSON-RPCの構造はどこで定義されているのか?

それは、Model Context Protocol (MCP) の仕様によって定められています。

  • MCPは、JSON-RPC 2.0をベースとしたプロトコルです
  • その上で、次のような method 名やパラメータ構造を定義しています:
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "resources/list"
}

参考: MCP Schema (GitHub)


🔁 実際の処理の流れ(まとめ)

  1. クライアント(例:Cursor)が POST /messages?sessionId=... にリクエストを送る
  2. ボディには MCPに従ったJSON-RPCメッセージが入っている
  3. mcp-stdio-proxy がそのまま stdin に流す
  4. 外部プロセス(例:github-mcp-server)がそのメッセージを処理
  5. 結果が stdout に出力され、SSEでクライアントに戻る

🔍 参考図

[ AIエディタ / クライアント ]
        │
        │ HTTP POST /messages
        │ Body: {"jsonrpc": "2.0", "method": "resources/list", ...}
        ▼
[mcp-stdio-proxy]  ⇔  [ 外部プロセス(github-mcp-server) ]
       stdin            stdout
        ▼                 ▲
      実行結果をSSEで返却

✅ まとめ

観点 説明
クライアントとは MCP形式のJSON-RPCをHTTP経由で送る実行側(Cursor等)
JSON-RPCを構築するのは クライアント自身
mcp-stdio-proxy の責務 JSON-RPCを中継するだけ。解釈・バリデーションはしない
MCPメッセージ形式の定義 Model Context Protocolのスキーマ に従う

JSON-RPCを構築して送るのは誰か

この文脈で「JSON-RPCを構築して送るのは誰か?」という質問は、
実は「MCPに従って行動を決める“実行主体”は誰か?」という根本の問いにあたります。

結論から言えば:


✅ JSON-RPCを構築して送るのは、AIエディタ内の「AIエージェント」または「アシスタント」機構です。


🔍 具体的に言うと誰か?(例別)

ツール名 JSON-RPCを構築する主体(≒エージェント)
Cursor Assistant 機構。モデル(Claudeなど)と対話し、MCP操作を判断して送信
Windsurf Agent クラス。LLMの応答に応じて tool_call を構築し、MCPサーバに送る
Cline LLM Engine による対話ループ内で構築。MCP tools/call を送る

🧠 流れを分解してみましょう(CursorのようなAIエディタ)

[ユーザー] → 「このPR、まとめて」
         ↓
[AIエージェント(LLM)]
→ ClaudeやGPTが推論:「resources/list を呼んでPR一覧を取得すべき」と判断
         ↓
[エージェントコード(例: TypeScript)]
→ JSON-RPCで以下を構築:
   {
     "jsonrpc": "2.0",
     "method": "resources/list",
     "id": 1
   }
         ↓
[HTTP POST] → /messages に送信(mcp-stdio-proxy経由でgithub-mcp-serverへ)

✅ JSON-RPCを「構築する」ロジックはどこにある?

  • AIモデル(Claudeなど)は「こういう操作をすべき」と提案する。
  • **その提案を受けて、AIエディタ内部の「エージェント層(ツール呼び出し層)」がJSON-RPCを組み立てて送る。

ClaudeやGPTが「完全なJSON-RPC」を出すとは限りません。
多くの場合:

  1. LLMが「目的」や「使用すべきツール(tool名)」を出す
  2. エージェントがそれをもとにMCP用のJSON-RPCを組み立てる

🔧 たとえば Windsurf の例

LLMから以下のような出力がある:

{
  "tool": "resources/list",
  "input": {}
}

すると、Windsurfのコード側で以下のように構築します:

const jsonrpcPayload = {
  jsonrpc: "2.0",
  id: uuidv4(),
  method: "resources/list",
  params: {}
}

✅ 結論(もう一度)

  • Claudeなどの LLMが「どのMCPメソッドを呼ぶべきか」を提案
  • それをもとに、AIエディタ側のコード(エージェントロジック)がJSON-RPCを構築して送信
  • つまり、「構築者」は人間とLLMの間にいる“AIエージェント層”

🧭 もし補足したい観点があれば

  • Claudeにtool呼び出しを促すプロンプト設計
  • LLMが生成するtool呼び出し形式の仕様(tool calling DSL)
  • JSON-RPCメッセージとMCPスキーマの接続点

なども説明できます。どこを深掘りしましょうか?

「AIエージェント」または「アシスタント」とは何者?

とても本質的で鋭い質問です。
AIエディタ内に登場する「AIエージェント」「アシスタント機構」「Agentクラス」「LLM Engine」などは、曖昧に語られることが多いですが、**実体としては明確な「アーキテクチャ上の中間層」**です。

以下、それぞれ具体的に解説します。


✅ 一言でいうと

「AIエージェント」や「アシスタント機構」とは、LLMの出力をもとに“外部ツールを呼ぶ判断・実行”を行う、AIエディタの中核的な制御層です。

これは「LLMの応答」と「実際のアクション実行」をつなぐ、中間制御層です。


🧠 全体構造(イメージ)

[ユーザー]
   ↓         ⬅ プロンプト(自然言語)
[LLM (Claude, GPT-4 etc.)]
   ↓         ⬅ 「PR一覧を取得したほうがいい」
[AIエージェント / Assistant / Agentクラス]
   ↓         ⬅ JSON-RPCに変換
[MCPサーバ or 外部ツール(GitHub MCPなど)]
   ↓
[ユーザーに結果を返す(コード変更・説明 etc)]

📦 各エディタにおける実装の実体

### 1. Cursor

  • Assistant 機構と呼ばれている。
  • LLMとのやりとり、tool callの判定、呼び出し、結果反映、を行うクラス群。
  • JSON-RPCやMCPの詳細は、バックエンドで mcp-stdio-proxy や GitHub MCPサーバに委譲される。

🔍 実装:
apps/assistant/src/agent.ts, apps/assistant/src/session.ts などに該当


2. Windsurf

  • Agent クラスという実体があり、以下を担う:

    • LLMとのやりとり(messagesの蓄積・送信)
    • LLMが tool_call を返した場合、それを構造的に解釈
    • MCPの JSON-RPC を構築して /messages に送信
const message = await agent.next(userInput);
if (message.toolCall) {
  await callToolViaMCP(message.toolCall);
}

🔍 実装例:
agent.ts


3. Cline

  • LLM Engine という仕組みで、LLMとの対話ループ+ToolCallの判断を行う。
  • Claudeなどの出力を読み取って、「GitHubのPR一覧が必要」と判断すれば resources/list を呼びに行く。

🔍 実装例:
cli/engine/llm.ts


✅ 共通点まとめ

機能 説明
LLMとの会話 Claudeなどとプロンプトを通じてやり取り
推論結果の解釈 LLMの出力が「Toolを呼ぶべき」と言っていたら判断
実行 JSON-RPCのMCPメッセージを作って送る(POST /messages)
状態管理 ユーザーとの会話履歴、現在のセッション状態などを保持

💬 よくある誤解:LLMがすべてやってるわけではない

Claude や GPT は「実行すべきアクションの意図」を自然言語 or structured data(ToolCall)で出すだけです。
**実際に外部ツールを呼んだり、MCP形式に整形して送るのは「AIエージェント層」**の責務です。


✅ まとめ

概念 実体 役割
AIエージェント Assistant(Cursor)、Agent(Windsurf)、LLM Engine(Cline)など LLMの出力を解釈し、MCPや外部ツールを実行する制御層
LLM Claude, GPTなど 意図・アクションを自然言語またはstructured dataで提案する
MCPサーバ github-mcp-serverなど 外部世界(GitHub)に実際にアクセスする

必要であれば、Claudeなどが返す ToolCallの例 や、**「エージェント層がどうやってtoolを呼ぶべきと判断するか」**などの内部ロジックも説明できます。興味ありますか?

ta.toshiota.toshio

脱線その2

./github-mcp-server stdioの起動タイミング考察

CursorCline, Windsurf などの AIエディタ系ツールが ./github-mcp-server stdio のような MCP準拠の stdio バックエンド をどのタイミングで起動するかについて、現在わかっている範囲で実行モデルと設計意図を解説します。


✅ 結論:セッション開始時 or 必要時にプロセスを起動

たとえば:

  • ファイルを開いたとき
  • GitHubと連携する操作をしたとき(例: PRの要約生成・diff説明・レビュー提案)
  • 会話エージェント(Assistant)を初めて起動したとき

などの**「セッション開始タイミング」で、mcpプロセスを起動**するのが基本です。

🧠 背景:なぜ毎回起動しないのか?

  • ./github-mcp-server stdio は長寿命プロセスとして動く想定
  • 通信は JSON-RPC over stdio なので、ステートフルなセッションが成り立つ
  • 必要なときに起動 → セッション中は維持 → 不要になったら kill

🎯 MCPサーバの起動のトリガーとなる処理

実際のAIエディタ(Cursor等)では、次のようなイベントがトリガーになりえます:

トリガー 説明
ユーザーが GitHub 連携機能を呼び出す ex: PRリスト表示・レビュー内容生成
.cursor/config.json に GitHub MCP の設定がある 起動時にバックエンド起動を予約する
アシスタントが GitHubコンテキストを求める ex: resources/list で現在のPRを取得
ファイル変更に関連する外部ツール呼び出し GitHubのレビューコメントをもとに変更提案など

🔁 起動方法(プロセス管理)

多くのツールは以下のような管理を行っています:

  • 初回リクエスト時にプロセス起動(遅延初期化)
  • stdin / stdout を非同期で接続(パイプやスレッド)
  • プロセスが落ちた場合は再起動
  • エディタ終了時 or セッション切れ時に kill

この部分は内部で goroutine や Node.js の child_process / subprocess モジュールなどで制御されています。


🧪 実装イメージ(疑似コード)

if (!githubMcpProcess) {
  githubMcpProcess = spawn("./github-mcp-server", ["stdio"]);
  setupStdIO(githubMcpProcess);
}
sendToStdin({ jsonrpc: "2.0", method: "resources/list", id: 1 });

🛠️ Windsurf や Cline の場合(例)

これらのツールは設定で どの MCP サーバを使うか を指定可能です:

{
  "mcp": {
    "tools": [
      {
        "command": "./github-mcp-server",
        "args": ["stdio"],
        "kind": "github"
      }
    ]
  }
}

この設定により、github タイプのMCPメッセージが飛んできたときだけ対象サーバを stdio モードで起動する、という仕組みになっています。


✅ まとめ

観点 内容
起動タイミング ユーザー操作またはセッション開始時に起動(on-demand)
接続方式 stdio 経由で JSON-RPC を読み書き
MCPプロセス管理 ツール側で起動・接続・再起動・終了処理を持つ
対象ツール Cursor / Cline / Windsurf などの AIエディタが対象
ta.toshiota.toshio

https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2024-11-05/basic/transports.mdx#http-with-sse

以下は、Model Context Protocol(MCP)仕様「Transports」(プロトコル改訂: 2024-11-05)の日本語要約です:


🚛 MCPのトランスポート方式(通信方式)

MCPでは、クライアントとサーバの通信手段として次の2つを標準定義しています:

1. stdio(標準入出力を用いた通信)

  • クライアントがMCPサーバをサブプロセスとして起動
  • stdin にJSON-RPCメッセージを書き込み、stdoutでレスポンスを受け取る
  • メッセージは改行区切り
  • stderr はログ用途として任意で使用可能
  • stdin/stdout にはMCPメッセージ以外を書き込んではならない

シーケンス図(抜粋)

Client起動 → サーバのstdinに書き込み → サーバはstdoutに返答

🔶 MCPクライアントは、可能であれば stdio をサポートすべき(SHOULD)


2. HTTP with SSE(HTTP + Server-Sent Events)

  • サーバは独立プロセスとして複数のクライアントと接続可能

  • 2つのエンドポイントを提供する必要がある

    1. SSE用GETエンドポイント(クライアントが接続し、サーバからの通知を受け取る)
    2. HTTP POST用エンドポイント(クライアントがMCPメッセージを送信)

接続の流れ:

  1. クライアントが /sse に接続
  2. サーバが endpoint イベントを送り、POST先のURIを通知
  3. 以後、クライアントは通知された /messages?sessionId=xxx にPOST
  4. サーバはレスポンスを event: message としてSSEで送信

シーケンス図(抜粋)

Client → GET /sse  
Server → event: endpoint(POST先を通知)  
Client → POST /messages  
Server → SSE message  

セキュリティ上の注意:

  • DNSリバインディング攻撃対策として Origin ヘッダーの検証を 必須(MUST)
  • ローカル環境では 0.0.0.0 ではなく 127.0.0.1 にバインドすべき(SHOULD)
  • 適切な認証も導入すべき(SHOULD)

3. Custom Transports(独自の通信手段)

  • 実装者は、要件に応じて独自の通信方式を実装してもよい(MAY)

  • 条件:

    • JSON-RPCフォーマットとMCPのメッセージライフサイクルを維持すること
    • 接続方式やメッセージ交換方法をドキュメント化して互換性を保つこと

✅ 要点まとめ

項目 内容
標準方式 stdio(LLMやCLI向け)、HTTP + SSE(Web向け)
クライアントに推奨 stdio を優先(SHOULD)
HTTP + SSE の構成 /sse → 接続、/messages → メッセージ送信
セキュリティ Originチェック、localhostバインド、認証を推奨
カスタム対応 可能。JSON-RPC形式を守ることが条件
ta.toshiota.toshio

✅ Model Context Protocol(MCP)における /sse/messages 通信のまとめ


1. 🎯 関係者の役割分担

構成要素 実体例 役割
LLM(AIモデル) Claude, GPT-4, Gemini など ユーザーの指示を解釈し、「どのツールを使うべきか」を自然言語 or structured outputで出力
AIエージェント層 CursorのAssistant、WindsurfのAgentなど LLM出力を受け取り、MCPメッセージを組み立て、HTTPリクエストを行うロジック層
クライアントアプリ(UI) AIエディタ、CLIなどの実行アプリケーション /sseへの接続、SSEの受信、/messagesへのPOSTを制御
MCPサーバ github-mcp-serverなど MCPメッセージを受け取り、外部リソース(GitHub等)と連携して応答
mcp-stdio-proxy 中継サーバ POSTSSEの橋渡し(HTTP ↔ 外部プロセスのstdin/stdout)

2. 🔁 通信の全体フロー(ステップ順)

① セッション開始(/sse)

タイミング 処理内容
ユーザーが何らかの操作を行う クライアント(AIエディタなど)が GET /sse を発行
mcp-stdio-proxyが応答 event: endpoint として /messages?sessionId=abc123 を返す
クライアント このセッションID付きの /messages を記憶し、今後のPOST先にする

② ツール呼び出し判断とメッセージ送信(/messages)

タイミング 処理内容
ユーザーが自然言語で命令(例:「このPRのレビューを書いて」)
クライアントがLLMに問い合わせ ClaudeやGPTが「resources/list を呼ぶべき」と推論(tool_use出力など)
AIエージェントが判断 出力をJSON-RPC形式に変換し、POST /messages?sessionId=abc123 に送信

③ 結果の受信(SSE)

タイミング 処理内容
MCPサーバが処理結果を出力 mcp-stdio-proxy が stdout を読み取り、SSEで event: message を送信
クライアント SSEで受信し、UIに反映(レビュー一覧・要約・編集案など)

3. 🔧 /sse/messages の仕様的根拠(MCP公式)

エンドポイント 説明
/sse MCP仕様により「セッション開始」「イベント受信」のための標準GETエンドポイント
/messages MCP仕様により、endpoint イベントで通知されたセッション専用POST先

🔗 出典:MCP Spec – HTTP with SSE


4. 🧠 補足:LLMと実行の役割の境界

処理 誰が行うか
ツールを使うべきかの判断 LLM(Claude, GPTなど)
具体的なエンドポイントの使用、リクエスト送信 AIエージェント層(コード)
セッション管理、SSE接続維持 クライアントアプリ(UI)

✅ 全体図(簡略化)

User
 ↓
LLM(Claudeなど)
 ↓         ↙ toolを使え
AIエージェント(Assistantなど)
 ↓         ↙ セッションID
GET /sse   ← event: endpoint (/messages?sessionId=X)
 ↓
POST /messages?sessionId=X ← JSON-RPC形式で送信
 ↓
MCPサーバ(github-mcp-server)
 ↓
SSE: event: message
 ↓
クライアントUIに表示

✔️ まとめ:各ステップの対応表

ステップ 誰が何をするか 通信手段
セッション開始 クライアントが /sse に接続 HTTP GET(SSE)
POST先の受信 サーバが event: endpoint を送信 SSE
メッセージ送信 AIエージェントが /messages にPOST HTTP POST
結果受信 サーバが event: message を送信 SSE
ta.toshiota.toshio

flusher.Flush() は、HTTPレスポンスのバッファを強制的にクライアントに送信する処理です。
特に Server-Sent Events(SSE) のようなストリーミング通信で重要になります。


flusher.Flush() の意味

GoのHTTPレスポンスは通常バッファリングされるため、fmt.Fprint(w, ...) などで w に書いても、すぐにはクライアントに届きません。

そこで:

flusher, ok := w.(http.Flusher)
flusher.Flush()

とすることで:

  • 現在までに w.Write() された内容を即時クライアントに送信する
  • 「強制送信」=「Flush(洗い流す)」という動作になる

🧠 なぜ必要か?(SSEでの必須処理)

Server-Sent Events(SSE)はクライアントと常時接続された1本のHTTPストリームです。
クライアントは、イベントが流れてくるたびに即座に受け取りたい

ところがGoのHTTPサーバはデフォルトでレスポンスをバッファリングするため、Flush() を明示的に呼ばないと 送信が遅延する or 届かない という問題になります。


🔁 使用例

fmt.Fprintf(w, "event: message\ndata: hello\n\n")
flusher.Flush() // ← ここで即送信

この組み合わせで、クライアント(ブラウザやエディタ側)は即座にSSEイベントを受け取れます。


✅ 補足:使う前に型チェックする理由

flusher, ok := w.(http.Flusher)
if !ok {
    http.Error(w, "Streaming unsupported", http.StatusBadRequest)
    return
}

これは、whttp.Flusher インタフェースを実装していない場合(≒Flushできないレスポンス)に備えるためです。


✅ まとめ

項目 内容
何をする? http.ResponseWriter に書き込まれた内容をすぐ送信する
どんな時使う? SSEやチャンク転送など「逐次ストリーミング」が必要なとき
なぜ必要? GoはHTTPレスポンスをバッファリングするため、明示的にFlushしないと送信されない

補足が必要であれば:

  • Flush() が効かないケース(例: Content-Lengthの影響)
  • WebSocketとの違い
  • SSEでのブラウザ側の受信例

なども解説できます。ご希望ありますか?

/messagesのときw.Write() だけで Flush() していないのはなぜ?

➤ 答え:

Flush() が必要なのは「ストリーミング通信(SSEなど)」のときだけです。


📦 通常の HTTP レスポンスの場合

w.WriteHeader(http.StatusAccepted)
w.Write([]byte("Accepted"))

このような処理では、**レスポンス全体が一度にまとめて送られる(バッファリングされた)**ため、Flush() を明示的に呼ぶ必要はありません。

🔹 理由:

  • 通常のリクエスト/レスポンス型通信では、GoのHTTPサーバはレスポンス全体が書き終わったと判断すると、自動的にクライアントに送信します。
  • Flush() を使わなくても w.Write() の終了後にバッファは流れます。

🌊 逆に Flush()必要になるケース

状況 必要
SSE(Server-Sent Events) ✅ 必要(都度送信したい)
チャンク転送(Transfer-Encoding: chunked) ✅ 必要(逐次部分送信)
長時間レスポンスを保持する場合 ✅ 必要(一定間隔で送らないとタイムアウト)
通常のHTTPレスポンス(全体で返す) ❌ 不要(Goが自動で送信)

🧪 まとめ:Flush()が必要かどうかの判断

処理 Flush() 必要? 理由
w.Write() + w.WriteHeader() で一括レスポンス 自動送信される
fmt.Fprintf(w, "event: message") in /sse 明示的にクライアントへ即送信する必要がある

✅ まとめ

項目 説明
w.Write(...) レスポンスにデータを書き込む(バッファされる)
Flush() バッファを即座にクライアントに送信する
Flush() が必要な場面 SSEやチャンク応答など、都度送信が必要なとき
なぜ /messages では不要? 普通のHTTP POSTレスポンスだから(レスポンス全体で一度に送る)

もし Flush() の裏側の動作(バッファの実装やチャンクの構造など)にも興味あれば、さらに深堀りできます。どうしますか?