Agent 基建系列:用 MCP Tunnel 让云端 Agent 访问企业内网工具

1 阅读11分钟

前两篇介绍了云端 Agent 的文件系统,以及基于 Proxy 的凭证管理和网络管控。到这里,Agent 已经能读写文件、加载 Skill,也能通过受控出口访问外部服务。云端 Agent 拥有强大的推理能力,但当其执行任务需要触达私网核心资产(如企业内部知识库、内网 OA 系统)时,往往会受阻于企业防火墙限制。

我们采用反向建连:在企业私网部署 Connector,由它主动通过 HTTPS 连接云端,领取工具调用并返回结果。这样,MCP Server 可以继续留在私网里。下面以一次知识库查询为例,看看请求怎样到达私网工具,结果又怎样回到 Agent 手中。

这次调用涉及四个角色:

  • Agent 运行在云端 Sandbox 中,发起知识库查询等 MCP 调用,并等待结果。
  • 云端 Gateway/Broker 负责接收和调度请求。Gateway 校验身份,Broker 检查能否接单、分发任务并记录处理状态。
  • Connector 运行在企业私网,主动向云端发起长轮询,领取任务、调用本地 MCP 服务,再提交结果。
  • 私网 MCP 运行在企业私网,访问内部数据并执行知识库检索等具体业务。

Connector 是整个架构的“破壁者”,它主动从私网连接云端,因此企业不需要开放公网入站端口。连接建立后,云端入口仍会检查调用方的身份和允许访问的目标,MCP 服务则负责自身的业务授权。

私网里的工具,为什么连不上

MCP 规定了工具怎样被发现和调用,但不负责网络互通。给 Agent 配好工具的 URL 和凭据之后,还要解决一个问题:Agent 所在的机器怎样到达这个地址。

常见的做法有三种:

  • 给 MCP Server 开公网入口:改动最少,但私网服务要直接暴露在公网上,攻击面随之扩大。
  • 用 VPN 或专线打通网络:连通范围大,适合整体网络互联。如果只需要暴露几个工具,网络和运维成本偏高。
  • 反向建连:在私网里运行一个客户端,由它主动连接云端,再通过这条连接接收工具调用。

我们选择了第三种。企业只需放行这个客户端访问云端的出站连接,MCP Server 仍然留在私网里。反向建连不等于授权:它只改变了“连接由谁发起”;通道建立之后,调用照样会进入私网,结果也会回到云端。授权范围和数据流向,仍然要单独设计。

MCP Server 留在私网,由私网客户端主动向云端建连

参考:Claude 怎样接入私网 MCP

Anthropic 的 Managed Agents 方案是在私网里部署 cloudflared 和一个 Proxy。cloudflared 主动连接 Cloudflare 的隧道入口,Proxy 把经过隧道的请求路由到目标 MCP Server。Claude 通过 Tunnel 地址访问工具,企业侧不需要开放公网入站端口。

Claude MCP Tunnels 的组件关系

在 Anthropic 后端和私网 Proxy 之间,还有一层 inner TLS,Cloudflare 因此只能转运密文。这层 TLS 在 Proxy 处终止,所以 Anthropic 仍然能读到正文,MCP 自身的业务授权也要另外处理。

我们借鉴的是其中的两个思路:由私网主动建连,由入口统一控制路由。承载方式上,我们选择自建调度层,因为不希望工具调用依赖第三方隧道服务。云端入口和转发组件都自己部署、自己运维,链路的可用性、容量和排障也自己负责。

我们的方案:两端沿用公开协议,中间自建调度

为了让现成的客户端和管理工具可以直接使用,我们当了一次缝合怪:

  • 管理 API 完全对齐 Claude Managed Agents 的 Tunnels 的接口协议;
  • 私网客户端我们没有开发,直接使用 OpenAI 开源的 tunnel-client,下文称为 Connector。

协议是现成的,真正要自己设计的是夹在中间的调度层。

tunnel-client 的数据面基于 HTTPS 长轮询:Connector 向云端建立一个长连接请求“有没有新任务”,领到任务后在本地调用 MCP,再通过另一个 Response 请求提交结果。整个过程只有普通的出站 HTTPS,对企业防火墙最友好。

一个 Connector 可以接入多个私网 MCP Server,每个 Server 对应一个 Channel。本例中,知识库的 Channel 名叫 knowledge。

一次查询怎样往返

先只看四个角色:Agent、云端服务、Connector、知识库 MCP。这里的云端服务,是指接收 Agent 调用、校验权限、调度 Connector 的整个服务;它内部包括会话网关和 Broker,后面会逐个展开。下文中的请求和回答都只是示意。

一次工具调用的完整往返

第一步,Agent 发起普通的 MCP 工具调用,然后等待结果:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "search_knowledge",
    "arguments": { "query": "服务发布后的健康检查规则" }
  }
}

第二步,Connector 主动领取任务。 Connector 一直向云端服务发起 HTTP 长轮询:有任务就领走,没有就等待事件发生。云端服务把这次查询放进 Poll 响应,Connector 根据 Channel 找到预先配置的本地 MCP 地址,然后执行调用。

第三步,Connector 提交结果,云端服务返回给 Agent。 Agent 不需要轮询任务状态,它最初发出的 HTTP 请求一直挂着,最后收到的也是一个普通的 MCP 结果:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      { "type": "text", "text": "发布后检查健康接口,并确认实例状态及错误率。" }
    ],
    "isError": false
  }
}

从 Agent 的角度看,这和调用一个公网 MCP Server 没有区别。复杂度都留在云端服务内部。

多实例下,三个请求怎样找到彼此

上面这次往返,涉及三个独立的 HTTP 请求:Agent 的调用、Connector 的 Poll、Connector 提交结果。云端服务部署了多个实例,这三个请求可能分别落在 A、B、C 三个实例上。如果任务和等待状态只存在 A 的内存里,B 领不到任务,C 也不知道结果该交给谁。

因此,我们在云端服务内部加了一个负责请求调度的 Broker,用 NATS 让所有实例共享命令和状态。下面按请求的先后顺序,逐个介绍用到的组件;为什么选 NATS,放在这一节的最后。

A、B、C 可以是不同实例,通过 NATS 看到同一份命令和状态

入队:先检查能否接单,再把命令放进队列

A 收到知识库查询后,为它分配一个唯一的 request_id,并保持 Agent 的 HTTP 请求,等待结果返回。

入队前,Broker 先确认目标 Channel 有在线的 Connector、凭据仍然有效,并且还有余量接收新请求。任何一项不满足,就直接拒绝请求。

检查通过后,Broker 先创建一条各实例共享的请求记录,将状态设为 queued,再把要执行的命令放进 Commands Stream。前者用来跟踪请求进度、保存最终结果;后者是任务队列,供 Connector 领取命令。

所有 Tunnel 共用这条队列,但每条命令都带有 Tunnel 和 Channel 对应的投递地址(Subject)。例如,这次查询只会交给目标 Tunnel 中能处理 knowledge 的 Connector。

领取:绑定、出队、交付

Connector 发起 Poll 时,会告诉云端自己能处理哪些 Channel。B 按 Tunnel 和 Channel 从 NATS 中领取对应的命令;如果命令必须由某个 Connector 进程处理,就只交给那个进程。多个 Channel 同时有任务时,B 并发领取,不会因为一个 Channel 没任务就阻塞其他 Channel。

拿到命令后,B 按顺序完成三步:

  1. 绑定领取者。 在请求记录中记下由哪个 Connector 处理,并把状态从 queued 改为 dispatched。
  2. 确认出队。 确认可以派发后,向 NATS 发送 ACK,告诉队列这条命令不再需要投递。
  3. 交付命令。 把命令放进 Poll 响应,交给 Connector 执行。

出队不等于执行完成。 只有 Connector 返回结果、云端保存成功,这次调用才算完成。

回传:先保存结果,再通知等待方

Connector 拿到知识库的查询结果后,向云端提交。这次请求可能落在另一个实例 C 上。C 根据请求记录,确认结果来自当初领取命令的 Connector,再把结果保存到请求记录中,并把请求标记为 completed。

结果保存成功后,C 通过 Core NATS 通知 A:“你等待的请求已有结果。”A 根据 request_id 查到请求记录、取出结果,通过最初保持的 HTTP 连接返回给 Agent。这样,即使接收调用和接收结果的是不同实例,也能完成这次往返。

结果回传路径

整个生命周期对应三个状态:

  • queued:排队中。 接收调用的实例 A 写入,表示命令已入队,等待领取。
  • dispatched:已派发。 处理 Poll 的实例 B 领取。
  • completed:已完成。 接收结果的实例 C 写入,表示最终结果已保存。

请求的生命周期

队列保存待交付的命令,请求记录保存处理状态和结果,通知负责唤醒等待方。命令从队列里移除,并不会删除对应的请求记录;后面处理故障,靠的就是这个区别。

为什么是 NATS

回头看这三步,调度层需要三样东西:一个能按路由领取的持久队列,一个支持版本条件更新的状态存储,一个轻量的唤醒通知。当前实现用 NATS JetStream 的持久队列保存命令,用 JetStream KV 存储请求记录,用 Core NATS 发送通知。JetStream 按 Subject 过滤的持久消费者,也正好对应“按路由领取”。

Redis 可以用 Streams、键值和 Pub/Sub 组合出同样的效果,但路由过滤和消费者状态要自己维护。Kafka 的分区日志更适合需要保留和回放的事件流,但它更重,我们目前技术栈也没有引入,被排除。

安全边界

会话身份决定能访问哪些目标

Sandbox 访问云端服务时,先经过会话网关,请求里携带当前 Session 的 Ingress Token。会话网关先核对会话身份,并检查 Token 是否属于当前运行代次,防止 Sandbox 恢复后旧实例继续访问工具。校验通过后,再按工具服务名(本例是 knowledge),在该会话固定下来的配置快照中查找目标。

目标由会话配置决定,不由调用参数决定

凭据在每一跳怎样变化

Session Ingress Token 在会话网关处就结束了:请求进入 Broker 之前,入口的 Authorization 会被移除,所以交给 Connector 的工具请求里没有 Session 凭据。真正的 MCP 凭证由 Connector 在私网中访问知识库时加上,一个典型的私网中的 Connector 配置如下:

mcp:
  server_urls:
    - channel: knowledge
      url: http://127.0.0.1:39091/mcp
  extra_headers:
    Authorization: env:MCP_AUTHORIZATION   # 环境变量中保存完整的 "Bearer ..." 值

一次调用经过的各段 Header 如下:

Sandbox → 会话网关
Authorization: Bearer <session-ingress-token>

会话网关 → Broker → Connector
Content-Type: application/json
(入口 Authorization 已移除)

Connector → 知识库 MCP
Authorization: Bearer <knowledge-token>

入口凭据和业务凭据在不同位置注入与移除

已派发的命令,不会自动重跑

假设 Agent 通过 MCP 触发了一次发布,Connector 却在返回结果前断连了。此时,云端无法知道:发布根本没开始,还是已经完成、只是结果没传回来。如果自动再执行一次,就可能重复发布。

因此,我们选择不自动重跑。平台一旦记下“这条命令已由某个 Connector 领取”,就不会再次派发。即使队列因为故障重新投递了这条命令,接手的云端实例也会检查请求记录,发现已经有人领取便停止派发。

这个选择也有代价:平台可能刚记下领取者,连接就断了,Connector 实际上还没收到命令。即便如此,平台也不会补发,这次调用可能以失败或超时结束。

这就是这里的 at-most-once(至多一次):允许一次调用没有执行成功,但不因自动重试而重复触发操作。它不保证每次调用都恰好执行一次。对发布、创建工单等操作,业务方应先查询执行结果,或用幂等键保证重试不会产生重复操作。

已派发请求不会自动重执行

小结

以上就是我们基于反向建连技术的云端 Agent 访问私网 MCP 服务架构,本文是 Agent 运行环境基建系列的第 3 篇。接下来我们计划通过这个系列逐步构建完整的 Agent 运行环境基建,包括运行时、文件系统、Session 管理、监控告警、安全管控等,欢迎关注后续的实战总结。