MCP 工具集成#
本文是 MCP 客户端层的设计:OpenProgram 在协议中扮演的角色、模块位置、 server 如何配置与连接,以及 MCP 工具如何到达 LLM。 关联代码:
openprogram/mcp/。
一句话概括#
把外部 MCP server 提供的工具注册成框架内的 AgentTool,LLM 调它跟调本地 @function/@agentic_function 完全一样。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/functions/ 下。
理由:
- MCP 是外部协议适配器层,跟"function 的实现"是两件事。我们的 function 体系(
@function/@agentic_function/agentics/下的代码)是本地实现的工具;MCP 是从外部进程取来的工具。两层概念正交。 - opencode (
src/mcp/) 和 Claude Code (src/services/mcp/) 都把 MCP 当独立 module,跟本地 tool 实现完全分开。 - 我们项目里
channels/(消息渠道)就是已有的"外部协议适配器"先例 —— 它把外部消息源接进来。MCP 也是接外部东西,只是接的是工具源,位置应该跟 channels 一致。
参考实现#
完整对照见 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 / /functions 页面不接 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) 同时:
- 替换
[A-Za-z0-9_-]以外的字符为_ - 截断到 64 字符(OpenAI 的硬上限),保留 tool 后缀
跨 server 同名工具会撞,后注册的覆盖前面的——跟框架其他地方的 last-wins 一致。
3. eager spawn vs. lazy spawn#
opencode 是 lazy:服务实例化时不连,首次 tools() 调用才 spawn。我们选 eager:load_mcp_servers() 在 FastAPI startup hook 跑完才让 worker 进入服务状态。
理由:eager 实现简单,启动后状态稳定。代价是每个 enabled server 最多阻塞 timeout_seconds 秒(单 server 顺序启动)。drawio-mcp 首次 npx -y @drawio/mcp 还要去 npm 下载,可能慢,所以 timeout 默认给 30s,用户可以调高。
非致命:某个 server 启动失败,manager 记录 error,跳过它的工具注册,worker 继续启动并能用其他 server(以及所有本地工具)。
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/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 传输上实现。已建成部分的边界:
- 三种传输都已实现(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"。 - 只消费
tools/*这一个协议面。 MCP 还定义了prompts/*、resources/*、sampling/*,框架内都没有对应概念。sampling/createMessage允许 server 反向借用 client 的 LLM 做推理,目前 client 会拒绝这类请求。 - 配置改动在 worker 重启后生效。 server 列表不做热重载,
MCPClient在start()与stop()之间没有restart。提供restart_server(name)API 可以免去重启整个 worker。 - 对同一 server 的并发调用在 ClientSession 锁上串行。
MCPClient._call_lock让它们排队。这不是性能问题——MCP server 本来就是单飞行的——但一个慢工具会阻塞同 server 上的其他工具调用。 - 工具列表只读取一次。 supervisor 在启动时拉一次
list_tools,之后不再询问,因此 server 中途增删的工具不可见。协议的tools/list_changed通知没有被监听。 _loaded是模块全局变量。 测试需要重置后才能复测,见tests/integration/test_mcp_client.py::_reset_mcp_loader_flagfixture。
webui 设置页面尚未渲染 server_status();渲染它可以展示每个 server 的状态、
错误信息、工具数量与最近一次调用结果。