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_id、workspace_id、user_id。
这带来两个后果:
- 从 Agent 的视角看,来自 plugin 的工具、来自 MCP server 的工具、内置适配器的工具,长得完全一样。绑定是有类型、有版本的。
- 权限检查、密钥注入、出网限制、审计、成本归集、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 配置里出现了 token 或 value 字段,直接抛错------
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)。
支持三种认证类型:bearer、api_key、oauth2。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 = True,egress_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.1 或 10.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_request记gateway_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 的维护者。