开源自托管 Agent 项目 Hermes Agent 的 MCP 双向落地:既当客户端又当服务端
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
双向不等于对称。 在 NousResearch/hermes-agent 这个 MIT 许可的自托管 Agent 项目里(注意它和 Nous Research 的开源模型系列同名,本文说的是那个常驻在你机器上的 Agent 程序,不是模型),MCP 客户端方向的全部难点是「外面的东西不可信、会挂、会改名」,服务端方向的全部难点是「主循环不在我手里,我得把自己拆成别人能消费的形状」。这两句话决定了两边的代码长得完全不一样:客户端方向是一个六千多行的连接管理器,服务端方向是两个千行以内的薄壳。
站内已经有几篇打底的:MCP 协议是什么 讲协议本身的角色分工,MCP Server 开发入门 讲你自己写一个 server 的通用做法,MCP 授权加固 讲授权面上的通用风险清单。这篇不重复那些方法论,只做一件事:拿这个具体项目的代码,看双向 MCP 真落到工程里会长出哪些约束、代价和踩点。
一、两个方向分别在解决什么问题
客户端方向解决的是能力扩展:你在配置里写一个 server,它的工具就出现在 Agent 的工具表里,跟内建工具一样被模型调用。这件事听起来简单,真正吃掉代码量的是「外部进程不受你控制」——它可能启动时往终端喷横幅、可能把凭据回显在报错里、可能连 ping 都没实现、可能在你空闲一小会儿之后就悄悄把会话回收掉——仓库注释里点了名的一个编辑器类 server,空闲会话的存活时间短到只有十几秒量级。
服务端方向解决的是另一个问题,而且这个问题很具体:当用户把某个模型的对话交给外部 harness 跑时,那个 harness 拥有主循环、自己构建工具表,Hermes 自己那套更宽的工具面(联网搜索、浏览器操作、视觉分析、技能库、语音合成等)就整体摸不到了。仓库里对这个场景的解法是反过来当 server——把一部分工具用 stdio MCP 暴露出去,让外部 harness 当普通 MCP server 注册。
值得先看清的是,服务端方向在这个仓库里是两个不同的 server,用途不重叠:一个暴露消息会话,一个暴露工具。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| MCP 客户端 | 连外部 server、发现并注册工具、重连/保活/回收 | tools/mcp_tool.py | 在配置里加任何一个外部 MCP server 时 |
| 会话桥 server | 把跨平台的聊天会话、消息、事件、审批暴露成 10 个工具 | mcp_serve.py | 想让别的 MCP 客户端读写你的聊天时 |
| 工具面 server | 把一批内建工具暴露给外部 harness 的会话 | agent/transports/hermes_tools_mcp_server.py | 用外部 harness 跑对话又想保住这些工具时 |
| OAuth 客户端胶水 | token/客户端注册落盘、回调端口、浏览器与粘贴兜底 | tools/mcp_oauth.py | 接一个要 OAuth 而非静态 token 的远程 server 时 |
| 配置校验 | 在任何 stdio 拉起之前丢掉形似外泄的 server 条目 | hermes_cli/mcp_security.py | 从别处抄一段 MCP 配置进来时 |
二、客户端方向:把外面接进来,代价在哪
配置读的是 Hermes 配置文件里的 mcp_servers 段。三种传输:给 command + args 走 stdio,给 url 走 Streamable HTTP,写 transport: sse 走 SSE。字符串里的 ${VAR} 会做插值,也认 ${env:VAR} 这种写法。
架构上有一个专用的后台事件循环跑在守护线程里,每个 server 是这个循环上的一个长驻任务,任务本身负责把传输上下文一直撑着;调用工具时从调用方线程把协程投递到那个循环上。关停时的讲究写在模块顶部注释里:必须给每个 server 任务发信号让它自己退出 async with 块,因为清理动作必须发生在当初打开连接的那个任务里。
命名。 注册进去的名字是 mcp__<server>__<tool>,双下划线做分隔符,好处是即便 server 名或工具名自己带下划线也不会切错边界。任何不在 [A-Za-z0-9_] 里的字符会被换成下划线——这里埋着一个真实的坑:归一化是有损的,read-file 和 read_file 会撞成同一个注册名。仓库的处理是失败关闭,撞车的条目全部跳过,不去挑一个「看起来对」的处理函数;跟内建工具撞名时则保内建、丢 MCP 那个。名字冲突这类问题的通用应对在 MCP 工具命名冲突 里聊过,这里的实现选择是宁缺毋滥。
过滤。 tools.include 是白名单、tools.exclude 是黑名单,两者都支持通配写法,include 优先,都不写就全注册。另外有两个布尔开关控制是否生成资源类和提示词类的工具入口。
能力探测。 资源和提示词那四个工具入口不是无条件注册的,而是看 server 在初始化响应里到底有没有声明对应能力。注释里写清了为什么:有些 server 只声明工具能力,早期版本却给它注册了四个桩,模型每次调用都拿回一个「方法不存在」的 JSON-RPC 错误,于是模型判定整个 server 坏了——哪怕它真正的工具全是好的。
保活与回收。 HTTP/SSE 会话按协议规范允许服务端用任意 TTL 过期掉,所以客户端要主动刷得比对方 TTL 更快。默认保活间隔是 180 秒,可以按 server 调小,有下限夹取防止误配成忙轮询。保活用的 ping 在协议里是可选项,不实现的 server 会回 -32601,代码识别到这个就把心跳降级成列工具;识别逻辑还额外容忍了「Unknown method」这类措辞,否则降级永远不生效、保活会一直触发重连。stdio server 可以配空闲回收和最长存活时间,回收之后下一次工具调用会触发一次懒重连并短暂等待新会话就绪。
反向请求。 外部 server 可以反过来向 Hermes 要东西,这是客户端方向最容易被忽略的一块。一是让 Hermes 替它调模型(默认开启,带每分钟请求数、单次 token 上限、超时、工具轮数上限和模型白名单几道闸);二是中途向用户要结构化输入,这条走的是 Hermes 自己的审批提示,会落到当前会话所在的那个界面上,失败一律按拒绝处理而不是默默同意,URL 模式的直接拒掉。这两件事意味着:你接进来的 server 有能力花你的模型预算、也有能力弹你的审批框,权限模型该怎么收见 最小权限设计。
几处防护。 stdio 子进程拿到的环境变量是白名单过的(PATH、HOME、USER、LANG、TERM、SHELL、临时目录这些,加 XDG 前缀的,加 Windows 那批位置类变量,加被外部密钥后端标记过的,再加你自己在 env: 里写的);返回给模型的报错文本会把常见凭据形状替换成占位符;stdio 子进程的 stderr 全部改写到日志文件,理由很实际——server 的启动横幅直接打到终端会把界面渲染搅乱甚至挂住会话;工具描述会扫一遍提示注入的常见句式,但只告警不拦截,因为误报会直接弄坏正常 server。
三、服务端方向:两个薄壳,各自的硬边界
会话桥由 hermes mcp serve 起,走 stdio,一共 10 个工具:列会话、看单个会话、读消息、取附件引用、拉事件、等事件、发消息、列待审批、回审批,再加一个列可发送目标的。数据源不是内存而是磁盘:会话路由信息从状态库的网关会话行里读,老库回落到早期那个 JSON 索引文件;可发送目标读一份缓存的目录文件。
事件是靠后台线程 200 毫秒轮一次状态库实现的,靠比对文件 mtime 短路——没变就整轮跳过,所以这个高频轮询实际上很便宜。事件队列有条数上限,游标单调递增,等事件的那个工具是长轮询并且有超时上限。
这里有三条必须知道的边界。第一,待审批只包含这个桥启动之后它观察到的那些,桥连上之前的看不见。第二,回审批那个工具在代码注释里写明是「没有网关 IPC 的尽力而为」,它把事件从内存表里摘掉并投一条已解决事件,不要把它当成真正的审批通道。第三,取附件返回的是引用不是字节——它从多模态内容块和文本里的媒体标记中解析出地址或路径给你。另外读消息和事件内容都有截断,外部客户端传进来的数值参数会被强制转换并夹到区间内,这是把 MCP 边界当成不可信输入在防。
工具面 server 用模块方式启动,由外部 harness 按自己的配置文件注册成一个普通 MCP server。它暴露的是那些外部 harness 没有等价物的能力:联网搜索与抽取、一整套浏览器操作、视觉分析、图像生成、技能查看与列举、语音合成,以及一批看板协作工具。
不暴露的分两类,区别很重要。一类是故意不给:终端与 shell、读写文件与打补丁、搜文件、进程操作、澄清提问——外部 harness 自己有,而且它的审批走它自己的界面,重复暴露只会让审批面变糊。另一类是结构上给不了:委派子任务、持久记忆、跨会话检索、待办这几个属于要跑在活着的 Agent 循环里的工具,它们需要循环中态才能派发,一个无状态的 MCP 回调驱动不了。这个区分是我认为整个服务端方向最有信息量的一句话:能不能暴露一个工具,取决于它是不是纯函数式的边界调用。
实现上有个细节值得学:它不手写工具签名,而是从 Hermes 自己的工具定义里拉权威 schema,再把 JSON Schema 反推成 Python 函数签名塞给 FastMCP。原因是 FastMCP 的装饰器靠类型注解生成 schema,而运行期只有 JSON Schema 拿不到注解。没在当前进程注册的工具直接跳过并记一条日志,不会伪造一个空壳。启动时它把静默模式和密钥脱敏两个环境变量设上,所有日志强制走 stderr——stdout 是 MCP 的传输线,往那儿写一个字都是协议污染。
四、OAuth:客户端方向上最费劲的一段
远程 server 要 OAuth 而不是静态 token 时,协议部分(发现、动态客户端注册、PKCE、换取、刷新)交给 MCP SDK 的授权组件;这个仓库补的是它不管的那些工程细节,而这些细节几乎全是被真实 provider 教出来的。
token 和客户端注册信息落成三个文件(token、客户端信息、授权服务器元数据),放在 Hermes 主目录下的专用子目录里,用带独占标志的原子创建方式写成仅属主可读写,父目录也收紧——注释指出先写文件再改权限会留一个按进程 umask 短暂世界可读的窗口。
存的时候额外记一个绝对到期时刻,因为 SDK 序列化的是相对剩余秒数,进程重启后没有挂钟基准,会让「token 还有效」的判断假报为真;旧格式文件则用文件修改时间当写入时刻近似兜底。发现到的授权服务器元数据也要持久化,否则冷启动刷新会回落到猜出来的 token 端点,大多数真实 provider 直接 404,把你逼回一整轮浏览器授权。
回调端口的选择是三级优先:显式配置的端口,其次缓存的客户端注册里那个回调端口,最后才新分配。中间那级不是优化而是必需——provider 把动态注册出来的客户端标识绑死在注册时的那个回调地址上,换了端口就会被判定地址不匹配。新分配走的路径也有讲究:拿到端口后把 socket 一直绑着但不开始监听,直到真的要等回调时才接管它,堵住「选完端口到真正绑定之间被别人抢走」的窗口。回调主机名默认是回环地址,但可以改成 localhost,因为有的 provider 的 WAF 见到授权请求里出现字面回环 IP 就直接拒;监听端始终还是绑回环。
非交互环境有三道闸,都在同一个问题上:后台跑的网关、定时任务、启动时的后台发现,都不该开浏览器流程。构建授权对象时如果既非交互又没有缓存 token 就直接抛错并给出「先在交互式会话里登录一次」的指引;展示授权地址前再查一次;真正等回调前还要再查一次,而且这次是不绑监听就拒——否则会打印一个没人能完成的地址、阻塞满整个超时,下次重试还会撞上仍处于等待关闭状态的端口,冒出「地址已被占用」这种误导性报错。
交互式终端下有个体贴的兜底:授权完成后浏览器会跳到回环地址报连接失败,把地址栏那整串复制粘回提示符即可完成流程,SSH 场景不必额外开隧道;也可以输一个跳过词直接放弃这个 server 继续启动。另外,当授权服务器用「客户端无效」拒掉缓存的客户端标识时,代码会删掉客户端信息与元数据文件强制重新注册(留一份备份),但故意不动 token——万一重新注册没走完,别把还能用的刷新凭据也毁了。
五、边界与代价:它放弃了什么
常驻的代价是真的。 这不是一个跑完就退的 CLI。一个守护线程、若干长驻任务、若干 stdio 子进程会一直在你机器上;仓库里专门有清理孤儿子进程的逻辑,说明这事在实践中确实会漏。如果你的机器是共享的或者跑着别的生产负载,这套东西的资源与副作用面比你想的宽。
会话桥暴露的是你的聊天。 拿到那个 stdio server 的客户端就能列出你连了哪些平台、哪些会话,能读消息内容,也能替你发消息。它没有细粒度的按会话授权,边界就是「谁能启动这个进程」。把它配给一个第三方客户端之前,先想清楚这句话意味着什么。
回审批不是审批通道。 前面说过,那个工具在没有网关进程间通信的前提下是尽力而为。想靠它做远程审批把关的话,会得到一个看起来通了、实际没落到执行侧的假象。
工具面 server 天生给不了一部分能力。 委派、记忆、跨会话检索这些依赖循环态,无状态回调驱动不了。所以「用外部 harness 跑对话 + 借回 Hermes 的工具」这个组合永远不是完整的 Hermes,它是一个功能子集,而且这个子集边界写在代码注释里,不会因为你改配置就变宽。
注入扫描只告警。 工具描述里的可疑句式会记日志但不拦截,理由是误报代价太高。也就是说这一层是给你事后排查用的证据,不是运行时防线。真正的防线还是最小权限和审批。
配置面是本机文件,不是中心化控制面。 单机单进程的模型意味着没有多副本一致性、没有集中下发。九台机器就是九份 YAML。
OAuth 依赖「有人在」。 纯后台部署必须先在一次交互式会话里完成初始授权,之后才靠缓存 token 续命;token 彻底失效时后台是恢复不了的,需要人回来一趟。
六、上手与避坑清单
别用「拿命令行手动跑通了」当验证标准。 会踩是因为 stdio server 在交互终端和在守护进程下的环境完全不同:环境变量白名单过滤掉的东西、TTY 探测的结果、能不能开浏览器,三项都会翻面。避法是直接在目标运行方式下测,尤其是要 OAuth 的 server,先在交互式会话里登录一次再切后台。
接一个陌生 server 前先只放行你要的那几个工具。 会踩是因为默认是全注册,一个 server 塞几十个工具进工具表,既挤上下文又扩大权限面。避法是用白名单,写明要哪几个,等真用上了再往里加。
工具名里有连字符或点号的 server 要额外看一眼日志。 会踩是因为归一化把它们全换成下划线,两个原本不同的名字可能撞成一个,然后两个都被跳过——你会发现某个工具凭空消失,而不是报错。避法是启动后核对注册到的工具名,日志里会打冲突。
空闲一段时间后第一次调用失败,先怀疑保活间隔。 会踩是因为对面的会话 TTL 比默认的 180 秒短,你的会话早被服务端回收了,这次调用要走完整重连才可能成功。避法是把该 server 的保活间隔调到明显小于它的 TTL。
从别人的配置片段抄 MCP server 时,逐条看 command 和 args。 会踩是因为一段 stdio 配置本质上就是「在你机器上执行任意命令」。仓库里确实有一层在拉起之前丢掉形似外泄配置的校验,但那是兜底而不是许可。避法是把每一段抄来的配置当成 shell 脚本审。
别把凭据直接写进 env: 就当安全。 会踩是因为 env: 里的内容会原样传给子进程,而子进程是别人的代码。避法是用外部密钥后端并理解一个后果:被标记过的那些变量会自动进入所有 stdio 子进程的环境。
远程 server 报「回调地址不匹配」时,不要重新走一遍注册。 会踩是因为你可能换了回调端口,而客户端标识被绑在旧地址上。避法是显式固定一个端口,让重启后的地址保持一致。
给外部 harness 暴露工具面之后,遇到整轮对话被拒且每次重放的怪症,去查标识长度。 会踩是因为多层命名前缀叠加会把调用标识撑过接口的 64 字符上限,被非重试性地拒掉,然后这条坏记录每轮都重放。仓库里的处理方式是给超长标识换一个确定性的短代号,你自己搭类似链路时要预留这个空间。
收束
判断这套双向设计好不好,不必看它支持多少种传输,看三个地方就够了:失败时是往严还是往松(这里是撞名全跳过、审批失败按拒绝、非交互直接抛错);边界输入当不当外部输入(这里是数值夹取、内容截断、报错脱敏);给不了的东西有没有说清楚(这里把「结构上给不了」写进了注释而不是留给你猜)。
想自己顺一遍的话,读的顺序建议是:先 mcp_serve.py,它的主体就是一个轮询线程加十个工具函数,看完就明白服务端方向的形状;再看 agent/transports/hermes_tools_mcp_server.py 里那份暴露与不暴露的名单和它的理由,那是整个仓库对「什么叫可外露的工具」最集中的表述;最后再进 tools/mcp_tool.py,从命名与注册那一段往两边扩,比从头顺读省力得多。要接 OAuth 的话,tools/mcp_oauth.py 里每一处防护上面基本都有一段说明它是被什么真实故障教出来的,那些注释比代码本身更值钱。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 自托管开源 Agent 项目 Hermes Agent 的终端环境抽象 和 自托管开源项目 Hermes Agent 的 ACP 适配层。