Warum Ihr MCP-Tool „5“ statt 5 bekam

Sie haben Ihren eigenen MCP-Server geschrieben, das Schema aus einem pydantic-Modell oder einer zod-Definition generiert und das Tool für sich allein getestet. Es funktionierte. Dann haben Sie es an Hermes angeschlossen, und irgendetwas fühlte sich falsch an: Ein count, das eindeutig ein Integer ist, taucht im Tool als String "5" auf. Ein Boolean-Flag kommt als "true" an. Ein filters-Argument, das ein Objekt sein sollte, kommt als escapter JSON-String an. Niemand hat etwas falsch getippt, und das Modell hat sich nicht danebenbenommen — das Tool hat schlicht die falschen Typen bekommen.
Die Lücke steckt weder in Ihrem Server noch im Modell. Sie sitzt auf dem kurzen Weg dazwischen. Bevor ein Tool-Call an seine Implementierung übergeben wird, bringt Hermes die Argumente anhand des Tool-Schemas in Ordnung — Typ-Coercion und Key-Unmapping. Lange Zeit las dieser Schritt nur das wörtliche Schema und folgte nie einem $ref, und genau dort legen pydantic und zod den echten Typ gerne ab. PR #137862 (am 13. Oktober 2026 in main gemergt) schließt diese Lücke.
Das Symptom: dasselbe Tool, mal eine Zahl, mal ein String
Zuerst, was dieser Argument-Durchlauf tatsächlich tut, denn beide Hälften davon sind hier wichtig. Vor dem Dispatch läuft jeder Tool-Call durch coerce_tool_args, das das Schema des Tools liest und:
- Typen coerct — wenn das Modell
5als String"5"schreibt oder ein Objekt als JSON-String übergibt, wandelt dieser Durchlauf das zurück in das, was das Schema deklariert; - Schlüsselnamen zurückmappt — manche Provider lehnen Property-Namen mit Leerzeichen ab, deshalb wird das Schema sanitisiert, bevor es das Modell erreicht (
first namewird zufirst_name). Dieser Durchlauf mappt die Schlüssel des Modells zurück auf die Namen, die das Tool wirklich kennt.
Vor dem Fix schauten beide Schritte nur auf das Schema-Objekt direkt vor ihnen. Eine Property der Form "count": {"type": "integer"} verhielt sich einwandfrei. Sobald sie als "count": {"$ref": "#/$defs/Count"} geschrieben war, sah der Durchlauf überhaupt keinen type — er verstand keine Referenzen, schloss daraus, dass keine Typinformation vorliegt, und tat nichts. Der Wert ging unangetastet ans Tool.
Warum MCP-Server so leicht hineinlaufen
Das ist kein Fehler in einem bestimmten Server; es ist das, was die gängigen SDKs standardmäßig ausgeben:
- Bei pydantic (die übliche Wahl für MCP-Server auf Python-Seite) wird ein wiederkehrendes Sub-Modell nach
$defshochgezogen, und die Property behält einen$ref, der darauf zeigt. - Bei zod-to-json-schema (die übliche Wahl auf TypeScript-Seite) wird ein zweimal genutztes Schema über den Pfad seines ersten Vorkommens referenziert, zum Beispiel
#/properties/limit/anyOf/0.
Der MCP-Adapter von Hermes lässt diese Referenzen so, wie sie sind — er benennt nur definitions in $defs um. Server, die den SDK-Konventionen folgen, sind also am ehesten betroffen, auch die, die Sie extern anbinden, etwa die aus dem offiziellen Remote-MCP-Katalog.
Vorher und nachher: Was das Tool tatsächlich empfängt
Dasselbe Schema, dieselbe Modellausgabe, ein anderer Wert an der Tool-Grenze:
| Wert vom Modell | Typ hinter dem $ref |
Vorher | Nachher |
|---|---|---|---|
"count": "5" |
integer | "5" |
5 |
"verbose": "true" ($ref plus ein description-Geschwister) |
boolean | "true" |
true |
"filters": "{\"labels\": \"[\\\"a\\\", \\\"b\\\"]\"}" |
Objekt mit einem Array | ein JSON-String | {"labels": ["a", "b"]} |
"offset": "7" (ref #/properties/limit/anyOf/0) |
integer | "7" |
7 |
"person": {"first_name": "Ada"} |
Objekt, dessen Schlüssel first name ist |
das Tool bekam first_name |
das Tool bekommt first name |
Die letzte Zeile ist der andere Fehlermodus, und der verwirrendere: Ihr Tool beschwert sich, der erforderliche Parameter first name fehle, während Ihr Call offensichtlich ein Feld enthält, das fast identisch aussieht. Der Schema-Sanitizer benennt auch Schlüssel innerhalb von $defs um, aber der Unmapping-Durchlauf lief nur durch inline stehende Objekte — ein referenziertes Objekt behielt den sanitisierten Namen also bis zum Tool.
Wie der Fix funktioniert: ein lokaler Pointer-Resolver
Der neue Resolver heißt resolve_local_schema_ref und liegt in tools/schema_sanitizer.py, damit Coercion und Key-Unmapping eine einzige Implementierung teilen, statt auseinanderzudriften. Seine Regeln sind klein:
- Nur dokumentlokale Referenzen. Ein Fragment, das mit
#beginnt, wird prozent-dekodiert und dann als RFC-6901-JSON-Pointer gelesen, der sowohl durch Objekte als auch durch Array-Indizes laufen kann — so wird der Pfad im StilanyOf/0erreicht. - Zyklen stoppen. Eine Referenzkette hält beim ersten Wiederholen an, und rekursive
anyOf/oneOf/allOf-Unions werden pro Zweig begrenzt, damit die übrigen Alternativen einer rekursiven Union überleben. - Geschwister-Keywords bleiben.
{"$ref": ..., "description": ...}behält seinedescription; wo ein Geschwister mit dem referenzierten Schema in Konflikt steht, gewinnt das referenzierte Schema. - Die Kosten sind ein paar Dict-Lookups pro Property, und nur für Properties, die tatsächlich einen
$reftragen.
Die ehrlichen Grenzen
Der Fix ist bewusst eng gefasst. In diesen Fällen bleibt das Schema genau wie es war, diese Argumente verhalten sich also weiter wie zuvor:
- Remote-Referenzen (die auf eine andere Datei oder URL zeigen);
- reine Namens-Anker wie
#Address, die keine Pointer sind; - ungültige
~-Escapes und ins Leere laufende Pointer, die zu nichts auflösen.
Eine bereits vorher bestehende Einschränkung liegt ebenfalls außerhalb des Fixes, mit oder ohne $ref: Wenn eine Union kein Top-Level-type hat (etwa anyOf: [object, null]), wird ein JSON-String, den das Modell dafür schickt, weiterhin nicht geparst.
Was das für Ihre Installation bedeutet
Der Fix wurde am 13. Oktober gemergt, während das jüngste getaggte Release v0.21.7 am 11. Oktober geschnitten wurde — er ist also noch in keinem Release ausgeliefert und kommt mit dem nächsten. Zwei Optionen:
- Soll Ihr MCP-Server sofort saubere Typen bekommen? Folgen Sie
mainmithermes update --set-channel main. Dieser Wechsel ist in v0.21.7 dokumentiert: Source-Installationen folgen standardmäßig getaggten Releases, und mit diesem Channel melden Sie sich für jeden Commit an. - Sie bleiben auf stable? Warten Sie auf das nächste Release. Bis dahin: Wenn ein MCP-Tool besonders typempfindlich ist, beschreiben Sie die erwarteten Typen in der Tool-Beschreibung, oder parsen Sie serverseitig nachsichtig, sodass
"5"als 5 akzeptiert wird.
Ein Befehl verrät Ihnen, ob Ihr Checkout den Resolver schon enthält:
grep -n "resolve_local_schema_ref" ~/.hermes/hermes-agent/tools/schema_sanitizer.py
Ausgabe bedeutet, der Fix ist da; keine Ausgabe bedeutet, er ist nicht da. Wie Sie den Server angebunden haben — als lokaler Befehl, als Remote-URL oder als anwendungsgestützter MCP-Server — spielt hier keine Rolle, denn der Argument-Durchlauf läuft für jedes Tool. Zur umgebenden Konfiguration siehe die Befehlsreferenz und den Artikel zu den MCP-Kontextvariablen.
Die Kurzfassung
Wenn ein Schema seine Typen hinter einem $ref versteckt, schaute Hermes früher einfach daran vorbei, und die Argumente erreichten das Tool genau so, wie das Modell sie geschrieben hatte. Der Fix löst dokumentlokale Referenzen vor dem Dispatch auf und mappt unterwegs auch innerhalb referenzierter Objekte die sanitisierten Schlüsselnamen zurück. Remote-Referenzen, ins Leere laufende Pointer und Unions ohne Top-Level-Typ bleiben weiterhin außen vor — das sind bewusste Grenzen, keine Versehen.