メインコンテンツへスキップ

MCPゲートウェイって何? agentgateway を Docker で最小構成から触ってみた

著者
Yuto Toya

1. はじめに / この記事でやること
#

MCP(Model Context Protocol)サーバーは増えてきましたが、複数をまとめたり、認証・可観測性を一箇所で挟んだりする「ゲートウェイ」を自分で動かしたことはありませんでした。

そこで agentgateway を Docker で最小構成から立て、認証不要のリモート MCP(Langfuse docs MCP)を1つ中継させて、組み込み UI の Playground からツールを実行するところまでを試しました。

Playground でツールを実行した画面
Playground でツールを実行した画面

やってみると、UI が開けない問題と Playground の初期化エラーの2つに引っかかりました。原因と対処は6章にまとめています。

2. そもそも agentgateway とは / MCPゲートウェイの位置づけ
#

agentgateway は、AIエージェントと、その接続先(MCPサーバー・他エージェント・LLM)の間に立つプロキシです。Solo.io 発で、現在は Linux Foundation ホストの OSS(Apache 2.0)。データプレーンは Rust 製です。

機能としては MCPゲートウェイ(今回触るのはこれ)、A2Aゲートウェイ(エージェント間通信)、LLMゲートウェイ(OpenAI互換API・フェイルオーバー・レート制限など)の3つを持ちます。

Solo の製品ファミリの中では、中継役のデータプレーンが agentgateway、ツールを一元管理するカタログが agentregistry、という役割分担になっています。周辺には kagent / Istio / kgateway もいます。

今回はライセンス不要の OSS standalone を使います。Solo Enterprise 版のクイックスタート(AgentgatewayPolicy などの CRD、Helm、license key)とは別物で、そちらの手順は使いません。OSS のデータプレーン本体はキー不要で動きます。

ノート

今回中継する https://langfuse.com/api/mcp は、認証不要で Langfuse のドキュメントを検索・取得できる Langfuse Docs MCP です。Langfuse には認証付きでプロジェクトのトレースなどを扱う Cloud MCP もありますが、別物です。本記事で「Langfuse MCP」と書いているのは前者を指します。詳細は公式 Introduction を参照してください。

3. なぜMCPに専用ゲートウェイが必要なのか
#

手を動かす前に、なぜ MCP に専用ゲートウェイという話が出るのかを整理しておきます。

従来の API ゲートウェイや Envoy 系は、短命な HTTP リクエストをさばくのに最適化されています。リクエストが来て、処理して、返して終わり。

MCP はそこに、1往復では終わらない要素が入ってきます。まず JSON-RPC ベースで、初期化・能力交渉・セッション管理を含むプロトコルです。HTTP では各メッセージが POST で送られますが、initialize で確立したセッション(Mcp-Session-Id)を後続のリクエストが引き継ぐ前提で設計されています。加えて、サーバーからクライアントへ非同期にメッセージを送る経路(SSE によるサーバー起点イベント)も想定されています。SSE やセッションIDが実際にどう使われるかは実装次第です。

誤解しやすいのは、Envoy や一般的な API ゲートウェイでも長寿命接続・HTTP/2・ストリーミング自体は扱える点です。違いは中身を読めるかどうかにあります。従来のゲートウェイは MCP の通信を「ただの HTTP リクエスト」としてしか扱えず、「どのツールが呼ばれたか」を見てツールごとに許可やログを分ける、といったことができません。そこまで踏み込めるのが MCP 向けゲートウェイ、というわけです。

flowchart TB
  subgraph A["従来型:短命リクエストをさばく(API GW / Envoy 系)"]
    direction LR
    ca["Client"] -->|"リクエスト①(独立・短命)"| ga["Gateway"] -->|"さばいて返す"| sa["Server"]
    ca -->|"リクエスト②(①とは無関係)"| ga
  end
  subgraph B["MCP:初期化とセッション管理が入る"]
    direction LR
    cb["Client"] <==>|"Mcp-Session-Id を引き継ぐ"| gb["agentgateway"] <==> sb["MCP Server"]
    sb -.->|"サーバー起点イベント / SSE"| cb
  end

図:従来型は短命リクエストを1回さばいて終わり。MCP では initialize で確立したセッション(Mcp-Session-Id)を後続リクエストが引き継ぎ、サーバーからのイベント経路も想定される。

ゲートウェイを挟むと、複数の MCP サーバーを1エンドポイントに束ねられる(federation / multiplex)、認証・認可・可観測性を1箇所で効かせられる、といった利点も出てきます。

このあとの検証では、initialize の応答で Mcp-Session-Id が返るところが実際に見えます。上の説明はそこで確認できます。

4. アーキテクチャ / 設定の考え方
#

agentgateway の設定は、Bind / Listener / Route / Backend の順に読むと追いやすいです。

階層役割
Bindリッスンする TCP ポート
Listenerホスト名 / プロトコル(HTTP・MCP・A2A)/ TLS
Routeマッチ条件とポリシー
Backend上流ターゲット(MCPサーバー / A2Aエージェント / HTTPサービス / LLM)

今回書いた config.yaml をこの4階層に対応づけると、こうなります。

# yaml-language-server: $schema=https://agentgateway.dev/schema/config
config:
  adminAddr: 0.0.0.0:15000        # ← UIをホストに露出(詳細は6章のハマりどころ)
binds:
- port: 3001                       # ← Bind: リッスンするポート
  listeners:
  - routes:                        # ← Listener / Route
    - policies:
        cors:                      # ← Route のポリシー(ブラウザからの接続用にCORS許可)
          allowOrigins: ["*"]
          allowHeaders: [mcp-protocol-version, content-type, cache-control, mcp-session-id]
          exposeHeaders: ["Mcp-Session-Id"]
      backends:
      - mcp:                       # ← Backend: MCP種別
          targets:
          - name: langfuse-docs
            mcp:
              host: https://langfuse.com/api/mcp   # ← リモート Streamable HTTP の MCP

今回の Backend はリモートの Streamable HTTP MCP(Langfuse docs MCP)です。ローカルで別プロセスを起動する stdio 型ではなく、既に存在するリモート HTTP エンドポイントに中継します。ここが公式サンプル(npx で stdio 起動する例)との違いです。

なお、上は4階層を素直に表す binds 形式(config ファイルで書く legacy スタイル)ですが、後述するように UI の Playground を使うとトップレベルの mcp: セクション(新形式)に落ち着きます(6章②)。最終的に動かした config はそちらなので、再現するなら6章の最終 config を使うのが早いです。

5. 実際に触ってみる(ハンズオン本体)
#

5-1. 環境準備
#

mkdir -p ~/agentgateway-lab && cd ~/agentgateway-lab
docker version    # 筆者環境: Client 29.1.4-rd / Server 29.1.3 (Rancher Desktop の dockerd)

5-2. 設定ファイル作成
#

まず公式の basic 設定を落として構造を把握します(これは stdio(npx) 版のサンプルです)。

curl -L https://agentgateway.dev/examples/basic/config.yaml -o config.basic.yaml

これを土台に、リモート MCP 向けの config.yaml を作ります(全文は4章のとおり)。リモート MCP の target は mcp.host にエンドポイント URL を書くだけです。キー名はバージョンで変わることがあるので、迷ったら公式の JSON スキーマ で確認してください。ただし UI Playground から試す場合は、後述のとおり mcp.port の公開と CORS 設定も必要になります(→ 6章②)。

5-3. Docker で起動
#

docker run -d --name agentgateway-lab \
  -p 127.0.0.1:15000:15000 -p 127.0.0.1:3001:3001 \
  -v "$HOME/agentgateway-lab/config.yaml:/config.yaml" \
  cr.agentgateway.dev/agentgateway:1.3.1 --file /config.yaml

ポート公開はホストの 127.0.0.1 に絞り、LAN の他端末へ露出しないようにしています(意図して全公開する場合だけ -p 15000:15000 にします)。

ノート

本記事の検証は v1.3.1(イメージ digest sha256:c3ce7b75…)です。再現性のためタグを固定しています。最新版や別タグは GitHub リリース で確認して差し替えてください。

起動ログは以下です。

state_manager   loaded config from File("/config.yaml")
app             serving UI at http://0.0.0.0:15000/ui
proxy::gateway  started bind   bind="bind/3001"

5-4. UI / Playground で MCP に接続する
#

  1. ブラウザで http://localhost:15000/ui を開きます。Gateway Overview が表示されます。この時点では MCP は “Not enabled”、config で入れた MCP は Routes に “legacy MCP backend” として並んでいます(UI 管理の新形式とは別です)。

Gateway Overview(MCP は Not enabled)
Gateway Overview(MCP は Not enabled)
Routes に legacy MCP backend
Routes に legacy MCP backend

  1. MCP の「Get started」→「Enable MCP」を押します。UI が config.yaml を自動更新し、トップレベルに mcp: セクションが追加されます。

  2. 「MCP Servers」→「Add server」を開きます。Transport = Streamable HTTP、URL = https://langfuse.com/api/mcp、name = langfuse-docs で保存します。一覧に State: ready で並びます。

MCP Servers に langfuse-docs が ready
MCP Servers に langfuse-docs が ready

  1. 「Tool Playground」を開き、「Initialize」でセッションを張ります。“3 tools discovered” となり、Session ID が発行されます。

Playground: Initialize 成功 / 3 tools discovered
Playground: Initialize 成功 / 3 tools discovered

5-5. ツール実行
#

searchLangfuseDocs を選び、query に How do I set up tracing in Python? を入れて「Call tool」を押します。Result カードに HTTP 200 と、Langfuse docs 由来の応答(LangChain Tracing & LangGraph Integration など)が返ってきました。

searchLangfuseDocs の Tool output(HTTP 200)
searchLangfuseDocs の Tool output(HTTP 200)

CLI でも同じ流れを確認できます。MCP のライフサイクルでは、initialize の成功後にクライアントが notifications/initialized を送り、以降のリクエストでは initialize で得た Mcp-Session-IdMCP-Protocol-Version ヘッダを引き継ぎます。これを渡さないとセッションが成立しません。

# 1) initialize してレスポンスヘッダの mcp-session-id を控える
curl -sS -X POST http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' -D -

# 2) 初期化完了を通知(同じ Mcp-Session-Id / MCP-Protocol-Version を付ける)
curl -sS -X POST http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Session-Id: <1で返った値>' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3) 後続リクエスト(tools/list)も同じヘッダを引き継ぐ
curl -sS -X POST http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Session-Id: <1で返った値>' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

返ってくる tools/list の3ツールは次のとおりです。

  • searchLangfuseDocs — Langfuse docs 全体へのセマンティック検索(RAG)
  • getLangfuseDocsPage — 指定パスの docs ページの生 Markdown を取得
  • getLangfuseOverview — 高レベルの機械可読インデックスを取得

ここまでで、Langfuse Docs MCP への中継と、ゲートウェイ越しのツール実行を確認できました。

6. ハマったところ / トラブルシュート
#

今回詰まったのは主に2点です。

① UIが開かない(HTTP 000)→ adminAddr で解決
#

-p 127.0.0.1:15000:15000 で公開したのに、http://localhost:15000/ui が HTTP 000(接続失敗)でした。

$ curl -o /dev/null -w "HTTP %{http_code}\n" http://localhost:15000/ui
HTTP 000

ログを見ると原因が分かりました。

listener established  address=127.0.0.1:15000  component="admin"

UI/admin がコンテナ内の 127.0.0.1(localhost)にバインドされていたのです。Docker のポートフォワードはコンテナ内 localhost には届かないので、ホストのブラウザからは見えません。

--help を見ても UI アドレスを変えるフラグはありません(config 指定は -f/--file で正しかった)。そこで再び JSON スキーマを確認したところ、config.adminAddr"ip:port" / "localhost:port" / "off" を取る)というフィールドがありました。デフォルトが localhost だったわけです(ソースの config.rs でも Address::Localhost(.., 15000)、Helm の standalone チャートは 0.0.0.0:15000 を明示していました)。

対処は config トップに1ブロック追加するだけです。

config:
  adminAddr: 0.0.0.0:15000

再起動すると serving UI at http://0.0.0.0:15000/ui に変わり、UI が HTTP 200(<title>agentgateway</title>)で開けました。

警告

この記事では安全側に寄せて、docker のポート公開をホストの 127.0.0.1 に絞り、CORS の allowHeaders も必要なヘッダだけに限定しています。ただし UI の「Apply CORS」は実際には allowHeaders: ["*"] のようなワイルドカードを入れるので、UI 任せにするときは注意してください。共有環境・本番では、allowOrigins を実際のオリジンに絞り、adminAddr も必要に応じて 127.0.0.1 にするなど、公開面をさらに絞ってください。この localhost バインド問題は、後編(k8s)での露出設計(LB / port-forward)にも繋がります。

② Playground の Initialize が Method Not Allowed(405)で失敗
#

UI で Enable MCP → Add server まで進め、いざ Playground で「Initialize」を押すと失敗しました。

{"detail":"Method Not Allowed"}
# ブラウザ console:
POST http://localhost:3000/mcp  405 (Method Not Allowed)

ブラウザが localhost:3000/mcp を叩いています。ここで2つの事実が噛み合っていました。

  1. Enable MCP を押したとき、UI が config に mcp: { port: 3000, ... } を自動で足していました。しかもこの 3000 は Docker で公開していないポートです(何もなければブラウザからの接続そのものが失敗します。筆者環境ではたまたま別サービスが 3000 を使っていたため、その応答として 405 が返っていました)。
  2. Playground はブラウザから mcp.port のリスナーを直接叩く設計です。つまり mcp.port は「Docker で公開済み」かつ「他サービスと衝突しない」ポートでなければなりません。

さらに Tool Playground を開いた時点で “Browser access is not allowed” の警告も出ます。これは「Apply CORS」ボタン一発で、mcp セクションの CORS に http://localhost:15000 許可と Mcp-Session-Id の expose が入ります。

最終的に、config を UI 新形式の mcp: 一本に整理し、mcp.port を Docker 公開済みの 3001 に変更(legacy な binds は削除)して解決しました。これが動作確認済みの最終形です。

# yaml-language-server: $schema=https://agentgateway.dev/schema/config
config:
  adminAddr: 0.0.0.0:15000      # UIをホストへ露出(検証用。本番は 127.0.0.1 を検討)
mcp:
  port: 3001                    # docker 公開済みポート(未公開だと Playground から届かない)
  targets:
  - name: langfuse-docs
    mcp:
      host: https://langfuse.com/api/mcp
  policies:
    cors:                       # Playground(ブラウザ)からの接続用。必要なヘッダだけ許可
      allowOrigins: [http://localhost:15000]
      allowHeaders: [content-type, accept, mcp-protocol-version, mcp-session-id]
      allowMethods: [GET, POST]
      exposeHeaders: [Mcp-Session-Id]
重要

この検証で注意が必要だったのは、UI 操作が config.yaml を直接書き換える点です(file watch で即反映されます)。手編集と UI 編集を混ぜると混乱するので、どちらかに寄せるのが安全です。また config ファイル方式の “legacy backend” と UI 新形式の mcp: は別管理で、Playground は後者しか見ません。再現する場合は、最初から本節の最終形(mcp: セクション)だけを使い、4章の binds 形式は試行錯誤の参考として読むのがおすすめです。

原因→対処 早見表

症状原因対処
:15000/ui が HTTP 000UI/admin が localhost バインドconfig.adminAddr: 0.0.0.0:15000
Playground Initialize が 405/接続失敗mcp.port が未公開/衝突ポートmcp.port を公開済みの空きポートへ
Playground “Browser access not allowed”mcp の CORS 未設定UI の「Apply CORS」/ config に cors ポリシー
config parse エラーtarget のキー名がバージョン不一致JSON スキーマで正しいキーを確認
unknown flagCLIフラグ違い--help(config は -f/--file

7. わかったこと / 知見まとめ
#

一番の収穫は、initialize 後にセッションID(Mcp-Session-Id)を引き継ぐ挙動を実際に目で見られたことです。3章で書いた「MCP を単発の HTTP リクエストだけで扱いにくい理由」が、机上の説明ではなく実測として腑に落ちました。

動かした感触としては、OSS standalone はかなり手軽でした。イメージ 81MB・数秒起動、config.yaml 一枚でリモート MCP を中継できます。UI の Playground もツール一覧・セッションID・レスポンスをその場で確認でき、curl だけで追うより挙動を把握しやすかったです。

引っかかったのは、config のキー名がバージョンで変わる点です。公式 docs だけを見ていると古い記述に当たることがあり、JSON スキーマやソースを一次情報として当たる場面が何度かありました。ドキュメントとコードの乖離は、使うなら覚悟しておいたほうがよさそうです。

複数の MCP をまとめたい、認証・ログをゲートウェイ側で挟みたい、といった用途なら十分使えそうです。ただ本番投入となると、バージョン差分や設定変更への追従はそれなりに手間がかかります。

8. 次にやりたいこと
#

  • 複数MCPの集約(multiplex)mcp.targets に target を足すだけで、1エンドポイントに複数MCPを束ねられます。ツール名はサーバー名でプレフィックスされ衝突回避されます。
  • 認証付き(OAuth/JWT)MCP をゲートウェイで保護する。
  • LLMゲートウェイ機能(OpenAI互換API・フェイルオーバー・レート制限)を試す。
  • 次回は、ゲートウェイ層を通る MCP / LLM 呼び出しを Langfuse でどこまで観測できるかを確認します(後編に続きます)。

9. 参考リンク
#