こんにちは。ジェダイパンくず🍞 です。
今年 (2026 年) は、「エージェントの通信をまとめて扱う gateway」が製品カテゴリとして立ち上がってきました。Kong は 4 月に AI Gateway 3.14 で Agent-to-Agent (A2A) のトラフィックに対応し、API7 は 8 月に A2A 1.0 を gateway でどう扱うかの記事を公開し、Maxim も 10 月に自社の Bifrost へ Agent Gateway を追加しています。そこで今回は OSS の agentgateway を使って、A2A・MCP・LLM の 3 種類のトラフィックを 1 つの gateway に通すサンプル環境を作り、手を動かしてみました。
動作確認のために書いたコードは下記に置いてあります。
https://github.com/jedipunkz/agent-gateway-playground
検証したバージョンは agentgateway v1.7.0、a2a-go v2.8.0 (A2A v1.0)、MCP Go SDK v1.8.0 です。環境は Linux で、Docker は使っていません。
Agent Gateway とは
AI エージェントが外とやりとりする通信は、大きく次の 3 種類に分けられます。
| 通信 | プロトコル | gateway が中身として見るもの |
|---|---|---|
| エージェント → LLM | OpenAI / Anthropic などの API | モデル名、入出力の token 数、プロンプト |
| エージェント → tool | MCP (JSON-RPC) | tools/list や tools/call などのメソッド、tool 名、引数 |
| エージェント → エージェント | A2A (JSON-RPC / REST / gRPC) | Agent Card、SendMessage などのメソッド、task の状態、context ID |
一般的な API gateway は主に HTTP のパスやヘッダを見て振り分けます。しかしこれらの通信はどれも中身を JSON-RPC や独自の JSON の本文で表すので、パスだけでは「どの tool を呼んだか」「どのエージェントのどの task か」が分かりません。Agent Gateway と呼ばれている製品は、これらのプロトコルを解釈して、その単位で認可・制限・観測をするものです。
参考にした各社の記事やドキュメントを読んで、A2A の扱いを比べると次のようになります。agentgateway 以外はドキュメントを読んだだけで、動かしてはいません。
| 製品 | A2A の扱い |
|---|---|
| agentgateway | OSS (Apache-2.0、Rust)。route に a2a policy を付けると Agent Card の URL を書き換え、メソッドや task の状態をログに出す。A2A v0.3 と v1.0 の両方の形式を解釈する。MCP と LLM も同じバイナリで扱う |
| Kong AI Gateway | 3.14 で AI A2A Proxy plugin が追加された。A2A のリクエストを検出して task のメタデータを抜き出し、Agent Card の URL を書き換える。認証は既存の認証 plugin を使う |
| API7 AISIX | エージェントごとに /a2a/<agent> の endpoint を作る。エージェントごとに A2A 1.0 か 0.3 を固定し、バージョン間の変換はしない。既定は拒否 |
| Bifrost (Maxim) | 2026 年 10 月に Agent Gateway を追加。Agent Card をリクエストごとにエージェントから取り直し、endpoint と認証の情報を書き換えて返す |
どの製品も「Agent Card の URL を gateway に書き換えて、以降の呼び出しを gateway に向けさせる」という同じ発想で作られています。API7 の記事 A2A Protocol 1.0 at the Gateway では、A2A 1.0 で署名付き Agent Card やテナントのルーティングが仕様に入ったと書かれていました。一方で「どの署名者を信頼するか」「どの入力でテナントを決めるか」は運用者が決めることであり、その決定を強制する場所が gateway だ、というのがこの記事の主張です。
サンプル環境の構成
エージェント、MCP server、LLM はすべて Go で書きました。LLM は OpenAI 互換の API で応答する fake LLM です。
flowchart LR
C[client] -->|A2A :3000| GW
subgraph GW[agentgateway]
A2A[routes<br/>a2a policy]
MCP[mcp<br/>jwtAuth / mcpAuthorization / mcpGuardrails]
LLM[llm<br/>apiKey / localRateLimit]
end
A2A --> P[planner agent]
P -->|A2A :3000| A2A
A2A --> W[worker agent]
W -->|MCP :3001| MCP
W -->|OpenAI API :4000| LLM
MCP --> D[mcpserver docs]
MCP --> O[mcpserver ops]
LLM --> F[fakellm]
GW -. OTLP/HTTP .-> T[traceview :4318]
planner は受け取ったメッセージを worker に転送するだけのエージェントで、worker は MCP の tool と LLM を呼んで返事を作ります。planner から worker への呼び出しも gateway を経由させています。
| パス | 内容 |
|---|---|
cmd/agent |
A2A v1.0 (JSON-RPC) エージェント。-peer で転送役、-mcp / -llm で tool と LLM を呼ぶ。-send で client として動く |
cmd/mcpserver |
Streamable HTTP の MCP server |
cmd/fakellm |
OpenAI 互換の /v1/chat/completions を返す fake LLM |
cmd/jwt |
シナリオ用の ES256 鍵と JWT を作る |
cmd/mcpcall |
MCP の tools/list と tools/call を行う client |
cmd/traceview |
OTLP/HTTP で受けた span をツリー表示する簡易 backend |
scenarios/ |
シナリオごとの gateway 設定と実行スクリプト |
agentgateway は GitHub Releases のバイナリをそのまま使います。scripts/setup.sh がバイナリの取得と Go のビルドを行い、各シナリオは run.sh を実行するだけで再現できます。
./scripts/setup.sh
./scenarios/01-a2a/run.sh
設定ファイルの形式
agentgateway の設定ファイルには、listener と route を入れ子で書く binds 形式と、gateway・route・MCP・LLM をトップレベルに並べる gateways 形式があります。公式の example には binds 形式のものも多いのですが、binds には待ち受けアドレスを指定する項目が無く、全インターフェースで listen します。今回は gateways.*.bindAddress で 127.0.0.1 を指定できる gateways 形式に統一しました。readiness (:15021) と metrics (:15020) も既定では全インターフェースで listen するので、config で loopback に限定しています。
config:
# 未指定だと agentgateway が鍵を生成してこのファイルに書き込むため、環境変数で渡す
session:
key: $SESSION_KEY
readinessAddr: 127.0.0.1:15021
statsAddr: 127.0.0.1:15020
gateways:
a2a:
port: 3000
bindAddress: 127.0.0.1
config.session.key はセッション ID の暗号化に使う鍵で、未指定のまま起動すると agentgateway が鍵を生成して設定ファイルに追記します。気付かずに commit すると鍵がリポジトリに入るので注意が必要です。値に $SESSION_KEY と書けば環境変数から読まれます。環境変数が無い場合は起動時にエラーになります。
シナリオ 01: A2A エージェントを gateway の配下に並べる
planner と worker の 2 体を、1 つの gateway の配下に /planner と /worker のパスで並べます。route に a2a: {} を付けると、その route は A2A のトラフィックとして扱われます。
routes:
- name: planner
gateways: a2a
matches:
- path:
pathPrefix: /planner
policies:
urlRewrite:
path:
prefix: /
a2a: {}
backends:
- host: 127.0.0.1:9302
- name: worker
gateways: a2a
matches:
- path:
pathPrefix: /worker
policies:
urlRewrite:
path:
prefix: /
a2a: {}
backends:
- host: 127.0.0.1:9301
エージェントの実装は a2a-go の JSON-RPC handler に、受け取ったテキストを処理する関数を渡すだけです。Agent Card には自分自身の URL (http://127.0.0.1:9302/invoke) を書いています。
(Agent Card とは A2A エージェントが「自分は何者でどこにどう話しかければよいか」を書いて公開する jSON です)
card := &a2a.AgentCard{
Name: *name,
Description: *name + " agent",
// gateway はこの URL を自分の URL に書き換えて返す (agent card rewrite)
SupportedInterfaces: []*a2a.AgentInterface{
a2a.NewAgentInterface("http://"+*addr+"/invoke", a2a.TransportProtocolJSONRPC),
},
// ...
}
mux := http.NewServeMux()
mux.Handle("/invoke", a2asrv.NewJSONRPCHandler(a2asrv.NewHandler(exec)))
mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(card))
Agent Card の書き換え
backend から直接取った Agent Card と、gateway 経由で取った Agent Card を比べます。
$ curl -s http://127.0.0.1:9302/.well-known/agent-card.json | jq '.supportedInterfaces'
[
{
"url": "http://127.0.0.1:9302/invoke",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
]
$ curl -s http://127.0.0.1:3000/planner/.well-known/agent-card.json | jq '.supportedInterfaces'
[
{
"url": "http://127.0.0.1:3000/planner/invoke",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
]
gateway 経由では url が http://127.0.0.1:3000/planner/invoke に書き換わっています。A2A の client は Agent Card に書かれた URL にメッセージを送るので、最初に gateway から Agent Card を取りさえすれば、以降の呼び出しも gateway を通ります。agentgateway は A2A v1.0 の supportedInterfaces[].url と v0.3 の url の両方に対応していて、urlRewrite でパスを書き換えている場合も、それを考慮した URL に直してくれます。
メッセージを送る
client から planner にメッセージを送ると、planner が gateway 経由で worker に転送し、worker の返事が返ってきます。
$ bin/agent -name client -send http://127.0.0.1:3000/planner "checkout"
[planner] [worker] received "checkout"
gateway のアクセスログです (一部抜粋しています)。
route=default/planner http.method=GET http.path=/planner/.well-known/agent-card.json http.status=200 protocol=a2a duration=0ms
route=default/worker http.method=GET http.path=/worker/.well-known/agent-card.json http.status=200 protocol=a2a duration=0ms
route=default/worker http.method=POST http.path=/worker/invoke http.status=200 protocol=a2a a2a.method=SendMessage a2a.response.outcome=success a2a.result.kind=message a2a.context.id=01a1d56f-d37b-7d59-a75a-092092397b75 duration=0ms
route=default/planner http.method=POST http.path=/planner/invoke http.status=200 protocol=a2a a2a.method=SendMessage a2a.response.outcome=success a2a.result.kind=message a2a.context.id=01a1d56f-d37a-7a99-b1f4-4f1d5f1237de duration=1ms
a2a.method=SendMessage が A2A v1.0 のメソッド名です (v0.3 では message/send)。応答の種類 (message か task)、task の状態、エラーコード、context ID もログに出ます。
ただし context ID は planner と worker で別の値になっています。A2A の context ID は会話の単位で、planner が worker に新しいメッセージを送ると、worker 側では別の会話として扱われるためです。エージェントをまたいで 1 つのリクエストを追うには、後述のシナリオ 04 の trace を使います。
a2a-go で path 付きの URL を扱う
gateway の配下にパスで並べたエージェントを a2a-go の client から呼ぶとき、一点つまずきました。agentcard.Resolver.Resolve() に http://127.0.0.1:3000/planner を渡すと 404 になります。a2a-go は base URL のパスが空か / のときだけ /.well-known/agent-card.json を付け、パスがあればその URL を Agent Card の URL そのものとみなすためです。WithPath で明示すれば解決します。
card, err := agentcard.NewResolver(hc).Resolve(ctx, cardURL, agentcard.WithPath(a2asrv.WellKnownAgentCardPath))
シナリオ 02: MCP server を束ねて tool 単位で認可する
docs と ops という 2 つの MCP server を、1 つの endpoint (:3001/mcp) に束ねてみます。agentgateway は複数の MCP server を 1 つの MCP server に見せる multiplex の機能を持っていて、束ねた後の tool 名は <target 名>_<tool 名> の形になります。
認可には JWT の claim を使います。JWT は cmd/jwt で作り、role claim に viewer か operator を入れています。読み取り系の tool (search_docs、get_status) は誰でも使え、restart_service は operator だけが使える、というルールです。
mcp:
gateways: mcp
policies:
jwtAuth:
mode: strict
providers:
- issuer: agent-gateway-playground
audiences: [playground]
jwks:
file: ./jwt/jwks.json
# tool 単位の認可。判定に使えるのは tool 名・target・JWT claim など (引数は見えない)
mcpAuthorization:
rules:
# 有効な JWT があれば読み取り系 tool は使える
- allow: 'mcp.tool.name in ["search_docs", "get_status"]'
# restart_service は operator だけ
- allow: 'jwt.role == "operator" && mcp.tool.name == "restart_service"'
targets:
- name: docs
mcp:
host: http://127.0.0.1:9201/mcp
- name: ops
mcp:
host: http://127.0.0.1:9202/mcp
run.sh の出力です。
## token なし
connect: calling "initialize": sending "initialize": Unauthorized
## viewer: tools/list と restart_service
tool: docs_search_docs
tool: ops_get_status
call: calling "tools/call": sending "tools/call": unknown resource: rejected by transport: Bad Request
## operator: restart_service (checkout)
tool: docs_search_docs
tool: ops_get_status
tool: ops_restart_service
result (isError=false): checkout: restarted
viewer の tools/list には ops_restart_service が出てきません。許可されない tool は一覧から消えるので、エージェントはそもそも呼べる tool として認識しません。それでも名前を指定して呼ぶと 400 で拒否されます。ログには誰が (jwt.sub) どの target のどの tool を呼んで、なぜ拒否されたのかが残ります。
http.status=401 protocol=http error="authentication failure: no bearer token found" reason=JwtAuth
http.status=400 jwt.sub=alice protocol=mcp mcp.method.name=tools/call mcp.target=ops mcp.resource.type=tool gen_ai.tool.name=restart_service error="mcp: authorization denied for tool: ops_restart_service" reason=Authorization
http.status=200 jwt.sub=bob protocol=mcp mcp.method.name=tools/call mcp.target=ops mcp.resource.type=tool gen_ai.tool.name=restart_service
mcpGuardrails
引数を見るには mcpGuardrails を使います。guardrail は MCP のリクエストやレスポンスを CEL 式で検査し、拒否 (reject) や書き換え (transform) ができる機能です。
# 引数を見た制御は guardrail で行う。payments の再起動は operator でも拒否する
mcpGuardrails:
processors:
- kind: expression
methods: {"tools/call": request}
condition: >-
mcp.tool.target == "ops" &&
mcp.params.name == "restart_service" &&
has(mcp.params.arguments.service) &&
mcp.params.arguments.service == "payments"
reject: "restarting payments requires a change request"
## operator: restart_service (payments) -> guardrail
result (isError=true): restarting payments requires a change request
http.status=200 jwt.sub=bob protocol=mcp mcp.method.name=tools/call mcp.target=ops mcp.resource.type=tool gen_ai.tool.name=restart_service error="mcp: mcpGuardrails rejected: restarting payments requires a change request" reason=MCP
認可で拒否された場合は HTTP 400 になり、client 側では tools/call の呼び出し自体が失敗しました。一方、guardrail で拒否された場合は HTTP 200 で、tool の結果として isError: true とメッセージが返ります。エージェントから見ると「tool を呼んだら失敗した」という扱いになるので、LLM は理由を読んで別の手を考えられます。
シナリオ 03: LLM に API key 認証と token rate limit をかける
fake LLM を OpenAI 互換の custom provider として登録し、team ごとに API key を発行して、team ごとの token 上限 (1 分あたり 40 token) を設定します。
llm:
gateways: llm
policies:
apiKey:
mode: strict
location:
header:
name: authorization
prefix: 'Bearer '
keys:
- key: $TEAM_A_KEY
metadata:
team: team-a
- key: $TEAM_B_KEY
metadata:
team: team-b
# team ごとに 1 分あたり 40 token まで
localRateLimit:
- type: tokens
maxTokens: 40
tokensPerFill: 40
fillInterval: 60s
key: apiKey.team
models:
- name: fake
provider:
custom:
formats:
- type: completions
params:
baseUrl: http://127.0.0.1:9100/v1
model: fake-1
key に付けた metadata は CEL から apiKey.team として参照できるので、rate limit の bucket を team 単位にしています。client は model: fake を指定し、gateway が fake-1 に置き換えて fake LLM に送ります。
## key なし
401 authentication failed
## team-a を 5 回 (上限 40 token/分)
200 {"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}
200 {"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}
200 {"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}
200 {"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}
429 rate limit exceeded
## team-b (別 bucket)
200 {"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}
1 回 12 token なので、上限が 40 token なら 3 回で止まりそうですが、実際には 4 回目まで通って 5 回目で 429 になりました。token 数はレスポンスを受け取るまで分からないので、リクエストの時点では残りがあるかだけを見て通し、レスポンスの token 数を後から差し引いているようです (ソースまでは確認していません)。上限は「1 リクエスト分はみ出しうる目安」として設定する必要があります。
アクセスログと metrics には、OTel GenAI semantic conventions の名前で token 数が出ます。前回の記事 ではエージェントごとに token 数の属性名がばらばらでしたが、LLM の呼び出しを gateway に通せば、どのエージェントから呼んでも同じ名前で集計できます。
http.status=200 protocol=llm gen_ai.operation.name=chat gen_ai.provider.name=custom gen_ai.request.model=fake-1 gen_ai.response.model=fake-1 agw.ai.original_model=fake gen_ai.usage.input_tokens=5 gen_ai.usage.output_tokens=7
http.status=429 protocol=llm agw.ai.original_model=fake error="rate limit exceeded" reason=RateLimit
agentgateway_gen_ai_client_token_usage_sum{gen_ai_token_type="input",gen_ai_operation_name="chat",gen_ai_system="custom",gen_ai_request_model="fake-1",gen_ai_response_model="fake-1"} 25.0
agentgateway_gen_ai_client_token_usage_sum{gen_ai_token_type="output",gen_ai_operation_name="chat",gen_ai_system="custom",gen_ai_request_model="fake-1",gen_ai_response_model="fake-1"} 35.0
シナリオ 04: エージェントをまたいだ trace
最後に、01〜03 の設定を 1 つの gateway プロセスにまとめ、client → planner → worker → MCP / LLM の流れを 1 本の trace で見ます。gateway の設定には trace の送り先を足すだけです。
config:
tracing:
otlpEndpoint: http://127.0.0.1:4318
otlpProtocol: http
randomSampling: "true"
エージェント側は、受けるリクエストを otelhttp.NewHandler で包み、送るリクエストは otelhttp.NewTransport を通して、traceparent ヘッダを伝搬させます。A2A の client にも MCP の client にも *http.Client を渡せるので、エージェントのコードに trace の処理はほとんど入りません。
func Client(header, value string) *http.Client {
var rt http.RoundTripper = otelhttp.NewTransport(http.DefaultTransport)
if value != "" {
rt = headerRT{rt, header, value}
}
return &http.Client{Transport: rt}
}
trace の backend には、OTLP/HTTP で受けた span をツリー表示するだけの cmd/traceview を書いて使いました。実行結果です。
trace 060470cb44a14a16e8ec4c68974468c4 (39 spans)
- [client] send http://127.0.0.1:3000/planner
- [client] HTTP GET
- [agentgateway] GET /planner/*
- [agentgateway] GET
- [planner] GET /.well-known/agent-card.json
- [client] HTTP POST
- [agentgateway] POST /planner/* a2a.method=SendMessage
- [agentgateway] POST
- [planner] POST /invoke
- [planner] HTTP GET
- [agentgateway] GET /worker/*
- [agentgateway] GET
- [worker] GET /.well-known/agent-card.json
- [planner] HTTP POST
- [agentgateway] POST /worker/* a2a.method=SendMessage
- [agentgateway] POST
- [worker] POST /invoke
- [worker] HTTP POST
- [agentgateway] server/discover
- [agentgateway] server/discover docs
- [agentgateway] server/discover ops
- [worker] HTTP POST
- [agentgateway] initialize
- [agentgateway] initialize docs
- [agentgateway] initialize ops
- [worker] HTTP POST
- [agentgateway] notifications/initialized
- [agentgateway] notifications/initialized docs
- [agentgateway] notifications/initialized ops
- [worker] HTTP POST
- [agentgateway] tools/call gen_ai.tool.name=get_status
- [agentgateway] tools/call ops_get_status gen_ai.tool.name=get_status
- [worker] HTTP DELETE
- [agentgateway] DELETE /mcp/*
- [agentgateway] DELETE docs
- [agentgateway] DELETE ops
- [worker] HTTP POST
- [agentgateway] POST /v1/chat/completions gen_ai.operation.name=chat gen_ai.usage.input_tokens=8 gen_ai.usage.output_tokens=10
- [agentgateway] POST
client から LLM の呼び出しまでが 1 本の trace につながりました。gateway は受けた traceparent を引き継いで自分の span を作り、backend に転送するときにも traceparent を付けています。A2A の span には a2a.method、MCP の span には gen_ai.tool.name、LLM の span には token 数が付くので、trace を見るだけで「planner がどのエージェントを呼び、そのエージェントがどの tool を呼び、LLM に何 token 使ったか」が分かります。シナリオ 01 で見たとおり context ID はエージェントごとに別になるので、エージェントをまたいだ追跡は trace ID で行うことになります。
trace からは multiplex のコストも見えます。worker は ops_get_status を 1 回呼んだだけですが、server/discover、initialize、notifications/initialized、セッション終了の DELETE は docs と ops の両方に送られています。束ねる MCP server が増えると、セッションを張るたびに送られるリクエストも、その数に比例して増えます。
気になった点
- 設定ファイルに
config.session.keyが無いと、agentgateway が起動時に鍵を生成して設定ファイルに書き込みます。 binds形式の設定では待ち受けアドレスを指定できず、全インターフェースで listen します。readiness と metrics の port も既定では全インターフェースです。- CEL のリファレンスに載っている変数でも、使える場所が限られているものがあります (
mcp.tool.argumentsは認可では使えず、mcp.target.nameは guardrail で評価エラーになる)。ルールを書いたら、許可されるケースと拒否されるケースの両方を実際に呼んで確かめる必要があると思いました。 - gateway は経路上の判定点なので、エージェントが gateway を迂回して backend を直接呼べると意味がありません。今回は全部
127.0.0.1で動かしているので、planner から worker の port を直接叩くこともできます。本番では、backend にはネットワーク上 gateway からしか届かないようにするか、OpenShell のようなサンドボックスで外向きの通信を gateway だけに絞る必要があります。
まとめ
agentgateway を使って、A2A・MCP・LLM の 3 種類のトラフィックを 1 つの gateway に通してみました。Agent Card の書き換えでエージェント間の呼び出しを gateway に寄せられること、MCP の tool を JWT の claim で絞ると一覧からも消えること、LLM の token を team 単位で制限できること、そしてそれらがすべて同じ形式のログと 1 本の trace に残ることを確認できました。エージェントが増えても「誰が何を呼んだか」を 1 か所で答えられるのは、運用する側として安心材料になると感じました。
一方で、認可ルールでは tool の引数を参照できず guardrail で補う必要があることや、CEL の変数が使える場所の違いなど、まだ発展途上の部分もありました。