Pourquoi votre outil MCP recevait « 5 » au lieu de 5

Vous avez écrit votre propre serveur MCP, généré le schéma depuis un modèle pydantic ou une définition zod, et testé l’outil tout seul. Il fonctionnait. Puis vous l’avez branché sur Hermes et quelque chose a cloché : un count qui est manifestement un entier arrive dans l’outil sous la forme de la chaîne "5". Un indicateur booléen arrive sous la forme "true". Un argument de filtres qui devrait être un objet arrive sous la forme d’une chaîne JSON échappée. Personne n’a mal saisi quoi que ce soit, et le modèle n’a pas déraillé — l’outil a simplement reçu les mauvais types.
Le décalage ne vient ni de votre serveur, ni du modèle. Il se niche dans le court trajet entre les deux. Avant qu’un appel d’outil ne soit remis à son implémentation, Hermes met les arguments en ordre par rapport au schéma de l’outil — coercition des types et démappage des clés. Pendant longtemps, cette étape ne lisait que le schéma littéral et ne suivait jamais un $ref, or c’est précisément là que pydantic et zod aiment loger le vrai type. La PR #137862 (fusionnée dans main le 13 octobre 2026) comble ce trou.
Le symptôme : même outil, tantôt un nombre, tantôt une chaîne
D’abord ce que fait réellement cette passe sur les arguments, car les deux moitiés comptent ici. Avant la distribution, chaque appel d’outil passe par coerce_tool_args, qui lit le schéma de l’outil et :
- coerce les types — quand le modèle écrit
5sous la forme de la chaîne"5", ou remet un objet sous la forme d’une chaîne JSON, cette passe le reconvertit en ce que déclare le schéma ; - dé-mappe les noms de clés — certains providers rejettent les noms de propriétés contenant des espaces, si bien que le schéma est assaini avant d’atteindre le modèle (
first namedevientfirst_name). Cette passe remet les clés du modèle sur les noms que l’outil connaît réellement.
Avant le correctif, les deux étapes ne regardaient que l’objet de schéma posé devant elles. Une propriété écrite "count": {"type": "integer"} se comportait parfaitement. Dès qu’elle était écrite "count": {"$ref": "#/$defs/Count"}, la passe ne voyait plus aucun type — elle ne comprenait pas les références, en concluait qu’il n’y avait aucune information de type et ne faisait rien. La valeur arrivait à l’outil telle quelle.
Pourquoi les serveurs MCP tombent si facilement dedans
Ce n’est la faute d’aucun serveur en particulier ; c’est ce que les SDK courants émettent par défaut :
- Avec pydantic (le choix habituel côté Python pour un serveur MCP), un sous-modèle répété est hissé dans
$defset la propriété conserve un$refqui pointe dessus. - Avec zod-to-json-schema (le choix habituel côté TypeScript), un schéma réutilisé une seconde fois est référencé par le chemin de sa première occurrence, par exemple
#/properties/limit/anyOf/0.
L’adaptateur MCP de Hermes conserve ces références telles quelles — il ne fait que renommer definitions en $defs. Les serveurs qui suivent les conventions des SDK sont donc les plus susceptibles d’être touchés, y compris ceux que vous connectez en externe, comme ceux du catalogue officiel de MCP distants.
Avant/après : ce que l’outil reçoit réellement
Même schéma, même sortie de modèle, valeur différente à la frontière de l’outil :
| Valeur envoyée par le modèle | Type derrière le $ref |
Avant | Après |
|---|---|---|---|
"count": "5" |
entier | "5" |
5 |
"verbose": "true" ($ref plus un description frère) |
booléen | "true" |
true |
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" |
objet contenant un tableau | une chaîne JSON | {"labels": ["a", "b"]} |
"offset": "7" (ref #/properties/limit/anyOf/0) |
entier | "7" |
7 |
"person": {"first_name": "Ada"} |
objet dont la clé est first name |
l’outil recevait first_name |
l’outil reçoit first name |
Cette dernière ligne est l’autre mode de défaillance, et le plus déroutant : votre outil se plaint que le paramètre requis first name est manquant, alors que votre appel contient visiblement un champ qui y ressemble presque à s’y méprendre. L’assainisseur de schéma renomme aussi les clés à l’intérieur des $defs, mais la passe de démappage ne parcourait que les objets définis en ligne — un objet référencé conservait donc le nom assaini jusqu’à l’outil.
Comment le correctif fonctionne : un seul résolveur de pointeurs local
Le nouveau résolveur s’appelle resolve_local_schema_ref et vit dans tools/schema_sanitizer.py, afin que la coercition et le démappage des clés partagent une seule implémentation au lieu de diverger. Ses règles sont courtes :
- Références locales au document uniquement. Un fragment commençant par
#est décodé en pourcentage, puis lu comme un pointeur JSON RFC 6901 capable de parcourir aussi bien des objets que des indices de tableau — c’est ainsi qu’on atteint le chemin de styleanyOf/0. - Les cycles s’arrêtent. Une chaîne de références s’interrompt au premier doublon, et les unions récursives
anyOf/oneOf/allOfsont bornées par branche pour que les autres alternatives d’une union récursive survivent. - Les mots-clés frères restent.
{"$ref": ..., "description": ...}conserve sondescription; lorsqu’un frère entre en conflit avec le schéma référencé, c’est le schéma référencé qui gagne. - Le coût se limite à quelques lectures de dict par propriété, et uniquement pour les propriétés qui portent réellement un
$ref.
Les limites assumées
Le correctif est volontairement étroit. Ces cas laissent le schéma exactement tel quel, si bien que ces arguments se comportent encore à l’ancienne :
- les références distantes (pointant vers un autre fichier ou une URL) ;
- les ancres à nom simple comme
#Address, qui ne sont pas des pointeurs ; - les échappements
~invalides et les pointeurs pendants qui ne résolvent rien.
Une limitation préexistante reste elle aussi à l’extérieur du correctif, avec ou sans $ref : quand une union n’a pas de type au niveau supérieur (par exemple anyOf: [object, null]), une chaîne JSON envoyée pour celle-ci par le modèle n’est toujours pas analysée.
Ce que cela implique pour votre installation
Le correctif a été fusionné le 13 octobre, tandis que la version étiquetée la plus récente, v0.21.7, a été découpée le 11 octobre — il n’est donc encore présent dans aucune version publiée et arrivera avec la suivante. Deux options :
- Vous voulez que votre serveur MCP reçoive les bons types dès maintenant ? Suivez
mainavechermes update --set-channel main. Ce basculement est documenté dans v0.21.7 : les installations depuis les sources suivent les versions étiquetées par défaut, et vous optez pour chaque commit avec ce canal. - Vous restez sur stable ? Attendez la prochaine version. D’ici là, si un outil MCP est particulièrement sensible aux types, décrivez les types attendus dans la description de l’outil, ou analysez en tolérance côté serveur pour que
"5"soit accepté comme 5.
Une commande vous dit si votre copie contient déjà le résolveur :
grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py
Une sortie signifie que le correctif est présent ; aucune sortie signifie qu’il ne l’est pas. La façon dont vous avez connecté le serveur — une commande locale, une URL distante ou un serveur MCP adossé à une application — ne change rien ici, car la passe sur les arguments s’exécute pour chaque outil. Pour la configuration environnante, voyez la référence des commandes et l’article sur les variables de contexte MCP.
La version courte
Quand un schéma garde ses types derrière un $ref, Hermes passait droit à côté, si bien que les arguments atteignaient l’outil exactement tels que le modèle les avait écrits. Le correctif résout les références locales au document avant la distribution et, chemin faisant, remappe aussi les noms de clés assainis à l’intérieur des objets référencés. Les références distantes, les pointeurs pendants et les unions sans type au niveau supérieur restent hors périmètre — ce sont des limites délibérées, pas des oublis.