Por que a sua ferramenta MCP recebeu "5" em vez de 5

Você escreveu o seu próprio servidor MCP, gerou o schema a partir de um modelo pydantic ou de uma definição zod, e testou a ferramenta isoladamente. Funcionou. Depois você a conectou ao Hermes e algo pareceu estranho: um count que é claramente um inteiro aparece dentro da ferramenta como a string "5". Uma flag booleana chega como "true". Um argumento de filtros que deveria ser um objeto chega como uma string JSON escapada. Ninguém digitou nada errado, e o modelo não se comportou mal — a ferramenta simplesmente recebeu os tipos errados.
A lacuna não está no seu servidor nem no modelo. Ela fica no caminho curto entre os dois. Antes de uma chamada de ferramenta ser entregue à sua implementação, o Hermes ajusta os argumentos conforme o schema da ferramenta — coerção de tipos e reversão dos nomes de chave. Por muito tempo esse passo apenas lia o schema literal e nunca seguia um $ref, que é exatamente onde pydantic e zod gostam de colocar o tipo real. O PR #137862 (mesclado na main em 13 de outubro de 2026) fecha essa lacuna.
O sintoma: a mesma ferramenta, às vezes um número, às vezes uma string
Primeiro, o que essa passagem de argumentos realmente faz, porque as duas metades importam aqui. Antes do despacho, toda chamada de ferramenta passa por coerce_tool_args, que lê o schema da ferramenta e:
- faz coerção de tipos — quando o modelo escreve
5como a string"5", ou entrega um objeto como uma string JSON, essa passagem converte de volta para o que o schema declara; - reverte os nomes de chave — alguns provedores rejeitam nomes de propriedade que contêm espaços, então o schema é sanitizado antes de chegar ao modelo (
first namevirafirst_name). Essa passagem mapeia as chaves do modelo de volta para os nomes que a ferramenta realmente conhece.
Antes da correção, os dois passos olhavam apenas para o objeto de schema à frente deles. Uma propriedade escrita como "count": {"type": "integer"} se comportava perfeitamente. No momento em que era escrita como "count": {"$ref": "#/$defs/Count"}, a passagem não via type nenhum — ela não entendia referências, então concluía que não havia informação de tipo e não fazia nada. O valor ia para a ferramenta sem nenhum ajuste.
Por que os servidores MCP esbarram nisso tão facilmente
Este não é um erro de nenhum servidor específico; é o que os SDKs mainstream emitem por padrão:
- Com pydantic (a escolha usual para servidores MCP em Python), um sub-modelo repetido é elevado para
$defse a propriedade mantém um$refapontando para ele. - Com zod-to-json-schema (a escolha usual no lado TypeScript), um schema reutilizado uma segunda vez é referenciado pelo caminho da sua primeira ocorrência, por exemplo
#/properties/limit/anyOf/0.
O adaptador MCP do Hermes mantém essas referências como estão — ele apenas renomeia definitions para $defs. Então os servidores que seguem as convenções dos SDKs são justamente os mais propensos a serem afetados, incluindo os que você conecta externamente, como os do catálogo oficial de MCP remotos.
Antes e depois: o que a ferramenta realmente recebe
Mesmo schema, mesma saída do modelo, valor diferente na fronteira da ferramenta:
| Valor vindo do modelo | Tipo atrás do $ref |
Antes | Depois |
|---|---|---|---|
"count": "5" |
inteiro | "5" |
5 |
"verbose": "true" ($ref mais um description irmão) |
booleano | "true" |
true |
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" |
objeto contendo um array | uma string JSON | {"labels": ["a", "b"]} |
"offset": "7" (ref #/properties/limit/anyOf/0) |
inteiro | "7" |
7 |
"person": {"first_name": "Ada"} |
objeto cuja chave é first name |
a ferramenta recebeu first_name |
a ferramenta recebe first name |
Essa última linha é o outro modo de falha, e o mais confuso: a sua ferramenta reclama que o parâmetro obrigatório first name está faltando, enquanto a sua chamada contém claramente um campo quase idêntico. O sanitizador de schema também renomeia chaves dentro de $defs, mas a passagem de reversão só percorria objetos inline — então um objeto referenciado mantinha o nome sanitizado até chegar à ferramenta.
Como a correção funciona: um único resolvedor de ponteiros locais
O novo resolvedor é resolve_local_schema_ref, e ele vive em tools/schema_sanitizer.py para que a coerção e a reversão de chaves compartilhem uma única implementação em vez de divergirem com o tempo. As suas regras são poucas:
- Somente referências locais ao documento. Um fragmento que começa com
#é decodificado em percentual e depois lido como um JSON Pointer RFC 6901, que consegue percorrer tanto objetos quanto índices de array — é assim que o caminho no estiloanyOf/0é alcançado. - Ciclos param. Uma cadeia de referências para no primeiro repetido, e uniões recursivas
anyOf/oneOf/allOfsão limitadas por ramo para que as outras alternativas de uma união recursiva sobrevivam. - Palavras-chave irmãs permanecem.
{"$ref": ..., "description": ...}mantém o seudescription; quando uma irmã conflita com o schema referenciado, o schema referenciado vence. - O custo é de algumas consultas de dicionário por propriedade, e apenas para propriedades que realmente carregam um
$ref.
As fronteiras honestas
A correção é deliberadamente estreita. Estes casos deixam o schema exatamente como estava, então esses argumentos ainda se comportam do jeito antigo:
- referências remotas (apontando para outro arquivo ou URL);
- âncoras de nome simples como
#Address, que não são ponteiros; - escapes
~inválidos e ponteiros pendentes que não resolvem para nada.
Uma limitação pré-existente também fica fora da correção, com ou sem $ref: quando uma união não tem type no nível superior (digamos anyOf: [object, null]), uma string JSON que o modelo envia para ela ainda não é interpretada.
O que isso significa para a sua instalação
A correção foi mesclada em 13 de outubro, enquanto a release marcada mais recente, a v0.21.7, saiu em 11 de outubro — então ela ainda não foi publicada em nenhuma release e chegará com a próxima. Duas opções:
- Quer que o seu servidor MCP receba os tipos corretos agora? Acompanhe a
maincomhermes update --set-channel main. Essa troca está documentada na v0.21.7: instalações a partir do código-fonte seguem releases marcadas por padrão, e você opta por cada commit com esse canal. - Prefere ficar no stable? Espere a próxima release. Até lá, se uma ferramenta MCP for especialmente sensível a tipos, descreva os tipos esperados na descrição da ferramenta, ou faça o parsing de forma tolerante no lado do servidor para que
"5"seja aceito como 5.
Um comando te diz se o seu checkout já contém o resolvedor:
grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py
Saída significa que a correção está presente; nenhuma saída significa que não está. Como você conectou o servidor — um comando local, uma URL remota ou um servidor MCP apoiado por aplicação — não faz diferença aqui, porque a passagem de argumentos roda para toda ferramenta. Para a configuração ao redor, veja a referência de comandos e o texto sobre variáveis de contexto do MCP.
A versão curta
Quando um schema guarda os tipos atrás de um $ref, o Hermes costumava passar direto por eles, então os argumentos chegavam à ferramenta exatamente como o modelo os escreveu. A correção resolve referências locais ao documento antes do despacho e, de quebra, também mapeia de volta os nomes de chave sanitizados dentro de objetos referenciados. Referências remotas, ponteiros pendentes e uniões sem um type no nível superior continuam fora do escopo — essas são fronteiras deliberadas, não descuidos.