Why your MCP tool received "5" instead of 5


You wrote your own MCP server, generated the schema from a pydantic model or a zod definition, and tested the tool on its own. It worked. Then you plugged it into Hermes and something felt off: a count that is clearly an integer shows up inside the tool as the string "5". A boolean flag arrives as "true". A filters argument that should be an object arrives as an escaped JSON string. Nobody typed anything wrong, and the model did not misbehave — the tool simply got the wrong types.

The gap is not in your server and not in the model. It sits in the short path between them. Before a tool call is handed to its implementation, Hermes tidies the arguments against the tool’s schema — type coercion and key unmapping. For a long time that step only read the literal schema and never followed a $ref, which is exactly where pydantic and zod like to put the real type. PR #137862 (merged into main on October 13, 2026) closes that gap.

The symptom: same tool, sometimes a number, sometimes a string

First, what that argument pass actually does, because both halves of it matter here. Before dispatch, every tool call goes through coerce_tool_args, which reads the tool’s schema and:

  • coerces types — when the model writes 5 as the string "5", or hands an object over as a JSON string, this pass converts it back to what the schema declares;
  • unmaps key names — some providers reject property names containing spaces, so the schema is sanitized before it reaches the model (first name becomes first_name). This pass maps the model’s keys back to the names the tool actually knows.

Before the fix, both steps looked only at the schema object in front of them. A property written as "count": {"type": "integer"} behaved perfectly. The moment it was written as "count": {"$ref": "#/$defs/Count"}, the pass saw no type at all — it did not understand references, so it concluded there was no type information and did nothing. The value went to the tool untouched.

Why MCP servers hit this so easily

This is not a mistake in any particular server; it is what the mainstream SDKs emit by default:

  • With pydantic (the usual Python-side MCP server choice), a repeated sub-model is hoisted into $defs and the property keeps a $ref pointing at it.
  • With zod-to-json-schema (the usual TypeScript-side choice), a schema reused a second time is referenced by the path of its first occurrence, for example #/properties/limit/anyOf/0.

Hermes’s MCP adapter keeps those references as they are — it only renames definitions to $defs. So servers that follow the SDK conventions are the ones most likely to be affected, including the ones you connect externally, such as those in the official remote MCP catalog.

Before and after: what the tool actually receives

Same schema, same model output, different value at the tool boundary:

Value from the model Type behind the $ref Before After
"count": "5" integer "5" 5
"verbose": "true" ($ref plus a description sibling) boolean "true" true
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" object containing an array a JSON string {"labels": ["a", "b"]}
"offset": "7" (ref #/properties/limit/anyOf/0) integer "7" 7
"person": {"first_name": "Ada"} object whose key is first name the tool received first_name the tool receives first name

That last row is the other failure mode, and the more confusing one: your tool complains that the required parameter first name is missing, while your call plainly contains a field that looks almost identical. The schema sanitizer renames keys inside $defs too, but the unmapping pass only walked inline objects — so a referenced object kept the sanitized name all the way to the tool.

How the fix works: one local pointer resolver

The new resolver is resolve_local_schema_ref, and it lives in tools/schema_sanitizer.py so that coercion and key unmapping share a single implementation instead of drifting apart. Its rules are small:

  • Document-local references only. A fragment starting with # is percent-decoded, then read as an RFC 6901 JSON Pointer that can walk objects and array indices alike — which is how the anyOf/0 style path is reached.
  • Cycles stop. A reference chain halts at the first repeat, and recursive anyOf/oneOf/allOf unions are bounded per branch so the other alternatives in a recursive union survive.
  • Sibling keywords stay. {"$ref": ..., "description": ...} keeps its description; where a sibling conflicts with the referenced schema, the referenced schema wins.
  • The cost is a few dict lookups per property, and only for properties that actually carry a $ref.

The honest boundaries

The fix is deliberately narrow. These cases leave the schema exactly as it was, so those arguments still behave the old way:

  • remote references (pointing at another file or URL);
  • plain-name anchors such as #Address, which are not pointers;
  • invalid ~ escapes and dangling pointers that resolve to nothing.

One pre-existing limitation also sits outside the fix, with or without a $ref: when a union has no top-level type (say anyOf: [object, null]), a JSON string the model sends for it still is not parsed.

What this means for your install

The fix merged on October 13, while the most recent tagged release, v0.21.7, was cut on October 11 — so it has not shipped in any release yet and will arrive with the next one. Two options:

  • Want your MCP server to receive proper types right now? Follow main with hermes update --set-channel main. That switch is documented in v0.21.7: source installs follow tagged releases by default, and you opt into every commit with this channel.
  • Staying on stable? Wait for the next release. Until then, if one MCP tool is especially type-sensitive, describe the expected types in the tool description, or parse leniently on the server side so "5" is accepted as 5.

One command tells you whether your checkout already contains the resolver:

grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py

Output means the fix is present; no output means it is not. How you connected the server — a local command, a remote URL, or an application-backed MCP server — makes no difference here, because the argument pass runs for every tool. For the surrounding configuration, see the command reference and the MCP context variables write-up.

The short version

When a schema keeps its types behind a $ref, Hermes used to look straight past them, so arguments reached the tool exactly as the model wrote them. The fix resolves document-local references before dispatch and, along the way, maps sanitized key names back inside referenced objects too. Remote references, dangling pointers and unions without a top-level type are still out of scope — those are deliberate boundaries, not oversights.