Por qué tu herramienta MCP recibió «5» en lugar de 5

Escribiste tu propio servidor MCP, generaste el esquema a partir de un modelo pydantic o de una definición zod y probaste la herramienta por separado. Funcionaba. Luego la conectaste a Hermes y algo no cuadraba: un count que es obviamente un entero aparece dentro de la herramienta como la cadena "5". Un flag booleano llega como "true". Un argumento de filtros que debería ser un objeto llega como una cadena JSON escapada. Nadie escribió nada mal y el modelo no se portó mal: la herramienta simplemente recibió los tipos equivocados.
El hueco no está en tu servidor ni en el modelo, sino en el trecho corto que hay entre ambos. Antes de entregar una llamada de herramienta a su implementación, Hermes ordena los argumentos según el esquema de la herramienta: coerción de tipos y desmapeo de claves. Durante mucho tiempo ese paso solo leía el esquema literal y nunca seguía un $ref, que es justo donde pydantic y zod suelen poner el tipo real. La PR #137862 (fusionada en main el 13 de octubre de 2026) cierra ese hueco.
El síntoma: la misma herramienta, a veces un número, a veces una cadena
Primero, qué hace realmente ese paso de argumentos, porque aquí importan sus dos mitades. Antes del despacho, cada llamada a herramienta pasa por coerce_tool_args, que lee el esquema de la herramienta y:
- coerciona tipos — cuando el modelo escribe
5como la cadena"5", o entrega un objeto como cadena JSON, este paso lo convierte de vuelta a lo que declara el esquema; - desmapea nombres de clave — algunos proveedores rechazan nombres de propiedad que contienen espacios, así que el esquema se sanea antes de llegar al modelo (
first namepasa afirst_name). Este paso vuelve a mapear las claves del modelo a los nombres que la herramienta conoce de verdad.
Antes del arreglo, ambos pasos miraban solo el objeto de esquema que tenían delante. Una propiedad escrita como "count": {"type": "integer"} se comportaba perfectamente. En cuanto se escribía como "count": {"$ref": "#/$defs/Count"}, el paso no veía ningún type: no entendía las referencias, así que concluía que no había información de tipo y no hacía nada. El valor llegaba intacto a la herramienta.
Por qué los servidores MCP caen en esto con tanta facilidad
No es un error de ningún servidor concreto; es lo que los SDK habituales emiten por defecto:
- Con pydantic (la opción habitual en los servidores MCP del lado Python), un submodelo repetido se iza a
$defsy la propiedad conserva un$refque apunta a él. - Con zod-to-json-schema (la opción habitual del lado TypeScript), un esquema reutilizado por segunda vez se referencia por la ruta de su primera aparición, por ejemplo
#/properties/limit/anyOf/0.
El adaptador MCP de Hermes mantiene esas referencias tal cual: solo renombra definitions a $defs. Así que los servidores que siguen las convenciones de los SDK son los más propensos a verse afectados, incluidos los que conectas de forma externa, como los del catálogo oficial de MCP remoto.
Antes y después: lo que recibe realmente la herramienta
Mismo esquema, misma salida del modelo, valor distinto en la frontera de la herramienta:
| Valor procedente del modelo | Tipo detrás del $ref |
Antes | Después |
|---|---|---|---|
"count": "5" |
integer | "5" |
5 |
"verbose": "true" ($ref más un description hermano) |
boolean | "true" |
true |
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" |
objeto que contiene un array | una cadena JSON | {"labels": ["a", "b"]} |
"offset": "7" (ref #/properties/limit/anyOf/0) |
integer | "7" |
7 |
"person": {"first_name": "Ada"} |
objeto cuya clave es first name |
la herramienta recibía first_name |
la herramienta recibe first name |
Esa última fila es el otro modo de fallo, y el más confuso: tu herramienta se queja de que falta el parámetro obligatorio first name, mientras tu llamada contiene claramente un campo que se le parece casi igual. El saneador de esquemas también renombra claves dentro de $defs, pero el paso de desmapeo solo recorría objetos en línea, así que un objeto referenciado conservaba el nombre saneado hasta llegar a la herramienta.
Cómo funciona el arreglo: un único resolutor de punteros locales
El nuevo resolutor es resolve_local_schema_ref y vive en tools/schema_sanitizer.py, para que la coerción y el desmapeo de claves compartan una sola implementación en lugar de divergir. Sus reglas son pocas:
- Solo referencias locales al documento. Un fragmento que empieza por
#se decodifica en porcentaje y luego se lee como un JSON Pointer de RFC 6901, capaz de recorrer tanto objetos como índices de array; así se llega a una ruta al estiloanyOf/0. - Los ciclos se detienen. Una cadena de referencias se corta en la primera repetición, y las uniones recursivas
anyOf/oneOf/allOfquedan acotadas por rama, para que las demás alternativas de una unión recursiva sobrevivan. - Las palabras clave hermanas se mantienen.
{"$ref": ..., "description": ...}conserva sudescription; cuando una hermana entra en conflicto con el esquema referenciado, gana el esquema referenciado. - El coste son unas pocas consultas de diccionario por propiedad, y solo para las propiedades que llevan un
$refde verdad.
Los límites, sin adornos
El arreglo es deliberadamente estrecho. Estos casos dejan el esquema exactamente como estaba, así que esos argumentos siguen comportándose como antes:
- referencias remotas (que apuntan a otro archivo o URL);
- anclas de nombre simple como
#Address, que no son punteros; - escapes
~no válidos y punteros colgantes que no resuelven a nada.
También queda fuera del arreglo una limitación que ya existía, con $ref o sin él: cuando una unión no tiene un type de nivel superior (por ejemplo anyOf: [object, null]), una cadena JSON que el modelo envíe para ella sigue sin parsearse.
Qué significa esto para tu instalación
El arreglo se fusionó el 13 de octubre, mientras que la última versión etiquetada, la v0.21.7, se cortó el 11 de octubre, así que todavía no ha salido en ninguna versión y llegará con la siguiente. Dos opciones:
- ¿Quieres que tu servidor MCP reciba tipos correctos ya? Sigue
mainconhermes update --set-channel main. Ese canal está documentado en la v0.21.7: las instalaciones desde código fuente siguen las versiones etiquetadas por defecto, y con este canal te apuntas a cada commit. - ¿Te quedas en estable? Espera a la próxima versión. Mientras tanto, si alguna herramienta MCP es especialmente sensible a los tipos, describe los tipos esperados en la descripción de la herramienta, o parsea de forma tolerante en el lado del servidor para que
"5"se acepte como 5.
Un solo comando te dice si tu copia ya contiene el resolutor:
grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py
Si hay salida, el arreglo está presente; si no hay salida, no lo está. Cómo conectaste el servidor —un comando local, una URL remota o un servidor MCP respaldado por aplicación— da igual aquí, porque el paso de argumentos se ejecuta para todas las herramientas. Para la configuración que lo rodea, consulta la referencia de comandos y la entrada sobre variables de contexto de MCP.
La versión corta
Cuando un esquema guarda sus tipos detrás de un $ref, Hermes solía mirar hacia otro lado, así que los argumentos llegaban a la herramienta tal y como los escribió el modelo. El arreglo resuelve las referencias locales al documento antes del despacho y, de paso, también vuelve a mapear los nombres de clave saneados dentro de los objetos referenciados. Las referencias remotas, los punteros colgantes y las uniones sin un tipo de nivel superior siguen fuera del alcance: son límites deliberados, no descuidos.