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 5 como 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 name vira first_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 $defs e a propriedade mantém um $ref apontando 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 estilo anyOf/0 é alcançado.
  • Ciclos param. Uma cadeia de referências para no primeiro repetido, e uniões recursivas anyOf/oneOf/allOf sã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 seu description; 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 main com hermes 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.