@epoch-agent/plugin-mcp
MCP (Model Context Protocol) 客户端插件。连接外部 MCP server 扩展工具集。
配置文件位置由 @epoch-agent/infra 的 mcpConfigPath() 决定
(认 EPOCH_HOME / EPOCH_PROFILE),默认 ~/.epoch/mcp.json。
支持 Transport
| Transport | 配置 | 场景 |
|---|---|---|
| Stdio | command + args | 本地 MCP server 进程 |
| SSE | url | 远程 MCP server |
| Streamable HTTP | url + transport: "http"(或 streamable) | 远程 HTTP 流式 |
⚠️ stdio 那档要机器上有 Node。
command是从 PATH 解析的 (SDK 用 cross-spawn、shell: false),而官方配置几乎都是npx -y @modelcontextprotocol/server-xxx—— PATH 上没有npx/node就是 ENOENT, 那台 server 起不来(stderr 里那行会进日志)。 CLI 用户天然有 Node;嵌入宿主的用户没有,那边要宿主自己把 ship 的 node 目录 prepend 到process.env.PATH,见 EMBEDDING.md 的 「用户机器上没装 Node」一节。HTTP / SSE 那两档不起子进程,不受这条影响。
transport 不写时按 url 有无推断:有 url 走 SSE,没有走 stdio。
type 是 transport 的别名(MCP 生态里多数客户端的 mcp.json 用的是 type),
两个都写时听 transport 的。收它不是为了顺手:不收的话 {"type":"http","url":…}
会掉进上面那条「按 url 推断」的路,一个 Streamable HTTP server 被静默连成 SSE。
配置示例
servers 是以 server 名为键的对象,不是数组:
这份文件现在有两条写法:手写,或者
epoch web的连接器页那枚「添加 server」 (2026-08-18,POST /api/mcp)。界面那条只收三样(传输类型 / 命令或 URL / 环境变量)—— 下面那几格 (
timeout/oauth/headers…)仍然只能手写,所以它加完会把 「写进了哪个文件」印出来,让你接着去补。⚠️ 界面那条往原文里插一段,你的注释、键序、缩进、以及
connect_timeout/type这类别名一个字节都不动(它不是「读进来 → 改 → 整份写回」)。 判据全文在 runtime/src/mcp-config-write.ts。⚠️ 界面那条给的 server 名收得比这份文件严(只许字母数字和
_-)—— 手写的不受这条管。理由同下面「插件带的名字为什么更严」:名字会原样进工具名。
{
"servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": { "LOG_LEVEL": "warn" }
},
"remote": {
"transport": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
}
}
}
字段
| 字段 | 类型 | 说明 |
|---|---|---|
transport / type | stdio|sse|http | 不写按 url 推断;互为别名 |
command / args | string / string[] | stdio 必填 |
url | string | sse / http 必填 |
streamable | boolean | 远程用 Streamable HTTP 而非 SSE |
headers | Record<string,string> | 远程请求头(如 auth) |
env | Record<string,string> | 子进程环境变量 |
timeout | 秒 | 单次请求超时,默认 300 |
connect_timeout | 秒 | 建连(含握手)超时,默认 60 |
keepalive_interval | 秒 | 心跳间隔,默认 180 |
idle_timeout_seconds | 秒 | 空闲自动断开,0 表示不断 |
max_lifetime_seconds | 秒 | 连接最大寿命,0 表示不限 |
supports_parallel | boolean | 是否允许并行调用 |
skip_preflight | boolean | 跳过启动预检 |
snake_case 和 camelCase 两种写法都收。未知字段会进启动诊断(多半是拼错了), 配置写错不再静默变成「没有 MCP」—— 每条问题都带文件名 + 字段路径。
⚠️ source 不是这张表里的字段,写在 mcp.json 里不生效。 那一格在
McpServerStatus 上(不在配置上),由调用方在 connectAll(configs, source)
那一下填死。分开是刻意的:来源要是配置的一个字段,用户就能给自己那台贴上
「宿主注入」的标 —— 同角色那条「命名空间由装配层拼,宿主自己写的不算数」。
server 是从哪儿来的:McpServerStatus.source
三档:
source | 谁带来的 | 名字长什么样 |
|---|---|---|
user | 上面那份 ~/.epoch/mcp.json | 你自己写的那个 |
host | 嵌入宿主 buildRuntime({hostCapabilities: {mcpServers}}) | <namespace>__<server> |
plugin | 已装插件根下那份 mcp.json(方案 44 PR-3) | <插件名>__<server> |
前两档一个字节都不落盘 / 落在插件目录里,能力页的连接器那一栏就是按这一格分组的。
⚠️ 加一档要连着改能力页那张 MCP_GROUPS 数组(web/src/capability/connectors.tsx)
—— 那是 satisfies readonly {…}[],多一格不会编译红,而页面按组过滤后渲染,
漏改的表现是那一档一行都不显示、也没有任何报错。PR-2 实测复现过一次。
后两档的名字都由装配层拼,用两个下划线,而不是专家 / 技能那条路上的
<namespace>:。这不是两套风格:
- server 名会原样变成工具名的一部分(
prefixedName()拼的mcp__<server>__<tool>),而工具名是真的发给 provider 的; - 2026-08-17 实测:带
:的工具名被硬拒 ——tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$', 同一发换成mcp__gh__search_issues就 200 正常回; - 角色名 / 技能名不上网线进 provider,所以那边拼
:一直没事。
判据全文(含「为什么这两批的名字收得比 mcp.json 严」、两个前缀的来路差别
(宿主那个是 buildRuntime 的参数、装配时校验;插件那个是插件名、安装时就被
PLUGIN_NAME_PATTERN 卡过了)、以及为什么这一格是必填)在
types.ts 的 McpServerStatus.source 上。
功能
- 三 transport(stdio / SSE / Streamable HTTP)
- 工具名前缀
mcp__<server>__<tool>,撞名交ToolRegistry处理 - 配置校验:语法错误 / 类型错误 / 缺必填字段都进诊断,逐 server 隔离
tools/listannotations + title 透传到EpochTool.annotations(权限系统消费)- schema 缓存:内存优先,磁盘(0600)作降级;按连接配置指纹失效
- 看门狗式重连:断连不阻塞工具调用,重连在后台按 1s/2s/4s 退避
- 取消得掉:
ToolContext.signal一路递到 SDK 的RequestOptions.signal, 用户按停时 server 当场收到notifications/cancelled(见下面那一节) timeout对每一条请求都算数:tools/call/tools/list/resources/*/prompts/*用的是同一个McpClient.requestOptions()。 ⚠️resources/list这种翻页的是逐页给 —— SDK 的超时按请求算,只给第一页的话 第二页起又落回它那个 60 秒默认值- keepalive 心跳、idle 超时断开、最大生命周期
- OAuth(
oauth/):epoch mcp login/logout的后端,本机回调 server + token 刷新(带 skew)+ 0600 存储。认证过不去时置needsLogin——重连解决不了 这个问题,只有重新登录能 - resources:包成每个 server 一对工具
mcp__<server>__list_resources/mcp__<server>__read_resource - prompts:
McpRegistry.listPrompts()/getPrompt() - 一台一台地断 / 问它此刻按的哪份配置(2026-08-18):
McpRegistry.disconnect(name)/configOf(name)。在这之前只有disconnectAll()—— 而「让一台 server 在这个进程里消失」是装配层那条「应用mcp.json的改动」 必须做到的事(runtime 的applyMcpConfig):全断再全连会把宿主注入的和插件带来的 那两批也连带断掉,而它们一个字节都不在那份文件里。 ⚠️disconnect()连带失效 schema 缓存(同reconnect()),但不碰工具表 —— 把mcp:<名字>那一组工具从ToolRegistry里摘掉是装配层的事
resources 和 prompts 都先看 capability 再问(server 没声明就别问,问了多半得到
-32601 Method not found),都翻页(nextCursor,上限 50 页),都把
method-not-found 当空处理——有些 server 声明了 capability 却没实现,
「没有 resources」比「MCP 初始化失败」更贴近事实。
resources 包成工具而不是注入上下文:一个 server 挂几十份文档是常态,全量注入等于 每一轮都为「模型多半用不到的东西」付 token;而工具是按需调用的,天然懒加载。
⚠️ 写 MCP server 的人请处理 notifications/cancelled
一次 tools/call 有两种结束不掉的方式,两种我们都会发那条通知:
- 用户按停(
ToolContext.signal→ SDK 的RequestOptions.signal) timeout到了(上面那张表里的timeout秒)
通知的形状是 JSON-RPC 规范里那一条:
{ "method": "notifications/cancelled", "params": { "requestId": 2, "reason": "…" } }
不认它的 server 会继续把活干完,而客户端这侧已经没人在等了。实测过一趟真跑的
代价:4 次 invoke → 后端 4 条「收流结束」一条不少,两份跑完的完整报告被扔掉,
外加一次白烧的后端算力。用 SDK 写的 server 自动就有这个行为;手写 JSON-RPC 的
要自己接(按 requestId 记一张在飞表,收到通知就中断那次活,且取消之后
result 和 error 都不发)。
⚠️ 这件事不因为 timeout 配得多长而消失:只要还有超时,「客户端放弃了而
server 还在跑」这个窗口就一直在。
还没做
notifications/tools/list_changed 收得到,但不是热更新:AgentLoop 的工具集是
构造参数,本进程内换不掉。registry 只把「工具表已经不是启动时那份了」冒泡给宿主,
真正换掉要重启。
明确不做的三条(带判据,不是「以后再说」)
- sampling / elicitation(server 反过来请求 client:帮我调一次模型 / 帮我问用户
一句)。它要一个新的
AgentEvent变体加一道权限闸门 —— 也就是动 protocol, 而那是全仓最贵的改动面。硬门槛在权限那一侧而不是协议那一侧:一个 server 能让我们 代它调模型,等于它能花我们的钱、并且把内容塞进我们的上下文,那需要先有一套 「这次反向请求要不要问用户」的语义。要做就先定那套语义,别从 SDK 那头往回接。 - MCP server 的目录 / 市场(对标 hermes 的
mcp_catalog.py+mcp_picker.py)。 ⚠️ 别和方案 32 的插件市场 混为一谈:32 管的是我们自己的插件(~/.epoch/plugins/),这一条管的是 「上哪儿找第三方 MCP server」。两件事,两套来源,两套信任模型。 - MCP server 模式(把 epoch 自己暴露成 MCP server 给别的 agent 用)。
hermes 有
mcp_serve.py、codex 有整个codex-mcp/crate,但那是「被集成」 而不是「集成」。更强的版本连同一条永久红线(真做也只暴露epoch_run, 不把内置工具逐个转出去 —— 那等于让对方绕过我们的权限层)写在 方案 28 验收记录第五章(那一版epoch mcp serve已经砍掉,红线连同重开的条件一起记在那儿)。
依赖
@modelcontextprotocol/sdkzod(配置校验,走 infra 的parseLenient)- protocol 和 infra —— 四个都是
dependencies(本包没有peerDependencies—— 2026-11-03 核)
开发
pnpm --filter @epoch-agent/plugin-mcp test
__tests__/orphan-reap.test.ts 真起 server、真查残留,钉的是
closeTransportAndReap() 那三步(拍快照 → 优雅关 → 清扫快照)。Windows 和 macOS
都跑,改 disconnect 路径必须跑它。
⚠️ 用例里那个「启动器」中间层必须是 shell(Windows 生成一个 .cmd,POSIX 用
/bin/sh -c),换成 node 写的启动器用例就是空的:Windows 上 node 起的非 detached
子进程会被 libuv 的全局 Job Object 连坐收掉,父进程一死孙进程自动没,把清扫整段
删掉照样全绿。shell 也更接近真实形态——npx -y mcp-remote … 在 Windows 上就是被
cross-spawn 改写成 cmd.exe /c npx.cmd … 的。细节记在
验收记录 的「追加二」。