MCP 接进生产环境之前,先回答这五个问题

MCP(Model Context Protocol)把「Agent 怎么接工具」这件事标准化了。一个 streamable HTTP 端点,一次 list_tools,工具就进了模型的可调用列表。接入成本低到什么程度?低到我们内部第一次接的时候,从零到跑通只用了一个下午。

但把它放进生产环境的那天,安全同学问了五个问题,一个都答不上来。

这篇文章讲的就是这五个问题,以及我们在 SOIT(一个开源的 Agent 运行时与治理平台)里是怎么答的。代码都在 GitHub 上,文中每个论断我都附了文件路径,可以直接去翻------毕竟这类文章最容易写成 PPT。

问题一:谁能调这个工具?

MCP 协议本身不管这件事。list_tools 返回什么,模型就能调什么。工具的可见性等于可调用性。

在多租户、多工作区的环境里这显然不够。SOIT 的做法是把 MCP server 当作 Plugin 制品 安装,而不是当作一个配置项:工具引用统一是 mcp_tool:{server}:{tool} 这种带命名空间的形式(server/app/adapters/tools/mcp.py 里的 parse_mcp_tool_ref),解析时带上 RequestContext------里面有 tenant_idworkspace_iduser_id

这带来两个后果:

  1. 从 Agent 的视角看,来自 plugin 的工具、来自 MCP server 的工具、内置适配器的工具,长得完全一样。绑定是有类型、有版本的。
  2. 权限检查、密钥注入、出网限制、审计、成本归集、trace、replay 这一整套,对 MCP 工具自动生效,不需要为 MCP 单独写一遍。

Agent 的每个版本还带一份能力 allowlist------模型、知识库、工作流、工具、插件、MCP server 都在里面。换句话说,「这个 Agent 的 v3 版本能调哪些 MCP 工具」是一个可以 diff、可以回滚的东西,而不是运行时的一个开关。

问题二:凭据放在哪?

绝大多数 MCP 接入示例是这样写的:

json 复制代码
{
  "auth": { "type": "bearer", "token": "sk-xxxxxxxx" }
}

配置文件里一个明文 token。它会进 git,会进日志,会进你导出的那份配置备份。

SOIT 在这里做了一个硬拒绝_build_auth_headers 里有这么一行判断:如果 auth 配置里出现了 tokenvalue 字段,直接抛错------

perl 复制代码
MCP credentials must use secret_id

只接受 secret_id,运行时再通过 SecretsPort 解析成真值。API key 同理,而且只支持放在 header 里(放 query string 会被日志和 referrer 带走,不给这个选项)。

真值只在调用的那一瞬间存在于内存里。落库、落审计、落 trace 的是脱敏后的副本ToolPolicyGateway._resolve_secrets 在解析密钥的同时生成一份 redacted payload,只保留 secret_id 和签名策略引用,后续所有写盘路径用的都是它(server/app/kernel/ports/tools/policy.py)。

支持三种认证类型:bearerapi_keyoauth2。OAuth 走的是 2.1 规范,带授权服务器发现(RFC 9728、RFC 8414 / OIDC)和资源绑定令牌(RFC 8707),用 client_credentials 授权。

这里要说清楚一个限制:我们没实现浏览器端的 authorization_code 流程。因为 SOIT 是以自己的身份去调 MCP server 的,不是代表某个正在浏览器前面的用户。如果你的场景需要「以终端用户身份调用受保护的 MCP server」,现在这套不覆盖。

问题三:它能连到哪里去?

这是五个问题里最要命的一个。

MCP server 是你部署的,但它是一个会代你发起网络请求 的东西。工具参数里塞一个 URL,它就去请求。经典的 SSRF 场景:让它去请求 http://169.254.169.254/,云厂商的元数据服务就在那儿,里面有临时凭据。

SOIT 的出网策略是 deny-by-default,并且分了三层:

第一层,域名策略check_egress_policy 检查目标域名,支持租户级和工作区级的 allowlist / blocklist,blocklist 优先于 allowlist。默认配置 enable_egress_policy: bool = Trueegress_allowlist: list[str] = []------空 allowlist 意味着默认什么都不放行,你得显式加。还有一个细节:策略查询本身如果抛异常,结果是拒绝,不是放行:

python 复制代码
except Exception as exc:
    raise ForbiddenError(
        "Egress policy lookup failed; request denied",
        {"resource_ref": resource_ref},
    ) from exc

fail-closed 不是口号,是要在每个 except 分支里写对的东西。

第二层,解析后逐地址校验 。光过域名不够------DNS rebinding 可以让一个通过了 allowlist 的域名解析到 127.0.0.110.0.0.x。所以 GovernedEgressGuard 在放行域名之后,会真的去解析主机名,然后对解析出的每一个地址 判断 ipaddress.ip_address(address).is_global,只要有一个不是公网地址就整体拒绝(server/app/kernel/security/egress.py)。

顺带堵掉的还有:非 http/https 协议默认拒绝;URL 里带 userinfo(https://user:pass@host/)拒绝;DNS 解析失败拒绝而不是重试。

第三层,逐跳授权 。一个通过了前两层的 URL,返回 302 跳到内网怎么办?所以出网的 httpx 客户端是这么构造的(server/app/adapters/http/governed_client.py):

python 复制代码
async def authorize_request(request: httpx.Request) -> None:
    await guard.authorize(ctx, resource_ref, str(request.url))

event_hooks["request"] = [authorize_request, *request_hooks]
kwargs.setdefault("follow_redirects", False)

授权挂在 httpx 的 request 事件钩子上------每一个真实发出的请求都过一遍,包括重定向的每一跳,而不是只在调用入口检查一次。而且默认不跟随重定向。

MCP 适配器的会话就是用这个客户端建的,所以 MCP 的整条链路------初始化、list_tools、每一次 call_tool------都在这套约束里面。

问题四:出了事查得到吗?

工具调用是 Agent 唯一真正产生副作用的地方。模型说错话可以重问,工具把生产库的一行数据改了就是改了。

SOIT 把工具调用当作 run 的一个 step 落库,每次调用写两条证据:

  • 网关审计log_gateway_requestgateway_type="tool",请求侧记 tool_ref、脱敏参数、出网决策(allow / deny + 目标 URL),响应侧记成功与否、结果类型、metadata、错误。失败路径同样写 ------except 分支里第一件事就是补一条 audit,这条经常被忘掉,但恰恰是排查时最需要的那条。
  • step 指标:延迟、成功标志、工具参数与结果摘要、错误码与错误详情。

同一次调用还会写一条成本记录,billing_basis="requests"、带 provider 和 source_port="tools"------所以「这个月哪个 Agent 的哪个 MCP 工具花了多少」是可以按 agent / workflow / tool / 来源(source_kind=plugin | mcp | builtin)下钻的。

再加一层 OpenTelemetry span soit.tool.invoke,属性里带 tenant / workspace / run / step 的 id,接你自己的 APM。

问题五:能重放吗?

Agent 排障最烦的一点是不可复现:同样的输入,模型这次这么想,下次那么想。

工具这一层至少可以做到确定性。SOIT 的每次工具调用都带一个幂等键,默认是 tool:{run_id}:{tool_call_id},通过一个带租约的执行记录来 claim。如果这次 claim 命中的是一条已经完成的记录,直接返回缓存的响应,不会真的再调一次外部工具。

重试策略也跟着变。看这段注释就懂了:

python 复制代码
max_retries=1 if kwargs.get("idempotency_key") else self.max_retries,

Durable Agent calls are at-most-once at this boundary. Not every downstream adapter can honor an idempotency key.

在这个边界上是 at-most-once,因为不能假设下游 MCP server 认得你的幂等键。宁可少调一次,不能多调一次------对写操作来说这个取舍没得选。

配套还有速率限制和日配额,key 按 tool_ref + tenant + workspace(+ user)分组,所以一个 Agent 跑飞了不会把整个租户的第三方 API 额度打爆。

坦白局

按惯例说一下这套东西能做什么:

  • MCP 传输只支持 streamable HTTP,适配的是 MCP SDK v1 线;2026-07-28 那版 stateless 协议修订还没支持
  • OAuth 只有 client_credentials,没有 authorization_code(原因见问题二)。
  • 一键安装 MCP 工具的市场还在 roadmap 上,现在是手动装 Plugin 制品。
  • 出网策略的默认 allowlist 是空的------这意味着你第一次接 MCP server 一定会被拒,必须显式加域名。这是刻意的,但确实会让 quickstart 多一步。

为什么这些东西不应该在 Agent 框架里做

顺便回答一个经常被混淆的问题:这跟 LangChain 之类的框架是什么关系?

不是一层东西。框架解决的是「怎么把这次调用编排出来」,运行时解决的是「这次调用在谁的身份下、用谁的密钥、能连到哪、留下什么证据、能不能重放」。前者是写代码时的关注点,后者是代码上线之后别人问你的关注点。

框架里当然也能塞权限检查,但那样每接一个新工具就要重写一遍治理逻辑。把它下沉到运行时的端口层(port)之后,MCP 工具、插件工具、内置工具走的是同一条路径------这也是为什么前面说「不需要为 MCP 单独写一遍」。

来试试

SOIT 是 Apache-2.0 的,代码全在 GitHub:

  • 仓库:github.com/soit-ai/soi...
  • 快速开始:仓库里的 docs/quickstart.md(有中文版)
  • 治理演示:docs/governance-demo.md------一个 20 分钟的本地跑通脚本,把权限、密钥、调用审计、成本归集、重放、回归这六件事挨个演示一遍,跑完输出一份机器可读的报告

如果你正在把 MCP 往生产环境推,欢迎来 issue 区聊聊你被卡在哪一步。上面五个问题里,最难的那个通常不是技术问题,是「谁来定这份 allowlist」。

利益相关:我是 SOIT 的维护者。

相关推荐
星火102417 分钟前
【从 0 到 1 动手造 Agent】02、确定性铁笼 LangGraph
人工智能·后端·agent
Csvn18 分钟前
第 8 章 RAG 工程
人工智能
CyberwayTech22 分钟前
从自然语言到结构化活动方案:Cyber TPM AI智能活动推荐如何嵌入TPM业务流程
人工智能·tpm·赛博威·营销费用管理·快消
桃西西呀23 分钟前
模型都能自己写代码了,你的 Agent 为什么还接不进一个日历?——一篇讲透 MCP 这个 AI 世界「USB-C」
人工智能·ai编程·mcp
伍树明23 分钟前
深入理解大模型Agent:从理论到年报ReAct Agent实战
人工智能
星火102423 分钟前
【从 0 到 1 动手造 Agent】03、给 Agent 装上操作系统——MemGPT/Letta 内存分层与自我演化
人工智能·后端·agent
Csvn26 分钟前
第 7 章 MCP 标准化工具接入
人工智能·aigc·agent
大模型码小白26 分钟前
Spring AI 框架中集成 MCP 的完整指南:从服务端到客户端的全流程实践
大数据·运维·数据库·人工智能·python·sql·spring
武子康28 分钟前
拆开 Pi Monorepo:改模型、循环、产品和 UI 时,代码应该放在哪一层
人工智能·llm·agent