Hermes MCP include 空列表:显式全禁现在在所有界面都算数


你给团队的 Hermes Agent 接了一个文件系统的 MCP 服务器,为了安全,你把它默认能用的工具全部取消勾选,打算等以后真的需要时再逐个放开。配置里确实写下了空列表——tools.include: [],你也以为这事已经生效。直到某天你顺手跑了一下 hermes mcp list,才看见那个服务器旁边写着一个 all:agent 依然能读写那些目录。原因很简单:这个“空列表”在好几个界面上被读成了“这个键没写”,也就是“不过滤”,等于全部放开。

先花一句话说清背景:MCP(Model Context Protocol)是让 Hermes 调用外部服务器所提供工具的协议;而 tools.include 就是某个 MCP 服务器的工具白名单——只有写进这个列表的工具才允许被调用。

先搞清三个状态:没写、写了一部分、写了空列表

tools 这一块其实只有三种状态,但它们的语义差别很大:

  • 没写:整块 tools 不出现,或者只写了别的字段而没有 include。这是“不过滤”,该服务器暴露的工具全部启用。
  • 写了一部分:include: [read_file, list_directory]。这是真正的白名单,只有列出来的工具能用。
  • 写了空列表:include: []。这是显式全禁——一个工具都不注册。

麻烦点在于,后两种在代码里极易被折叠成同一个分支。在 Python 里空列表是假值(falsy),所以 cfg.get("include") or [] 这种写法一旦用上,“用户写了空列表”和“用户什么都没写”就再也区分不开了——而后者恰好是用户意图的反面。

值得注意的是,运行时那一侧一直是对的。tools/mcp_tool_registration.py 在遇到 include: [] 时什么都不注册,一个工具也不会暴露给 agent。也就是说,你的封锁在事实上是生效的,只是界面在骗你。

空列表在一半界面上失踪了

既然运行时没问题,那问题就出在“读取方”。有多个界面在读取配置时,用 or [] 或简单的真值判断处理 include,于是空列表和“键缺失”被当成了同一件事,统一显示成“全部启用”。PR #109188(2026-09-12 合入 main)修的就是这几个分叉的读取方,标题是 “fix(mcp): include: [] no longer reopens as “all tools enabled” in CLI, catalog and desktop”,对应 issue #12865,改动 +107 / −31,涉及 8 个文件。

用一张表看得更清楚(表中的“修前 / 修后”指 PR #109188 之前与之后):

检查点 修复前 修复后
hermes mcp list 显示 all 显示 0 selected
hermes tools 列表行 all tools enabled [include only: (none)]
hermes tools 选择器 每个工具都已预勾选,按确认会把它们全部重新启用 没有预勾选,按确认保持 include: []
运行时 _make_tool_filter("demo")("a") False False(不变)

具体到代码,四段改动值得记住:

  • hermes_cli/mcp_config.py 的 _tool_filters:只要键里装的是列表就返回那个列表(包括空列表);只有键缺失、或者值不是列表时才返回 None。列表输出与 configure 选择器则改成用 is not None 判断,而不是看真假。
  • hermes_cli/tools_config_mcp.py:hermes tools 选择器的预勾选状态,以及列表行显示 [include only: (none)]。
  • hermes_cli/mcp_catalog.py:交互式重装时的预检查会尊重先前的 [],而不是退回清单(catalog)里的默认值。
  • apps/desktop/src/lib/mcp-tool-filter.ts:isToolEnabled 把“存在 include”当作白名单——这一点在下一节细说。

源码注释把这件事说得很直白:

一个显式的 include: [] 是真正的(全禁)白名单,运行时不会注册任何工具,所以它不能在这里被折叠成“没有过滤”。

另外要交代的是,运行时那一侧其实更早就在另一个提交(fb1ec36a4b86)里修好了;本 PR 收拾的是仍然和它分叉的那几个读取方。所以这不是“新增一个功能”,而是让所有界面重新对齐运行时早就确立的语义。

桌面端最隐形的那个坑:删掉键就变成全开

桌面端 MCP 面板里,apps/desktop/src/lib/mcp-tool-filter.ts 的 isToolEnabled 现在把“存在 include”当作白名单来理解。这个改动看着不起眼,但它顺手堵住了一个比 CLI 更隐蔽的坑:当你把最后一个 include 项关掉时,代码会保留 include: [],而不是删掉整个键。

为什么这点重要?因为在配置文件层面上,“空列表”和“键不存在”是完全不同的两件事:

  • 保留 include: [] = 显式全禁,一个工具都不注册。
  • 删掉 include 键 = 不过滤,服务器静默变成全开。

在 UI 上,两者的表现都只是“所有勾选框都没勾上”,肉眼完全分不出来。删键这个操作看起来像是在做清洁,实际上是把封锁悄悄拆掉了。所以如果你的团队里有脚本或习惯会“顺手删掉空字段”,那正是最需要提防的写法。

顺带交代一下来源:桌面那一段提交是从 #52874(@Bartok9,本身是对 #13096 @dingn42 的抢救)cherry-pick 过来的。

怎么正确地把一个 MCP 服务器锁死

推荐的做法是把意图显式写进 YAML,而不是依赖界面的默认值:

mcp_servers:
  demo:
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]
    tools:
      include: []        # 显式全禁:一个工具也不注册
  demo2: {}              # 没写 tools 键 = 不过滤,全部启用

写完之后,按这个顺序验证一遍:

  1. 运行 hermes mcp list,demo 旁边应该显示 0 selected,而不是 all。
  2. 运行 hermes tools,对应那行应该显示 [include only: (none)];进到选择器里,不应该有任何工具处于预勾选状态,直接确认也不会把它们打开。
  3. 之后需要放开某个工具时,把它的名字加进 include 列表,而不是把整个键删掉。

如果你是从旧版本升上来的,建议顺便核对一遍现有配置:只要某台服务器属于“本来想全禁、后来一直没人动过”的状态,就检查它到底是写了 include: [],还是干脆没写 include——两者看起来几乎一样,效果却完全相反。

进一步阅读:想了解 MCP 配置里上下文变量怎么用,可以看 Hermes MCP config context variables;想系统理解 MCP 工具面的安全边界,可以看 Hermes MCP catalog security guide;如果你还在挑该给 agent 接哪些服务器,Hermes official remote MCP catalog 是一份不错的起点;还没装上 Hermes 的话,安装指南 讲清了依赖和第一步。

小结

  • tools.include: [] 从来就不是“没配置”,它是显式全禁。运行时一直这么理解,只是有多个界面曾经读错。
  • PR #109188(2026-09-12 合入 main)让 hermes mcp list、hermes mcp configure、hermes tools、目录重装和桌面端 MCP 面板都与运行时对齐:显式空列表 = 0 个已选。
  • 最容易踩的坑是“删掉键”:include: [] 和“没有 include”在界面上长得一样,语义却正好相反。
  • 验证成本很低:hermes mcp list 显示 0 selected,就说明你的封锁是站得住的。