MCP 服务器发来一张截图,你的模型却只看到一行文件路径


你给 Hermes 接了一个能做屏幕捕获的 MCP 服务器——那种自己截屏、把图作为结果返回的。你让它“看看现在屏幕上是什么”,它很配合地开始工作,然后告诉你:我这边只看到一个文件路径。于是你开始怀疑服务器没配好、缓存目录权限不对、模型不支持视觉……排查一圈才发现,图一直都在,只是被拦在工具结果这一层,从没真正交到模型手上。这个坑在 10 月 6 日合并的两个改动里被填掉了,而且改法不是“给 MCP 单独开个后门”,而是把四条路径合并成同一个判断。

之前发生了什么

MCP 工具的 ImageContent 会被写进 Hermes 的图片缓存,然后在结果里以一行 MEDIA:/path/to/image.png 的形式返回。这本身是有用的(任何支持附件的界面都能让你把这文件取出来),但它从来没把图片本身附加到工具结果上。于是出现一种很别扭的状态:

  • 你用一个能看图的模型当主力(比如 supports_vision=True 的模型);
  • MCP 服务器返回了截图;
  • 模型只拿到路径字符串,于是它诚实地告诉你它看不到图像。

最尴尬的例子是原生的 cua-driver MCP 服务器:它本来做的就是“截屏给你看”,结果整个屏幕它都看不到。同类问题还出现在 computer_use 的截屏上:那条路径只检查“provider 是否携带工具结果媒体”,从不看 agent.image_input_mode,也不看目录里是否标注了这个模型看不见图。后果是:在一个只有文本能力的模型上(代码里举的例子是 OpenRouter 上的 deepseek-v3.2),截屏会被判成“走原生”,然后 agent 的兜底逻辑把图片换成一句“请换成支持视觉的模型”,而你明明配好的辅助视觉模型压根没被调用。你设的 image_input_mode: text 也照样被无视。

现在:唯一一道门禁

改动把判断收敛到一个函数:tools.vision_tools._native_tool_result_images(provider, model, cfg)。它的含义是:这张图能不能真的把像素交给模型? 条件是两个同时成立——图像路由解析为 native(由 agent.image_input_mode、显式配置的 auxiliary.vision 后端、模型目录三方共同决定)并且这条线路接受“工具结果里带图片”。

函数上一句注释写得很直白:同一道谓词,所以这条通道不再取决于图片是哪个工具产生的。现在走同一道门禁的有四个来源:

来源 之前 现在
vision_analyze 本来就正确 不变
浏览器截图 本来就正确 不变
computer_use 截屏 忽略 image_input_mode,对纯文本模型也走原生 与门禁一致,纯文本模型交给辅助视觉模型描述
MCP ImageContent 只给 MEDIA: 路径 能原生看图时把像素一并附加

MCP 这一路的具体处理是:从本次调用自己写的缓存文件里取图(绝不从服务器返回的文本里解析路径),重新编码成 provider 接受的格式,按 vision.embed_target_bytes(默认 256 KiB,夹在 64 KiB–4 MiB 之间)和 1568 px 上限规整,每个结果最多 4 张——因为图片会被之后每一轮请求重新发送。缩放跑在工具线程上,不占 MCP 共用的循环。文本那一半保留 MEDIA: 路径,所以“取文件”这条路仍然通。插件通过 ctx.call_mcp 拿到的仍然是字符串,契约没变。

三种模式,行为各不相同

看这个门禁之前,先弄清 agent.image_input_mode 的三个取值——它决定了“像素到底要不要进对话历史”:

agent:
  image_input_mode: auto      # auto(默认)| native | text
  • auto(默认):模型报告 supports_vision=True 且你没有显式指定 auxiliary.vision.provider,就走原生;否则走文本描述。
  • native:总是把图附到工具结果里。代价是纯文本模型要么在 provider 那一步报错,要么吃到一次“最后一搏”的文本回退——所以别对看不见图的模型开这个。
  • text:总是先用 vision_analyze 预先分析,把描述前置。这条路的意义是像素永远不进历史——对那些不想把截图交给主力模型、或者按图计费的场景,这是你想要的档位。

顺手记住一条老规则:代理别名(catalog 不认识的模型名)在 auto 下会被送去辅助视觉模型,想让它走原生就得显式写 native。

一个实际结论:如果你接了截图类 MCP 服务器、主力模型本身能看图,那么默认的 auto 就能工作;如果你之前为了绕开“模型看不到图”把 image_input_mode 改成了 text,现在可以改回 auto 让它真正拿到像素——代价是每次请求都带着那张图。

两个容易忽略的安全处理

MCP 结果是不受信任的输入,这两个细节值得知道:

  • 不可见的 TAG 字符会被剥掉。 U+E0000–U+E007F 这一段字符在终端和聊天界面里完全不显示,但模型看得见——这是恶意或被攻陷的服务器夹带提示注入的经典通道。Hermes 会把它们从工具结果、资源内容和工具描述里剥除;合法的 emoji 标签序列(比如 🏴󠁧󠁢󠁳󠁣󠁴󠁿 这类区域旗帜)会被保留。
  • 厂商 _meta 会透传,协议保留键不会。 服务器挂在工具结果上的 _meta 映射(例如 com.example/handoff 这种厂商命名空间)会连同结果一起给模型;但 modelcontextprotocol、mcp 这类协议保留前缀下的键会被丢掉。如果剥完没有面向模型的内容,_meta 字段整个不出现。

自查清单

  • 模型看不到 MCP 图片 → 先确认这一路是否走了原生:看 agent.image_input_mode 是不是 text,以及是否显式设了 auxiliary.vision.provider(设了就一定走辅助描述)。
  • 说明“图太大看不清” → 抬高 vision.embed_target_bytes(上限 4 MiB),不过要注意它会被每一轮重发,长对话里会持续占上下文。
  • 一个结果里有很多张图 → 最多附加 4 张;其余仍以 MEDIA: 路径形式留在文本里,需要哪张就明确让 agent 用 vision_analyze 处理。
  • 只想知道屏幕上有什么、又不想把截图留在历史里 → 显式设 image_input_mode: text。

小结

  • MCP 的 ImageContent 以前只给路径,所以能看图的模型也读不到像素。
  • 现在 tools.vision_tools._native_tool_result_images 是唯一门禁,vision_analyze、浏览器截图、computer_use 截屏、MCP 图片四路共用。
  • image_input_mode 三档语义:auto 看模型能力、native 总是附加、text 只走辅助描述(像素不进历史)。
  • MCP 结果会被剥离不可见 TAG 字符,协议保留的 _meta 键不会传给模型。

关于 MCP 这条链,站内还有两份可以直接配合着读:MCP 配置里的上下文变量讲怎么用变量把配置和上下文对齐,MCP 目录的安全边界讲第三方服务器进来之后该怎么设防线。