MCP 工具集成

本文是 MCP 客户端层的设计:OpenProgram 在协议中扮演的角色、模块位置、 server 如何配置与连接,以及 MCP 工具如何到达 LLM。 关联代码:openprogram/mcp/。

一句话概括

把外部 MCP server 提供的工具注册成框架内的 AgentTool,LLM 调它跟调本地 @function/Agent method 完全一样。worker 启动时 spawn 配置文件里所有 enabled server 子进程,通过 stdio 跑 JSON-RPC,把它们的 tools/list 包装好挂进 _registry。

角色 — 我们做 client,不做 server

MCP 是协议,定义"工具的提供方"(server)和"工具的消费方"(client)。

角色 谁做 例子
Server 提供工具的进程 @drawio/mcp、@modelcontextprotocol/server-filesystem
Client LLM 框架,把 server 的工具暴露给 LLM OpenProgram、Claude Desktop、Claude Code、Cursor、Cline、opencode

我们是 client。本设计不涉及把 OpenProgram 自己的工具暴露给外部 client —— 那个方向是另一个独立模块,不复用 openprogram/mcp/ 里的代码,见权威的 MCP 服务端设计与实现证据。

目录位置

openprogram/mcp/ 是 top-level module,跟 openprogram/channels/ 平级。不放在 openprogram/programs/ 下。

理由:

参考实现

完整对照见 references/opencode/packages/opencode/src/mcp/。OpenProgram 的 MCP 层基本是 opencode 的 Python 翻译。

references/hermes-agent/mcp_serve.py 和 references/openclaw/src/mcp/ 也提供 MCP,但都是 server 端(把自己的能力暴露给 Claude Desktop 等),跟我们要做的事方向相反。

配置

<state_dir>/mcp_servers.json(默认 ~/.openprogram/mcp_servers.json,profile 切换路径自动跟着走 paths.get_state_dir)。

{
  "servers": {
    "drawio": {
      "type": "local",
      "command": ["npx", "-y", "@drawio/mcp"],
      "env": {},
      "enabled": true,
      "timeout_seconds": 60
    }
  }
}
字段 说明
type "local"(stdio 子进程)、"http"(Streamable HTTP)或 "sse"(旧式 SSE)。远程类型使用 url / headers / auth(none / bearer / oauth)
command 子进程命令 + 参数列表
env 注入子进程的环境变量(基础环境从父进程继承,这里 override / 追加)
enabled false 临时禁用某个 server,无需删配置
timeout_seconds startup 等待 + 单次 call 的 read timeout

文件缺失或解析失败都不致命——load_configs() 返回空 list,worker 正常启动,日志一行 warning。MCP 是 opt-in 功能。

凭证

env 和 headers 是自由格式,任何一项都可能装着凭证:ENDPOINT 可以是一个带签名的 URL,和 API_KEY 装 key 没有区别。因此不按名字猜,这两个 map 里的每一个值都当作密钥处理,bearer token 和 OAuth client secret 同样。

两套序列化把这条界线写死。MCPServerConfig.to_storage_dict() 保留完整值,只用于写 mcp_servers.json。to_response_dict() 把每个值换成 {"has_value": bool, "masked": str},把 auth 密钥收敛成 has_token / has_client_secret 加一个掩码,所有 route 返回的都是它:/api/mcp/servers、/api/mcp/servers/{name}、/api/mcp/catalog、/api/mcp/catalog/diff。没有任何 route 返回已存储的 MCP 凭证。

所以 PATCH /api/mcp/servers/{name} 不需要客户端把看到的东西再传回来。它按名称执行 preserve / replace / delete:

请求体 含义
名称未出现(或整个 env / headers 字段未出现) preserve,保持已存值
名称出现且带值 replace,替换为该值
名称出现且值为空字符串 delete,删除该名称

auth.token 和 auth.client_secret 适用同一规则。改动 auth.kind 会丢弃另一种方式的密钥,因为它既取不回也没有用途。Web UI 的密钥输入框一律从空开始,只列出已存的名称,未改动的字段就不会出现在请求体里,于是被保留。

配置文件装着这些密钥,所以 save_configs() 按 owner-only 写入:临时文件在一次系统调用里以 0600 创建,fsync,再 os.replace。旧版本留下的 0644 文件会在下次保存时收紧。落盘发生在 restart 成功之后,因此一次启动失败的编辑不会破坏已存的凭证。

代码结构

openprogram/mcp/
  __init__.py    public re-exports: load_mcp_servers / shutdown_mcp_servers / server_status
  config.py      MCPServerConfig + load_configs(读 mcp_servers.json,纯静态)
  client.py     MCPClient — 单个 server 的 stdio 子进程 + ClientSession + supervisor task
  adapter.py    MCP wire 类型 ↔ AgentTool 类型翻译(纯函数,无状态)
  registry.py    全局 manager: load_mcp_servers / shutdown_mcp_servers / server_status

四个文件每个一职责:配置、单连接、翻译、管理。

调用路径 — 只有 LLM 自动调用一条

你在 webui 输入 "画个流程图"
  ↓
worker → agent_loop → 调 LLM API,带上所有工具(本地 + MCP)
  ↓
LLM 决策,输出 tool_call(name="drawio__open_drawio_xml", args={...})
  ↓
agent_loop 在 _registry 查到 AgentTool,await execute(args)
  ↓ execute 是 adapter.register_remote_tool 里建的 closure
  ↓ closure 内部 capture 了对应 MCPClient,直接调 client.call_tool(原始工具名, args)
  ↓ MCPClient 通过 stdio JSON-RPC 发到对应 drawio 子进程
  ↓
drawio MCP 子进程执行(调 macOS open 命令开浏览器)
  ↓ 返回 CallToolResult
  ↓
adapter.convert_call_result 把 MCP content blocks 转成 AgentToolResult
  ↓
agent_loop 把结果塞进 chat history,再调一次 LLM
  ↓
LLM 看到工具结果,生成最终文本回复 "画好了"
  ↓
webui 显示

整条链路在 FastAPI 主 loop 上同步完成,不涉及跨线程 / 跨 event loop 调度。

/api/run/{name} / CLI programs run / /programs 页面不接 MCP 工具——它们是本地 function 的入口。MCP 工具用户不感知,LLM 隐性使用。

关键技术决策

1. 持久连接 + supervisor task

MCP 协议设计上是 long-lived 双向连接(server 可以推 progress notification、resource update 通知等)。Python 官方 SDK 把 session 暴露成嵌套 async-context-manager:

async with stdio_client(params) as (r, w):
    async with ClientSession(r, w) as session:
        await session.initialize()
        ...

跨函数调用持有 context manager 需要保持 __aexit__ pending,Python 没有直接 API。最干净的写法是把整个嵌套块包进一个 long-lived coroutine,放进一个 asyncio.Task 当 supervisor:

async def _supervisor(self):
    async with stdio_client(params) as (r, w):
        async with ClientSession(r, w) as session:
            await session.initialize()
            result = await session.list_tools()
            self._session = session
            self.tools = list(result.tools)
            self._ready.set()
            await self._shutdown.wait()      # 一直挂住,等关闭信号

start() 创建 supervisor task 并 await self._ready.wait();call_tool() 直接复用 self._session;stop() set self._shutdown,supervisor 退出嵌套 async with,子进程自动关。

启动失败(spawn 不起来、initialize 报错)也走 self._ready.set(),把 self.error 设成原因字符串,start() 解除阻塞,manager 看到 error 跳过 tool 注册并 log 一行。

2. 工具命名空间 {server}__{tool}

opencode 用 {server}:{tool} 作为命名空间分隔符。我们换成双下划线 {server}__{tool},因为 OpenAI 的 tool name regex 是 ^[a-zA-Z0-9_-]+$,冒号会被拒。Anthropic 也是同样的字符集。

adapter.namespace_tool_name(server, tool) 同时:

跨 server 同名工具会撞,后注册的覆盖前面的——跟框架其他地方的 last-wins 一致。

3. Worker 就绪与可选 MCP 生命周期

Worker 在自己的事件循环中以受管理的后台任务立即连接已启用的 MCP server。核心 FastAPI lifespan 和 /healthz 不等待可选传输初始化、npm 下载或 OAuth 同意;canonical execution recovery 仍是启动门槛。用户配置的单 server timeout 和顺序连接保持不变,不能通过延长 worker health deadline 或禁用已有集成来声称就绪。

等待连接前先把正在启动的 client 发布到现有 registry。状态保持真实:ready=false、无远程工具,现有 error/auth 字段随实际结果更新。初始化成功后才注册工具;失败或禁用的 server 不注册工具。连接等待不代表认证成功或远程能力可用。MCP 启动等待期间仍可访问现有掩码管理 API。

启动任务在轮到每个尚未启动的 server 时读取当前持久化配置。用户删除配置后不再启动该 server;禁用后保持禁用,不启动进程或注册工具。管理操作已经建立的同名 client 优先于启动时的配置快照。较早的等待连接恢复后不能撤销这些操作。

App 拥有后台启动任务并观察失败。关闭时先取消并等待未完成的启动,再关闭所有已发布 client 并注销工具,包括尚未完成初始化的 client。自有 OAuth callback 被取消时关闭 socket/thread 并解除 executor waiter,不伪造 code、state 或认证成功;正常 callback 校验和配置的认证行为保持不变。

入口 可观察验收
真实 app lifespan 与 /healthz 受控可选 MCP 传输等待时已就绪;canonical recovery 仍约束就绪
现有 MCP 状态与工具 registry 正在启动/失败的 client 可见,ready/error/auth 字段真实,秘密已掩码,没有提前注册工具
连接成功 现有命名空间工具及 prompts 通过当前 API 可用
启动期间关闭 取消并等待启动;释放 client、supervisor、callback 资源和注册;不发生关闭后的注册
启动等待期间删除或禁用后续配置较早连接恢复后,后续 server 仍保持不存在或禁用,不产生新进程或工具,管理操作建立的同名 client 保持有效
自有 callback 取消与成功 取消解除 waiter 而不伪造 code;正常 code/state 校验保持不变

不采用首次调用工具才连接的 lazy 方案。拒绝所有可选连接完成后才提供核心服务,因为认证等待可以合理地超过 health deadline。新建另一个 worker、改用户 timeout、隐藏认证失败或把等待中的 client 标记为 ready 均不满足该合同。

4. tolerant content 转换

adapter.convert_call_result() 处理 MCP 返回:

MCP 类型 转成
TextContent providers.types.TextContent (chat 历史)
ImageContent providers.types.ImageContent (multimodal LLM)
EmbeddedResource 展开成 [resource: <uri>] 文本 — 不把 raw bytes 塞进 chat
未知 / 未来类型 repr(block) 文本 — 至少 LLM 能看到有东西返回了

isError=True 的返回会在 details["is_error"] 体现,LLM 看到的 chat 历史里就是工具结果文本,跟本地工具的 error path 一致。

5. 测试隔离 — 警惕 from openprogram.paths import get_state_dir

tests/integration/store/test_attach_lazy_session.py 用 monkeypatch.setattr("openprogram.paths.get_state_dir", ...) 重定向 state 目录。如果 config.py 用 from openprogram.paths import get_state_dir 引入,首次 import 发生在 attach 测试期间(因为 webui startup hook 触发 _start_mcp_servers → import openprogram.mcp → import config),lambda 会被永久 binding 到我们模块,attach 测试结束后泄漏。

修复:from openprogram import paths as _paths,每次调 _paths.get_state_dir() 现查 attribute。监控:test 套件如果加新的 paths monkeypatch,确认我们的 config.py 仍然走 module reference。

附录:实现状态

上述设计已在 stdio 传输上实现。已建成部分的边界:

  1. 三种传输都已实现(stdio、Streamable HTTP、SSE)。远程认证支持静态 bearer token 与 OAuth 2.1 PKCE。OAuth 委托给 MCP SDK 的 OAuthClientProvider,经 token_storage.PersistentOAuthProvider 把静默重连所需的状态跨 worker 重启持久化:token 及绝对时间戳 expires_at、动态客户端注册信息(复用其中 loopback redirect 端口,重授权时呈现的 redirect_uri 与注册时一致)、发现到的授权服务器元数据(冷启动 refresh 直达真实 token endpoint,而不是 <server>/token 兜底)。access token 过期用 refresh token 后台续期;只有首次授权或 refresh 被拒时才走浏览器授权,supervisor 将后者标记为 error_kind="needs_reauth"。
  2. Tools、resources、prompts 和 sampling 使用现有 client API。 Resource/prompt meta tools 复用持久 client,MCP prompts 同步到 slash-command registry。Sampling 使用已配置的 host model callback,无 host model 时返回结构化错误;本次就绪修复不改变这些协议能力。
  3. 管理变更复用现有 add/remove/restart API。 它们控制选中的 client 及工具注册,不要求重启整个 worker;本次修复不改变配置或认证语义。
  4. 对同一 server 的并发调用在 ClientSession 锁上串行。 MCPClient._call_lock 让它们排队。这不是性能问题——MCP server 本来就是单飞行的——但一个慢工具会阻塞同 server 上的其他工具调用。
  5. 工具列表只读取一次。 supervisor 在启动时拉一次 list_tools,之后不再询问,因此 server 中途增删的工具不可见。协议的 tools/list_changed 通知没有被监听。
  6. _loaded 是模块全局变量。 测试需要重置后才能复测,见 tests/integration/programs/test_mcp_client.py::_reset_mcp_loader_flag fixture。

管理 API 通过 server_status() 和单 server 详情提供真实 ready/error/auth 状态、工具数量与掩码凭据。核心后台就绪与未完成启动的清理正在实现,只有公开回归、独立 spec/quality 审查及实际已安装 worker 验收完成后才判定完成。