1. 为什么需要 MCP 网关
当组织里只有一两个 MCP 服务器时,客户端直接连就行。当服务器涨到几十个、上百个,问题就变了:客户端要维护一堆连接配置、工具名互相冲突、鉴权各自为政、故障排查无从下手。MCP 网关(gateway)就是把这些复杂性收敛到一个统一入口。
1.1 直连模式的三个痛点
# 痛点一: 客户端配置爆炸
# 每个 IDE/Agent 都要写 N 份 server 配置,改一次要改 N 处
# 痛点二: 命名冲突与能力发现
# 两个服务器都有 search 工具,客户端如何区分、如何选择
# 痛点三: 横切关注点无处安放
# 鉴权、配额、审计、限流、灰度——每个服务器重复实现
# 网关 = 把横切关注点从 N 个服务器收敛到 1 个入口
1.2 网关的职责边界
| 职责 | 网关做 | 服务器做 |
|---|---|---|
| 鉴权 | 统一 OAuth/令牌校验 | 业务级授权 |
| 路由 | 工具名 → 目标服务器 | 工具实现 |
| 配额 | 按主体/工具限流 | 内部资源调度 |
| 审计 | 全量调用日志 | 业务日志 |
| 聚合 | 合并 tools/list | 各自能力声明 |
| 转换 | stdio ↔ HTTP | 无 |
一句话:网关不是「多一跳」,而是「把 N 份重复的横切逻辑换成 1 份统一策略」。
2. 多服务器聚合与命名空间
聚合的第一个问题是命名:不同服务器可能导出同名工具。网关必须建立一套命名空间规则,否则模型看到两个 search 会无所适从。
2.1 命名空间策略
| 策略 | 形式 | 优点 | 缺点 |
|---|---|---|---|
| 前缀式 | github__create_pr | 无歧义、易路由 | 名字变长、token 略增 |
| 后缀式 | create_pr@github | 可读 | 部分客户端不支持特殊字符 |
| 扁平 + 冲突拒绝 | 冲突时报错 | 名字干净 | 需人工干预 |
| 分域 | 按会话只挂载一个域 | 上下文干净 | 切换成本 |
2.2 聚合 tools/list
// 网关把多个后端的 tools/list 合并成一个
async function aggregateTools(): Promise<Tool[]> {
const backends = registry.list();
const merged: Tool[] = [];
for (const b of backends) {
const tools = await b.client.listTools();
for (const t of tools) {
merged.push({
...t,
// 前缀式命名空间:<server>__<tool>
name: `${b.namespace}__${t.name}`,
// 保留原始名,便于回程路由
_origin: { server: b.id, tool: t.name },
// 注入配额/审计提示,模型可见
description: `[${b.namespace}] ${t.description ?? ""}`,
});
}
}
return merged;
}
2.3 回程路由
// 收到 tools/call 后,从命名空间解析目标服务器
async function routeCall(name: string, args: unknown, ctx: CallContext) {
const sep = name.indexOf("__");
if (sep < 0) throw new McpError(-32601, `unknown tool: ${name}`);
const ns = name.slice(0, sep);
const tool = name.slice(sep + 2);
const backend = registry.get(ns);
if (!backend) throw new McpError(-32601, `unknown namespace: ${ns}`);
await quota.check(ctx.principal, backend.id);
const res = await backend.client.callTool({ name: tool, arguments: args });
audit.record({ principal: ctx.principal, ns, tool, args, res });
return res;
}
一句话:命名空间既是「防冲突」的手段,也是「可路由」的关键——名字里必须携带回程信息。
3. 能力路由
除了「按名字路由」,网关还能做更聪明的路由:按能力标签、按权限、按负载、按版本。
3.1 路由维度
# 1) 精确名路由: 工具名直接映射(最常用)
# 2) 标签路由: 工具带 tags(read/write/db/cloud),按标签批量启用
# 3) 权限路由: 不同主体看到不同工具子集(RBAC)
# 4) 版本路由: v1/v2 并存,灰度切换
# 5) 负载路由: 同一能力的多个副本间做负载均衡
# 6) 地理位置路由: 就近接入降低延迟
3.2 权限过滤的 tools/list
// 按主体权限裁剪可见工具:模型根本看不到无权使用的工具
async function listToolsFor(principal: Principal): Promise<Tool[]> {
const all = await aggregateTools();
return all.filter((t) => {
const policy = policies.for(principal, t._origin);
return policy.canSee; // 不可见 = 不出现在列表里
});
}
3.3 路由表配置
# gateway routes 配置
namespaces:
github:
transport: streamable-http
url: https://mcp.internal/github
tools: ["list_prs", "create_pr", "get_issue"]
tags: [vcs, write]
k8s:
transport: streamable-http
url: https://mcp.internal/k8s
tags: [cloud, write]
docs:
transport: stdio
command: ["mcp-docs", "--root", "/srv/docs"]
tags: [read]
routing:
strategy: namespace-prefix
deny_untagged_write: true # 未显式授权的写工具一律不可见
4. 注册中心与目录
网关怎么知道有哪些服务器?答案是注册中心(registry):一张「谁提供什么能力、在哪里、健康与否」的目录。
4.1 注册信息模型
| 字段 | 含义 | 示例 |
|---|---|---|
| id / namespace | 唯一标识 | github |
| endpoint | 接入地址 | https://mcp.internal/github |
| transport | 传输方式 | streamable-http / stdio |
| capabilities | 声明的能力 | tools, resources, prompts |
| health | 健康状态 | healthy / degraded / down |
| owner | 归属团队 | platform-team |
| version | 版本 | 1.4.2 |
4.2 服务发现流程
# 1) 服务器启动 → 向注册中心注册(或注册中心主动探测)
# 2) 网关拉取目录 → 建立本地路由表(带缓存)
# 3) 定期健康检查 → 更新 health 状态
# 4) 客户端请求 → 网关按路由表转发
# 5) 服务器下线 → 注销或健康检查失败 → 从路由表摘除
# 目录是"能力的真相来源",网关只是它的消费者
4.3 注册 API 示例
# 服务器向注册中心注册
curl -X POST https://registry.internal/v1/servers \
-H "Authorization: Bearer $REGISTRY_TOKEN" \
-d '{
"namespace": "github",
"endpoint": "https://mcp.internal/github",
"transport": "streamable-http",
"capabilities": ["tools"],
"owner": "platform-team",
"version": "1.4.2",
"ttl_seconds": 30
}'
一句话:注册中心解决「有哪些能力」,网关解决「怎么到达它们」——两者解耦,才能各自独立演进。
5. 鉴权与配额
网关是天然的鉴权收口点:客户端只跟网关建立信任,后端服务器只信任网关。
5.1 鉴权模型
# 1) 客户端 → 网关: OAuth 2.1 令牌(含 scope)
# 2) 网关 → 后端: 服务身份(mTLS 或内部令牌)
# 3) 网关校验 scope 与目标工具所需权限是否匹配
# 4) 后端不再直接面对用户,只信任网关的服务身份
# 关键: 用户身份必须"透传"给后端(用于后端自己的授权与审计)
5.2 令牌透传与降权
// 网关把用户身份转为后端可验证的签名头
function forwardHeaders(ctx: CallContext): Record<string, string> {
return {
// 服务身份(网关自己)
"x-mcp-gateway": "gw-prod-01",
// 用户身份(签名,防伪造)
"x-mcp-principal": ctx.principal.id,
"x-mcp-scopes": ctx.scopes.join(" "),
"x-mcp-signature": sign(ctx.principal.id + ctx.scopes, GW_PRIVATE_KEY),
// 追踪
"x-request-id": ctx.requestId,
};
}
5.3 配额维度
| 维度 | 示例 | 超限动作 |
|---|---|---|
| 主体 | 每用户 1000 次/天 | 拒绝 + 告警 |
| 工具 | create_pr 20 次/小时 | 拒绝 |
| 命名空间 | github 组 5000 次/天 | 拒绝 |
| 并发 | 每主体 5 并发 | 排队 |
| 令牌 | 单次响应 256 KB | 截断 |
| 成本 | 云工具累计花费 | 熔断 |
# 令牌桶限流(按主体 + 工具)
class TokenBucket:
def __init__(self, rate: float, burst: int):
self.rate, self.burst = rate, burst
self.tokens, self.ts = burst, time.monotonic()
def allow(self, cost: float = 1.0) -> bool:
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False
一句话:鉴权管「能不能」,配额管「能多少」——两者都在网关收口,才能对全组织生效。
6. 协议转换
网关常常要弥合异构:有的后端是 stdio 本地进程,有的是 Streamable HTTP 远程服务,客户端可能只支持其中一种。
6.1 转换矩阵
| 客户端 → | stdio 后端 | HTTP 后端 |
|---|---|---|
| stdio 客户端 | 透传 | HTTP → stdio 桥 |
| HTTP 客户端 | stdio → HTTP 桥 | 透传/负载均衡 |
6.2 stdio 到 HTTP 的桥接
// 把本地 stdio 服务器的 JSON-RPC 转发为网关内部的调用
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "mcp-docs",
args: ["--root", "/srv/docs"],
stderr: "pipe", // 后端日志走 stderr,别污染 JSON-RPC 通道
});
const client = new Client({ name: "gw-bridge", version: "1.0.0" });
await client.connect(transport);
6.3 JSON-RPC 透传的注意点
# 1) 保留原始 id 映射: 网关自己的 id 与后端 id 要建立映射表
# 2) 能力协商在连接建立时完成: initialize 握手各自独立
# 3) 通知(notifications)要双向转发: tools/list_changed 等
# 4) 错误码透传: -32601/-32602 等标准码不要被网关改写
# 5) 大响应分块: HTTP 侧要处理流式与截断
# 网关是"协议透明"的,改协议细节会破坏客户端的兼容假设
7. 可观测性
网关是全局唯一能看到「所有工具调用」的位置,天然是观测的最佳埋点。
7.1 关键指标
# 1) 调用量: 按主体/命名空间/工具/状态
# 2) 延迟: P50/P95/P99,区分网关自身开销与后端耗时
# 3) 错误率: 按错误码分类(协议错/鉴权错/后端错/超时)
# 4) 配额命中: 限流触发次数
# 5) 后端健康: 各服务器可用率、连接复用率
# 6) 工具热度: 哪些工具被高频调用、哪些从没被用过
7.2 分布式追踪
{
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span": "tools/call",
"name": "github__create_pr",
"attributes": {
"mcp.principal": "user:alice",
"mcp.namespace": "github",
"mcp.tool": "create_pr",
"mcp.backend_latency_ms": 412,
"mcp.gateway_latency_ms": 18,
"mcp.status": "ok"
}
}
一句话:网关的追踪要能区分「网关自身耗时」与「后端耗时」,否则优化无从下手。
8. 高可用部署
网关成了单点,就必须按「它一定会挂」来设计。
8.1 高可用要点
| 关注点 | 做法 |
|---|---|
| 无状态 | 会话状态外置到 Redis/DB,网关实例可随意扩缩 |
| 多副本 | 至少 3 副本,跨可用区 |
| 健康检查 | 主动探测后端 + 自身就绪探针 |
| 熔断降级 | 后端连续失败则快速失败,避免雪崩 |
| 连接复用 | 后端连接池化,避免每请求重建 |
| 优雅退出 | 摘流 → 等待在途请求完成 → 关闭 |
8.2 熔断器
class CircuitBreaker {
private failures = 0;
private state: "closed" | "open" | "half" = "closed";
private openedAt = 0;
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt > 30_000) this.state = "half";
else throw new McpError(-32000, "backend circuit open");
}
try {
const r = await fn();
this.failures = 0;
this.state = "closed";
return r;
} catch (e) {
if (++this.failures >= 5) {
this.state = "open";
this.openedAt = Date.now();
}
throw e;
}
}
}
8.3 部署拓扑
# 客户端(IDE/Agent)
# │ HTTPS + OAuth
# ▼
# [ LB / Ingress ]
# │
# ▼
# [ MCP 网关 x3 ] ←→ [ Redis: 会话/配额 ] ←→ [ 注册中心 ]
# │
# ├── HTTP ──→ [ github-mcp ] [ k8s-mcp ]
# └── stdio ─→ [ docs-mcp (sidecar) ]
# 网关无状态、后端可异构、注册中心是真相来源
9. 从直连迁移到网关
已有直连配置的团队,迁移要平滑,不能一刀切。
9.1 迁移步骤
# 1) 网关旁路部署: 只做观测,不改变流量(影子模式)
# 2) 单个服务器试点: 把一个服务器接入网关,客户端改为连网关
# 3) 验证: 对比直连与网关的工具列表、调用结果一致性
# 4) 批量迁移: 按命名空间逐步迁移
# 5) 收敛: 直连配置退役,客户端只剩网关一个入口
# 影子模式先看清流量,再动真格
9.2 兼容性检查清单
# 迁移前确认
# 1) 客户端支持命名空间前缀吗(名字变长)
# 2) 客户端支持 Streamable HTTP 吗(原为 stdio)
# 3) 后端的能力协商字段是否被网关完整透传
# 4) notifications 是否双向可达
# 5) 大响应/流式是否被网关缓冲破坏
10. 常见陷阱
- 命名空间用特殊字符:
@、:部分客户端不支持——用__这类安全分隔符。 - 网关有状态:会话存在网关本地内存,扩容后会话丢失——状态外置。
- 鉴权只做认证不做授权:任何登录用户都能调
create_pr——scope 与工具权限绑定。 - 配额只按用户:某个用户建了一堆子账号绕开配额——按主体 + 工具 + 命名空间多维限流。
- 无熔断:一个后端挂了拖垮整个网关——熔断 + 快速失败。
- 吞掉错误码:网关把后端的
-32602改写成通用错误——透传标准错误码。 - 转发 stderr 到 stdout:stdio 后端的日志污染 JSON-RPC——日志走 stderr。
- 没有影子期:直接切流量,出问题回滚困难——先旁路观测再迁移。
11. 总结
MCP 网关、注册中心与代理,解决的是「服务器数量增长后」的规模化问题:把散落的连接收敛为统一入口,把重复的横切逻辑收敛为统一策略。四个核心构件是:聚合与命名空间(合并能力、前缀路由、回程可解析)、注册与发现(目录作为能力真相来源,网关作为消费者)、鉴权与配额(认证在网关、身份透传、多维限流)、可观测与高可用(全链路追踪、无状态多副本、熔断降级)。落地节奏上,先影子观测、再单点试点、后批量迁移,比一次性切换安全得多。与 https://plumephp.com/mcp-multi-server-orchestration/ 的编排视角、https://plumephp.com/mcp-production-deployment/ 的部署实践、https://plumephp.com/mcp-remote-streamable-http/ 的传输细节配合阅读,能覆盖从单机到集群的完整路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。