
千笔-AIWritePaper · https://www.aiwritepaper.com
把 MCP server 接进 Agent,最贵的失败往往不是「连不上」,而是连上了、工具全暴露了、谁都能调 :一个文件系统 server 同时带着 read_file 和 delete_file,开发时图省事不做过滤,上线后模型在一次误解里就把删除工具调了出去。官方 MCP 页 开头就写了信任前提:只连可信 server、用最小权限凭据、token 放在 authorization 字段或 header 而不是 URL、敏感操作要求审批。本文接着 handoffs / lifecycle 两篇笔记,把 MCP 接入钉成工程清单:四种接入方式各在哪执行、三道闸各管什么、本地实跑的 smoke 与 fail 样本、生产禁区。文中代码在 openai-agents 0.22.3、mcp 2.2.0 下实跑过(输出原样贴出);模型相关部分用脚本化假模型离线复现,字段名以你安装版本的官方文档为准。

图:上方四种接入方式与执行位置;中部过滤、审批、护栏三道闸及其适用范围;下方生产禁区。
目标说明
读完你应能独立完成五件事:
- 说清四种接入方式的区别:
HostedMCPTool由 Responses API 代为调用远程 server;MCPServerStreamableHttp/MCPServerSse/MCPServerStdio由你的 Python 进程连接并调用。 - 跑通一个本地 stdio server:连接、
list_tools()、白名单过滤、直接call_tool(),全程不需要 API key。 - 给写入/删除类工具挂
require_approval,并走通interruptions → to_state() → approve/reject → 续跑的审批流程。 - 用
tool_input_guardrails在调用前拦截可疑参数,并知道它不作用于HostedMCPTool。 - 留下三条 fail 样本(未过滤全暴露、manager 静默丢弃坏 server、把本地护栏当成托管工具也有效),写进禁区表。
规格钉死(对照官方 MCP 页):
- 执行位置 :托管 MCP 的整个工具往返在 OpenAI 基础设施里完成,你的进程不经手;本地三类 server 的
list_tools()/call_tool()都在你的进程里发生。 - SSE:MCP 项目已弃用 SSE 传输,新集成优先 Streamable HTTP 或 stdio。
- 依赖 :SDK 支持
mcp>=1.19.0,<3,自动适配 v1/v2;HostedMCPTool不受本地mcp版本约束。注意 mcp v2 把FastMCP改名为MCPServer(下文 server 代码做了兼容导入)。 - 缓存 :每次 run 都会对每个 server 调
list_tools();cache_tools_list=True只适合工具定义很少变化的 server,变更后用invalidate_tools_cache()刷新。 - 失败面 :
failure_error_function默认把工具失败格式化成模型可见文本;设为None则直接抛异常。
适用边界
适合用 MCP 接入
- 工具已经以 MCP server 形式存在(文件系统、内部知识库、工单系统),想复用而不是重写成
function_tool。 - 多个 Agent 或多个应用要共享同一组工具,希望工具定义集中维护。
- 需要把工具进程隔离出去(stdio 子进程或独立 HTTP 服务),便于单独限权、单独部署。
更适合 function_tool(不要硬上 MCP)
- 只有两三个纯本地函数,没有跨应用复用需求:多一层协议只多一层故障点。
- 需要对单个工具精细控制超时、错误格式、审批条件:
function_tool的参数面更直接。
不该指望它单独搞定
- 授权:MCP 是协议,不是权限系统。server 能做什么取决于你给它的凭据;工具暴露给模型后,模型就可能调用。
- 托管工具的客户端护栏 :本地 server 的
tool_input_guardrails/tool_output_guardrails不会附加到HostedMCPTool上。 - 观测完整:lifecycle 笔记里说过 tool hooks 以本地工具为准,托管调用要单独留痕。
风险提示
MCPServerManager 默认 drop_failed_servers=True:连不上的 server 会被安静地排除在 active_servers 之外,Agent 照常运行,只是少了一批工具。开发环境看起来「能跑」,生产里可能是「该查的库根本没接上」。
步骤与机制
1. 四种接入方式对照
| 方式 | 谁发起工具调用 | 审批入口 | 本地护栏 | 典型用途 |
|---|---|---|---|---|
HostedMCPTool |
Responses API | require_approval + on_approval_request |
不适用 | 公网可达的远程 server、官方 connector |
MCPServerStreamableHttp |
你的进程 | require_approval |
支持 | 自建 HTTP 服务、内网部署 |
MCPServerStdio |
你的进程(子进程) | require_approval |
支持 | 本地工具、原型、CLI 型 server |
MCPServerSse |
你的进程 | require_approval |
支持 | 仅兼容旧 server(已弃用) |
2. 可跑 smoke:本地 stdio server + 过滤 + 直调
先准备一个三工具的演示 server(内存存储,读 / 写 / 删各一个):
python
# notes_server.py ------ 本地 stdio MCP server(演示用,内存存储)
try: # mcp>=2:FastMCP 已更名为 MCPServer
from mcp.server.mcpserver import MCPServer
except ImportError: # mcp<2
from mcp.server.fastmcp import FastMCP as MCPServer
app = MCPServer("notes")
NOTES: dict[str, str] = {"n1": "MCP 只是协议,不是授权层"}
@app.tool()
def read_note(note_id: str) -> str:
"""读取一条笔记"""
return NOTES.get(note_id, "NOT_FOUND")
@app.tool()
def write_note(note_id: str, text: str) -> str:
"""写入/覆盖一条笔记(有副作用)"""
NOTES[note_id] = text
return "OK"
@app.tool()
def delete_note(note_id: str) -> str:
"""删除一条笔记(高危)"""
NOTES.pop(note_id, None)
return "DELETED"
if __name__ == "__main__":
app.run() # 默认 stdio
再写 smoke,无 API key 即可运行:
python
# smoke.py ------ 无 API key 也能跑:连接、列工具、过滤、直调、失败样本
import asyncio, sys
from agents.mcp import MCPServerStdio, MCPServerManager, create_static_tool_filter
PARAMS = {"command": sys.executable, "args": ["notes_server.py"]}
async def main() -> None:
# 1) 白名单:只暴露只读工具
async with MCPServerStdio(
name="notes",
params=PARAMS,
tool_filter=create_static_tool_filter(allowed_tool_names=["read_note"]),
client_session_timeout_seconds=10,
max_retry_attempts=2,
cache_tools_list=False,
) as ro:
tools = await ro.list_tools()
print("filtered tools:", [t.name for t in tools])
assert [t.name for t in tools] == ["read_note"]
res = await ro.call_tool("read_note", {"note_id": "n1"})
print("call read_note:", res.content[0].text)
# 2) 不过滤:看见全部工具(生产前必须逐个定审批策略)
async with MCPServerStdio(name="notes-all", params=PARAMS) as rw:
print("all tools:", sorted(t.name for t in await rw.list_tools()))
# 3) Fail 样本:命令写错的 server 被 manager 静默丢弃
bad = MCPServerStdio(name="broken", params={"command": "no-such-binary-xyz", "args": []})
good = MCPServerStdio(name="notes", params=PARAMS)
async with MCPServerManager([good, bad], connect_timeout_seconds=10) as mgr:
print("active:", [s.name for s in mgr.active_servers])
print("failed:", [s.name for s in mgr.failed_servers])
assert "broken" not in [s.name for s in mgr.active_servers]
asyncio.run(main())
实跑输出(原样):
text
Failed to connect MCP server
filtered tools: ['read_note']
call read_note: MCP 只是协议,不是授权层
all tools: ['delete_note', 'read_note', 'write_note']
active: ['notes']
failed: ['broken']
注意第一行:坏 server 只留下一行日志,进程没有退出。这就是 Fail B 的原型。
3. 审批:本地 server 的 require_approval
require_approval 支持 "always" / "never"、布尔值、按工具名映射、以及分组写法。下面把写和删都挂上审批,用一个「第一轮要求删除、第二轮输出终答」的脚本化假模型离线复现(真实场景把 model= 换成你的模型即可):
python
# 摘自 _w/mcp-smoke/approval_smoke.py(ScriptedModel、decision 的定义见完整脚本)
async with MCPServerStdio(
name="notes",
params={"command": sys.executable, "args": ["notes_server.py"]},
require_approval={"always": {"tool_names": ["delete_note", "write_note"]}},
) as server:
agent = Agent(name="Ops", instructions="按需调用工具。",
model=ScriptedModel(), mcp_servers=[server])
result = await Runner.run(agent, "删除 n1")
print("interruptions:", [(i.name, i.arguments) for i in result.interruptions])
state = result.to_state()
for item in result.interruptions:
state.approve(item) if decision == "approve" else state.reject(item)
result = await Runner.run(agent, state)
实跑两次的差异:拒绝时 n1 after: MCP 只是协议,不是授权层(未删);批准时 n1 after: NOT_FOUND(已删)。两次都先出现 interruptions: [('delete_note', '{"note_id": "n1"}')],说明调用在执行前就被挂起。完整脚本见 _w/mcp-smoke/approval_smoke.py。
托管 MCP 的对应写法是 tool_config 里的 require_approval,外加可选的 on_approval_request 回调,由代码直接批准或拒绝:
python
from agents import Agent, HostedMCPTool, MCPToolApprovalFunctionResult, MCPToolApprovalRequest
SAFE_TOOLS = {"read_wiki_structure", "read_wiki_contents", "ask_question"}
def approve_tool(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult:
if request.data.name in SAFE_TOOLS:
return {"approve": True}
return {"approve": False, "reason": "需人工复核"}
agent = Agent(
name="Assistant",
tools=[HostedMCPTool(
tool_config={"type": "mcp", "server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "always"},
on_approval_request=approve_tool,
)],
)
4. 护栏:调用前拦截参数
本地 server 可以挂 tool_input_guardrails,SDK 会把它附加到过滤后剩下的每个工具上:
python
import json
from agents import ToolGuardrailFunctionOutput
from agents.decorators import tool_input_guardrail
from agents.mcp import MCPServerStdio
@tool_input_guardrail
def block_secrets(data):
args = json.loads(data.context.tool_arguments or "{}")
if any("password" in str(v).lower() for v in args.values()):
return ToolGuardrailFunctionOutput.reject_content("参数疑似含凭据,已拒绝调用。")
return ToolGuardrailFunctionOutput.allow()
MCPServerStdio(name="notes", params=PARAMS, tool_input_guardrails=[block_secrets])
离线实跑:假模型尝试 write_note(text="password=123456"),工具输出为 参数疑似含凭据,已拒绝调用。,随后读 n2 返回 NOT_FOUND,说明写入没有发生。
5. Agent 级配置:同名工具与失败面
多个 server 发布同名工具时,用 Agent.mcp_config 的 include_server_in_tool_names=True 给本地 MCP 工具加服务器前缀;failure_error_function=None 让工具失败直接抛异常,适合「宁可中断也不让模型拿着错误文本继续编」的场景。server 级 failure_error_function 会覆盖 Agent 级设置。
6. 工具分级:先定级,再定审批
接入前把 server 暴露的每个工具按副作用分级,比事后补审批省事得多。下表是一个可直接抄走的分级口径:
| 级别 | 例子 | 过滤 | 审批 | 审批人至少看什么 |
|---|---|---|---|---|
| R0 只读、无敏感数据 | 查公开文档、读示例笔记 | 放行 | never |
不需要 |
| R1 只读、含敏感数据 | 查客户工单、读内部库 | 按 Agent 放行 | 视数据分级 | 调用方身份与查询范围 |
| W1 可逆写入 | 新建草稿、追加备注 | 按 Agent 放行 | always |
目标对象与写入内容 |
| W2 不可逆或外发 | 删除、发邮件、付款、改配置 | 默认屏蔽 | always + 人工 |
参数全文、影响范围、回滚方式 |
分级表和 require_approval 映射应当是同一份清单:表里写了 W2,代码里却是 never,评审时直接打回。动态场景(例如只有某类 Agent 才能看见写工具)用 tool_filter 传可调用对象,它能拿到 run_context、agent 和 server_name。
Fail smoke:三条必造失败
Fail A:未过滤,全量暴露。 上面 smoke 第 2 段的输出里 delete_note 直接出现在工具列表中。记 unfiltered_tools=fail;修复是白名单过滤,加上写删类工具强制审批。
Fail B:manager 静默丢坏 server。 failed: ['broken'] 只在你主动打印时才看得到。记 silent_drop=fail;修复是关键 server 用 strict=True,或启动时断言 failed_servers 为空,否则拒绝对外服务。
Fail C:把本地护栏当成托管工具也有效。 在 HostedMCPTool 上期望 tool_input_guardrails 拦截参数:官方明确这类客户端护栏不附加到托管工具。记 hosted_guardrail_assumed=fail;修复是托管侧用 require_approval + on_approval_request,或改用本地 server。
生产禁区(硬表)
| 禁区 | 为什么炸 | 最低替补 |
|---|---|---|
写删类工具 require_approval="never" |
模型误解一次就产生副作用 | 按工具名映射 always |
| 不过滤直接挂全部工具 | 暴露面等于 server 全部能力 | create_static_tool_filter 白名单 |
| token 拼进 URL | 日志、代理、追踪里泄漏 | header 或 authorization 字段 |
| 关键 server 走默认静默丢弃 | 少了工具仍「成功」 | strict=True 或启动断言 |
工具常变却 cache_tools_list=True |
模型看到过期 schema | 关缓存或变更后 invalidate_tools_cache() |
| 新集成用 SSE | 传输已弃用 | Streamable HTTP / stdio |
| 以为本地护栏覆盖托管工具 | 托管调用不经你的进程 | 托管侧审批回调 |
| 审批只测「批准」路径 | 拒绝路径从未验证 | reject 与 approve 各跑一次 |
与 handoffs / lifecycle 笔记的咬合
| 问题 | 看哪篇 | 关键点 |
|---|---|---|
| 专家接管对话后能调哪些 MCP 工具 | handoffs + 本篇 | 各专家挂各自的 mcp_servers,不要共享全量 server |
| 调用时间线要进审计 | lifecycle | tool hooks 以本地工具为准,托管调用另行记录 |
| 副作用要有人批准 | 本篇 | 本地 require_approval,托管 on_approval_request |
| 暂停后第二天再批 | 本篇 + HITL 文档 | RunState 只从可信存储恢复,审批人身份由服务端认证 |
最后一行值得单独强调:官方 HITL 文档写明,RunState.from_json() / from_string() 不会验证快照来源和提交人身份。审批界面只下发审批人有权看的工具详情,快照留在服务端,决策到达时由服务端认证、授权并校验待审项,再调用 approve / reject。
失败含义速查
| 现象 | 含义 | 下一步 |
|---|---|---|
| 工具列表里出现没打算暴露的写工具 | 未过滤 | 先上白名单 |
| 从未见过 interruptions | 审批没挂上或全是 never | 对照分级表逐个核对 |
| 日志里有连接失败但服务照常 | 静默丢弃 | 启动断言或 strict |
| 升级依赖后 server 起不来 | mcp v1/v2 API 差异 | 兼容导入或锁定大版本 |
| 模型拿着错误文本继续答 | 默认失败格式化 | 关键工具设 failure_error_function=None |
验收清单
| 编号 | 项 | 通过 | 失败含义 |
|---|---|---|---|
| M1 | 说清四种方式的执行位置 | 一句话 | 选型错位 |
| M2 | stdio smoke 可跑 | 输出含过滤后列表 | 环境未就绪 |
| M3 | 写删工具有审批 | interruptions 可见 | 副作用无闸 |
| M4 | 拒绝路径验证过 | reject 后数据未变 | 只有快乐路径 |
| M5 | 护栏拒绝样本存在 | 工具输出为拒绝文本 | 参数级风险未测 |
| M6 | 启动时检查 failed_servers | 断言或 strict | 静默缺工具 |
| M7 | 禁区表进仓库 | 可勾选 | 口头「注意一下」 |
可审计产物
_w/mcp-smoke/notes_server.py、smoke.py、approval_smoke.py、guardrail_smoke.py_w/mcp-smoke/*_output.txt(实跑输出)与versions.txt_w/mcp-smoke/mcp-fail-smoke.csv
踩坑
- 升级到 mcp v2 后
from mcp.server.fastmcp import FastMCP直接报错:它已改名为MCPServer。 - 在
list_tools()返回值上改 schema 想「临时收紧」:缓存返回的是拷贝,改了不生效。 - 只在开发机跑 stdio,上线换 HTTP 后忘了把
require_approval一起搬过去。 - 审批回调里只看工具名、不看参数:同一个
write_note写普通笔记和写配置文件风险完全不同。
当天 40 分钟脚本
- 复制
notes_server.py与smoke.py,无 key 跑通并保存输出。 - 把
delete_note/write_note挂上审批,reject 与 approve 各跑一次。 - 挂一个参数护栏,造一次拒绝样本。
- 故意配错一个 server,确认你的启动检查会报错而不是静默继续。
- 三条 fail 写进 CSV,禁区表贴进 PR 描述。
总结
MCP 接入的工程核心不是「又多了一批工具」,而是三件事:工具在哪执行、谁能看见、谁批准副作用。过滤决定暴露面,审批决定副作用能否发生,护栏决定参数能否进门;三者都只对本地 server 完整成立,托管工具要走自己的审批回调。先交可跑 smoke 和三条 fail,再谈「我们接入了 MCP 生态」。