Un serveur MCP a envoyé une capture d'écran et votre modèle n'a vu qu'un chemin de fichier


Vous connectez un serveur MCP capable de capturer l’écran — le genre qui prend la capture lui-même et la renvoie comme résultat. Vous demandez à Hermes de regarder ce qui est à l’écran maintenant. Il se met docilement au travail puis rapporte : tout ce que je vois, c’est un chemin de fichier. Alors vous commencez à suspecter la config du serveur, puis les permissions du répertoire de cache, puis si le modèle prend en charge la vision tout court. Vous finissez par découvrir que l’image était là depuis le début — elle n’a simplement jamais franchi la couche des résultats d’outil pour arriver dans les mains du modèle. Deux changements fusionnés le 6 octobre ont bouché ce trou, et le correctif n’est pas « ouvrir une porte dérobée pour MCP » : c’est ramener quatre chemins à une seule décision.

Ce qui se passait avant

L’ImageContent d’un outil MCP est écrit dans le cache d’images de Hermes et renvoyé dans le résultat sous forme de ligne MEDIA:/path/to/image.png. C’est réellement utile en soi — toute surface capable de pièces jointes vous laisse extraire le fichier — mais l’image elle-même n’était jamais attachée au résultat d’outil. Cela produisait un état gênant :

  • votre modèle principal peut voir les images (supports_vision=True) ;
  • le serveur MCP a renvoyé une capture d’écran ;
  • le modèle a reçu une chaîne de chemin, et vous a dit en toute vérité qu’il ne peut pas voir l’image.

Le cas le plus embarrassant est un serveur MCP cua-driver brut : son métier entier est de capturer l’écran pour vous, et il ne pouvait pas voir l’écran du tout. Le même problème se trouvait dans les captures computer_use, dont la route ne vérifiait que « le provider transporte-t-il bien des médias de résultat d’outil » — elle ne regardait jamais agent.image_input_mode, ni un verdict de catalogue indiquant que le modèle ne peut pas voir les images. Donc sur un modèle texte uniquement (le commit cite deepseek-v3.2 sur OpenRouter), la capture passait en natif, le filet de sécurité de l’agent remplaçait la capture par un message « basculez vers un modèle capable de vision », et le modèle de vision auxiliaire que vous aviez configuré ne tournait jamais. image_input_mode: text était aussi ignoré pour les captures d’écran.

Maintenant : un seul point de contrôle pour tout ça

Le correctif ramène la décision à une seule fonction : tools.vision_tools._native_tool_result_images(provider, model, cfg). Elle répond à une seule question — cette image peut-elle vraiment transmettre des pixels au modèle ? — et deux conditions doivent être réunies : le routage d’images se résout en native (décidé ensemble par agent.image_input_mode, un backend auxiliary.vision explicitement configuré, et le catalogue de modèles) et la route accepte les images à l’intérieur des résultats d’outil.

Sa docstring le dit simplement : « Un seul prédicat, pour que la voie ne dépende jamais de l’outil qui a produit l’image. » Quatre sources passent désormais par ce même point de contrôle :

Source Avant Maintenant
vision_analyze déjà correct inchangé
Captures d’écran du navigateur déjà correct inchangé
Captures computer_use ignorait image_input_mode, passait en natif même sur les modèles texte uniquement suit le point de contrôle ; les modèles texte uniquement reçoivent la description de vision auxiliaire
MCP ImageContent chemin seulement pixels attachés dès que la route peut les prendre en natif

Pour le chemin MCP en particulier : l’image est prise depuis l’écriture en cache de cet appel lui-même (les chemins ne sont jamais extraits du texte du serveur), ré-encodée dans un format accepté par le provider et dimensionnée selon vision.embed_target_bytes (256 KiB par défaut, borné entre 64 KiB et 4 MiB) et un plafond de 1568 px, au plus 4 par résultat — parce que l’image est renvoyée à chaque requête suivante. Le redimensionnement s’exécute sur le thread de l’outil, hors de la boucle MCP partagée. La moitié texte conserve ses chemins MEDIA:, donc « donne-moi le fichier » fonctionne toujours, et les plugins qui appellent ctx.call_mcp reçoivent toujours une chaîne.

Trois modes, trois comportements

Avant de raisonner sur le point de contrôle, connaissez les trois valeurs de agent.image_input_mode, qui décident si les pixels entrent ou non dans l’historique de conversation :

agent:
  image_input_mode: auto      # auto (défaut) | native | text
  • auto (défaut) : natif quand le modèle rapporte supports_vision=True et que vous n’avez pas défini explicitement auxiliary.vision.provider ; sinon la route texte.
  • native : attache toujours l’image au résultat d’outil. Un modèle texte uniquement échoue alors soit chez le provider, soit via un repli texte de dernière chance — donc ne l’activez pas pour les modèles qui ne peuvent pas voir.
  • text : pré-analyse toujours avec vision_analyze et préfixe la description. L’intérêt de cette voie est que les pixels n’entrent jamais dans l’historique — le bon réglage quand vous ne voulez pas que les captures d’écran aillent à votre modèle principal, ou quand votre provider facture par image.

Une règle plus ancienne mérite d’être retenue : les alias de proxy que le catalogue ne connaît pas vont au modèle de vision auxiliaire sous auto. Pour les garder en natif, mettez native explicitement.

Une conclusion pratique : si vous faites tourner un serveur MCP de type capture d’écran et que votre modèle principal voit déjà les images, le auto par défaut fonctionnera désormais tout seul. Et si vous aviez basculé image_input_mode sur text pour contourner « le modèle ne voit pas l’image », vous pouvez le remettre sur auto pour qu’il obtienne les pixels — au prix de transporter cette image à chaque requête.

Deux détails de sécurité faciles à manquer

Les résultats MCP sont des entrées non fiables, donc ces deux points comptent :

  • Les caractères TAG invisibles sont supprimés. La plage U+E0000–U+E007F ne s’affiche pas dans les terminaux et les interfaces de chat mais est parfaitement visible pour le modèle — le canal de contrebande classique pour l’injection de prompt depuis un serveur malveillant ou compromis. Hermes les retire des résultats d’outil, du contenu des ressources et des descriptions d’outils, tandis que les séquences de tag emoji légitimes (drapeaux régionaux comme 🏴󠁧󠁢󠁳󠁣󠁴󠁿) sont préservées.
  • Le _meta du fournisseur est exposé ; les clés réservées au protocole ne le sont pas. Quand un serveur attache un mapping _meta (espaces de noms de fournisseur comme com.example/handoff), il est transmis au modèle aux côtés du résultat. Les clés sous des préfixes réservés au protocole tels que modelcontextprotocol ou mcp sont écartées, et s’il ne reste rien de tourné vers le modèle, le champ _meta est entièrement omis.

Une courte liste d’auto-vérification

  • Le modèle ne voit pas les images MCP → confirmez quelle voie prend cette route : agent.image_input_mode est-il réglé sur text, et auxiliary.vision.provider est-il défini explicitement (ce qui force la description auxiliaire) ?
  • Il dit que l’image est trop petite pour être lue → augmentez vision.embed_target_bytes (jusqu’à 4 MiB), en vous rappelant qu’elle est renvoyée à chaque tour et consomme du contexte dans les longues conversations.
  • Un résultat porte beaucoup d’images → au plus 4 sont attachées ; les autres restent sous forme de chemins MEDIA: dans le texte, donc demandez celle dont vous avez besoin via vision_analyze.
  • Vous voulez savoir ce qui est à l’écran sans laisser la capture dans l’historique → réglez image_input_mode: text.

Pour conclure

  • MCP ImageContent ne rapportait auparavant qu’un chemin, donc même un modèle capable de vision ne lisait aucun pixel.
  • tools.vision_tools._native_tool_result_images est désormais le point de contrôle unique, partagé par vision_analyze, les captures d’écran du navigateur, les captures computer_use et les images MCP.
  • Les trois valeurs d’image_input_mode : auto suit la capacité du modèle, native attache toujours, text garde les pixels hors de l’historique via le descripteur auxiliaire.
  • Les résultats MCP voient leurs caractères TAG invisibles supprimés et leurs clés _meta réservées au protocole retenues.

Deux autres articles s’accordent bien avec celui-ci : les variables de contexte dans la config MCP pour aligner la config sur le contexte, et les frontières de sécurité du catalogue MCP pour durcir une fois que des serveurs tiers entrent en jeu.