OpenAI Agents SDK 工程笔记:MCP 工具接入与生产禁区

千笔-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 下实跑过(输出原样贴出);模型相关部分用脚本化假模型离线复现,字段名以你安装版本的官方文档为准。

图:上方四种接入方式与执行位置;中部过滤、审批、护栏三道闸及其适用范围;下方生产禁区。

目标说明

读完你应能独立完成五件事:

  1. 说清四种接入方式的区别:HostedMCPTool 由 Responses API 代为调用远程 server;MCPServerStreamableHttp / MCPServerSse / MCPServerStdio 由你的 Python 进程连接并调用。
  2. 跑通一个本地 stdio server:连接、list_tools()、白名单过滤、直接 call_tool(),全程不需要 API key。
  3. 给写入/删除类工具挂 require_approval,并走通 interruptions → to_state() → approve/reject → 续跑 的审批流程。
  4. 用 tool_input_guardrails 在调用前拦截可疑参数,并知道它不作用于 HostedMCPTool。
  5. 留下三条 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 分钟脚本

  1. 复制 notes_server.py 与 smoke.py,无 key 跑通并保存输出。
  2. 把 delete_note / write_note 挂上审批,reject 与 approve 各跑一次。
  3. 挂一个参数护栏,造一次拒绝样本。
  4. 故意配错一个 server,确认你的启动检查会报错而不是静默继续。
  5. 三条 fail 写进 CSV,禁区表贴进 PR 描述。

总结

MCP 接入的工程核心不是「又多了一批工具」,而是三件事:工具在哪执行、谁能看见、谁批准副作用。过滤决定暴露面,审批决定副作用能否发生,护栏决定参数能否进门;三者都只对本地 server 完整成立,托管工具要走自己的审批回调。先交可跑 smoke 和三条 fail,再谈「我们接入了 MCP 生态」。

参考:Model context protocol (MCP) · Human-in-the-loop

相关推荐
code2cat4 小时前
【随笔】MCP资源更新订阅:通知到达以后,Agent怎样刷新旧资料
java·后端·开发工具·ai agent·mcp
网络毒刘7 小时前
开源 MCP 服务器怎么选:五个 AtomGit/GitHub 可自托管候选与适用场景速查
开源·cursor·mcp·atomgit·工具实践
漂着的圆木18 小时前
本地沙箱:Agent策略执行能力与OS边界核对表
agent·沙箱·github copilot·mcp·安全边界
EatFan19 小时前
AI 从「能生成」到「能交付」:2026年9月智能体(Agentic)成为产业主线的多源证据与开发者应对清单
人工智能·大模型·rag·智能体·mcp·agentic ai
VIP_CQCRE19 小时前
把 Codex CLI 变成全能 AI 工作台:一键接入 Ace Data Cloud MCP
codex·ai工具·开发者工具·mcp·acedatacloud
Bug收容所1 天前
AI-Agent-是怎么工作的
agent·functioncalling·mcp
光依旧1 天前
MCP实战手记(八):从“能跑“到“能上线“——无状态MCP Server的生产落地清单
java·人工智能·spring boot·架构·ai agent·mcp
EatFan1 天前
多智能体协作的三套通信语言:MCP、A2A 与 Tool Calling 怎么选(附 LangGraph/DeepAgents 实战分工模式)
区块链·多智能体·ai agent·mcp·a2a·tool calling
光依旧1 天前
RSA杀入Agent 身份安全:MCP网关成了新战场
rsa·身份安全·ai安全·mcp·agent安全·mcp网关