OpenProgram Docs

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.pyreferences/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 功能。

凭证#

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

两套序列化把这条界线写死。MCPServerConfig.to_storage_dict() 保留完整值,只用于写 mcp_servers.jsonto_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.tokenauth.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.pymonkeypatch.setattr("openprogram.paths.get_state_dir", ...) 重定向 state 目录。如果 config.pyfrom 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/* 这一个协议面。 MCP 还定义了 prompts/*resources/*sampling/*,框架内都没有对应概念。sampling/createMessage 允许 server 反向借用 client 的 LLM 做推理,目前 client 会拒绝这类请求。
  3. 配置改动在 worker 重启后生效。 server 列表不做热重载,MCPClientstart()stop() 之间没有 restart。提供 restart_server(name) API 可以免去重启整个 worker。
  4. 对同一 server 的并发调用在 ClientSession 锁上串行。 MCPClient._call_lock 让它们排队。这不是性能问题——MCP server 本来就是单飞行的——但一个慢工具会阻塞同 server 上的其他工具调用。
  5. 工具列表只读取一次。 supervisor 在启动时拉一次 list_tools,之后不再询问,因此 server 中途增删的工具不可见。协议的 tools/list_changed 通知没有被监听。
  6. _loaded 是模块全局变量。 测试需要重置后才能复测,见 tests/integration/test_mcp_client.py::_reset_mcp_loader_flag fixture。

webui 设置页面尚未渲染 server_status();渲染它可以展示每个 server 的状态、 错误信息、工具数量与最近一次调用结果。

Last updated · 2026-08-13