数据截至 (上游 commit 5359534c6f00)
第 4 章 · MCP 层:工具即动作
本章讲 OpenEnv 怎么把 MCP(Model Context Protocol,模型上下文协议——一套让模型发现并调用外部工具的标准)接进 Gym 式的
step()。设计依据是仓库里的rfcs/003-mcp-support.md。
4.1 它要解决的小问题
传统 RL 环境的动作空间是环境作者拍脑袋定的:Discrete(4)、{"code": str}、{"move": "e2e4"}。每个环境一套,模型每换个环境就要重新学「这里的动作长什么样」。
而 LLM 世界已经有了现成的标准答案:MCP 工具。工具有名字、有描述、有 JSON Schema 参数表,模型天生会用。
RFC 003 的判断很直接:列出所有可能动作 = tools/list;执行一个动作 = tools/call。 这两件事和 MCP 的语义完全重合,那就别再发明第三套。
于是:
传统 RL OpenEnv + MCP
动作空间是什么? ────────▶ tools/list
执行这个动作 ────────▶ tools/call
4.2 思路:把 MCP 方法包成 Action 类型
映射只用了两个 Pydantic 类(src/openenv/core/env_server/mcp_types.py):
| MCP 方法 | Action 类 | 位置 | 对应 Observation |
|---|---|---|---|
tools/list | ListToolsAction | mcp_types.py:225 | ListToolsObservation(:259) |
tools/call | CallToolAction | mcp_types.py:239 | CallToolObservation(:269) |
两个 Action 都带一个 type 字面量字段做判别器("list_tools" / "call_tool")。CallToolAction 另有 tool_name: str 和 arguments: Dict[str, Any]。
关键是它们是普通的 Action 子类。所以模型「调工具」这件事,在协议层就是一次普通的 step()。
一个细节:反序列化会优先认 MCP 类型
deserialize_action()(src/openenv/core/env_server/serialization.py:42)在交给环境自己的 action_cls 之前,先看 type 字段是不是 list_tools / call_tool:
_MCP_ACTION_TYPES: Dict[str, Type[Action]] = {
"list_tools": ListToolsAction,
"call_tool": CallToolAction,
}
真实源码 serialization.py:20-23。但拦截是有条件的——只有当 action_cls 本身是 Action 基类或某个 MCP 类型时才生效(serialization.py:36)。注释说明了理由:「这让环境自己的动作校验保持权威」。
换句话说:如果你的环境声明了 MyGameAction,那 type: "call_tool" 的载荷不会被偷偷改道,还是走 MyGameAction.model_validate(),该报错就报错。
4.3 MCPEnvironment:把 FastMCP 服务器接进 step()
定义在 src/openenv/core/env_server/mcp_environment.py:133,继承 Environment。
环境作者要写什么
看 envs/echo_env/server/echo_environment.py:71-104 这段真实代码的结构:
- 在
__init__里建一个FastMCP("echo_env"); - 用
@mcp.tool装饰几个普通函数; super().__init__(mcp)把服务器交给基类;- 实现
reset()、state、_step_impl()。
注意第 4 步是 _step_impl 而不是 step——因为 step 已经被基类占用来做路由了。
step() 的路由
MCPEnvironment.step()(mcp_environment.py:418)只做三岔分流:
if isinstance(action, ListToolsAction):
return self._handle_list_tools()
elif isinstance(action, CallToolAction):
return self._handle_call_tool(action, timeout_s=timeout_s)
else:
return self._step_impl(action, timeout_s=timeout_s, **kwargs)
真实源码 mcp_environment.py:447-452。MCP 动作被基类吃掉,其余的落到子类。 纯 MCP 环境(比如 echo)的 _step_impl 就只回一句「不认识这个动作类型」(echo_environment.py:156-163)。
同步与异步:两条镜像路径
这是本模块最容易看晕的地方。基类为 list_tools 和 call_tool 各准备了两个版本:
| 同步入口 | 异步实现 | 关系 |
|---|---|---|
_handle_list_tools(:454) | _async_handle_list_tools(:492) | 同步版用 run_async_safely 包异步版 |
_handle_call_tool(:468) | _async_handle_call_tool(:512) | 同上 |
真正的逻辑只在异步版里,同步版是一行转调。而 step_async()(mcp_environment.py:597)直接调异步版,跳过 run_async_safely。
为什么要这么绕?step_async 的文档字符串给了答案(mcp_environment.py:606-608):
WebSocket 处理器在外层事件循环上直接调用它,而 MCP 会话已经在那个循环上打开了——这避免了走同步
step()经由run_in_executor时出现的线程/事件循环死锁。
串起来看就是:run_async_safely()(src/openenv/core/utils.py:9)在已有事件循环时会另起一个线程跑 asyncio.run。而 MCP 客户端会话绑在原来那个循环上,跨线程访问就死锁。所以异步路径必须一路 await 到底,不能中途落回同步。
✅ WebSocket 路径(推荐)
websocket_endpoint → step_async → _async_handle_call_tool → await client.call_tool
(全程同一个事件循环,MCP 会话一直开着)
⚠️ 同步路径(仍支持,但要小心)
step → _handle_call_tool → run_async_safely → 新线程 + 新循环 → ...
4.4 mcp_session:一个被反复解释的上下文管理器
mcp_session()(mcp_environment.py:211)只有三行实体代码:
client = self._require_mcp_client()
async with client:
yield client
但它有 30 行文档字符串,讲了两个作用(mcp_environment.py:213-239):
作用一:空值守卫。 close() 之后 mcp_client 会被置 None(mcp_environment.py:653),这里给出清晰报错而不是 AttributeError。
作用二(有意思的那个):AsyncExitStack 适配器。 FastMCP 的 Client.__aenter__ 会创建一个后台 asyncio.Task 管理会话。如果在 HTTP 会话路径里直接把它塞进 AsyncExitStack,某些 ASGI 测试工具(比如 Starlette 的 TestClient)会在请求之间取消这个孤儿任务,会话状态就损坏了。
包一层 @asynccontextmanager 生成器就能隔离:生成器帧把 async with client: 挂在 yield 处,只有当 stack 显式关闭这个生成器时清理才会跑,事件循环取消孤儿任务时不会误伤。
这段注释还顺带说明了两件事:FastMCP 的 Client 上下文管理器是可重入的(内部引用计数,最外层退出才真关);它内部已经有 anyio.Lock 串行化连接状态变更,所以不需要额外加锁。
会话为什么要一直开着
服务端在三处把 MCP 会话钉在连接生命周期上:
| 位置 | 场景 |
|---|---|
http_server.py:409-414 | HTTP MCP 会话创建时进 stack |
http_server.py:1179-1185 | /mcp WebSocket 连接期间 |
http_server.py:1510-1516 | /ws WebSocket 连接期间 |
目的一致:避免每条消息都重建 MCP 传输,同时保住 FastMCP 的 ctx.set_state / ctx.get_state 跨调用有效。