05 - MCP 网关:一个客户端,多个后端
数据快照 2026-08-19。代码引自当天拉取的
envoyproxy/ai-gateway@main与agentgateway/agentgateway@main。
前三篇讲的都是"业务 → 模型"这段流量。这一篇讲的是另一段:"Agent → 工具"。
这段流量在 2026 年基本被 MCP 统一了,而它带来的问题和 LLM 流量完全不同:
- LLM 请求是无状态的,MCP 连接是有状态的(有 session、有订阅、有服务端主动推送)
- LLM 后端是同质的(都能回答同一个问题),MCP 后端是异质的(每个提供不同的工具)
- LLM 网关做的是二选一,MCP 网关做的是聚合
所以 MCP 网关不是"给 MCP 做个反向代理",它更像一个协议感知的聚合器。
前置:01 - 网关是什么 里的 MCP 概念(让 AI 应用连接外部工具的协议,提供工具的一方叫 MCP Server)。
本篇回答:Agent 要连 5 个 MCP Server,网关怎么把它们伪装成一个?
会用到的词:
- JSON-RPC:MCP 使用的消息格式,每条消息有
method(方法名)和id(用来把响应和请求配对) tools/list/tools/call:MCP 里最核心的两个方法 —— 列出有哪些工具、调用某个工具- session(会话):MCP 连接是有状态的,客户端和服务端靠一个 session ID 维持上下文,这是它和普通 HTTP 请求最大的区别
- SSE(Server-Sent Events):服务端向客户端单向推送消息的长连接,MCP 用它来做服务端主动通知
一、核心问题:客户端只想看到一个 MCP Server
客户端发一次 tools/list,期望拿回一份完整列表。但这份列表实际来自三个后端,而且:
- 三个后端各有各的 session ID
- 三个后端可能有同名工具
- 任何一个后端推送
tools/list_changed,都要转发给客户端 - 客户端调用某个工具时,要知道该发给谁
Envoy AI Gateway 的 internal/mcpproxy/(handlers.go 1,904 行 + session.go 801 行)就是在解决这四件事。
二、工具命名:用 __ 做后端命名空间
最朴素也最关键的一步 —— 给工具名加后端前缀:
// internal/mcpproxy/handlers.go
const nameSeparator = "__"
func downstreamResourceName(name string, backendName string) string {
return fmt.Sprintf("%s%s%s", backendName, nameSeparator, name)
}
所以后端 github 上的 search_issues 工具,客户端看到的是 github__search_issues。调用时反向解析:
func (m *mcpRequestContext) handleToolCallRequest(...) (handlerResult, error) {
backendName, toolName, err := upstreamResourceName(p.Name)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err))
return handlerResult{}, err
}
backend, err := m.getBackendForRoute(s.route, backendName)
// ...
p.Name = toolName // 发给后端时把前缀去掉
这个方案朴素但有代价:工具名会变长,而工具名和描述是要塞进模型上下文的。三十个工具、每个前缀多十几个字符,就是几百个 token 的固定开销。同时它也解释了为什么 MCP 生态里工具名普遍不能带 __。
同样的前缀技巧还用在了请求 ID 上,因为服务端可以反向给客户端发请求(比如采样),响应回来时得知道是哪个后端问的:
prefixedID = fmt.Sprintf("%d%si%s%s", v, nameSeparator, nameSeparator, backend)
后面那个 i / f / s 是原始 ID 的类型标记(int / float / string)—— JSON-RPC 的 ID 可以是数字也可以是字符串,加了前缀后必须能还原回原来的类型。这种细节是"给一个有状态协议做代理"和"给 HTTP 做代理"的真实难度差距。