MCP tool argument type coercion and schema resolution
NousResearch/hermes-agent · cluster 3682 · mcp
Data as of
4 open items. Fix merged in #14811. Check it covers your case.
The short answer
- Known?
- Yes. 12 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #14811. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 2 issues and 2 PRs.
Next step: read #14811, then check whether your report is one of the 2 still open.
Read #14811Pick up a Lab taskThese issues and pull requests address a recurring problem where MCP tool parameters are serialized or passed as strings instead of their native types (numbers, arrays, objects), with fixes targeting JSON-string coercion, anyOf/oneOf union schema resolution, and dynamic dispatch argument handling.
Open issues2
Open PRs2
Fixes on record
-
#14811 fix(mcp): coerce stringified arrays/objects in tool args (salvage #12640)
files:
model_tools.py,scripts/release.py,tests/run_agent/test_tool_arg_coercion.pyShow the diff
-
model_tools.py@@ -418,6 +418,31 @@ def _coerce_value(value: str, expected_type): return _coerce_number(value, integer_only=(expected_type == "integer")) if expected_type == "boolean": return _coerce_boolean(value) + if expected_type == "array": + return _coerce_json(value, list) + if expected_type == "object": + return _coerce_json(value, dict) + return value + + +def _coerce_json(value: str, expected_python_type: type): + """Parse *value* as JSON when the schema expects an array or object. + + Handles model output drift where a complex oneOf/discriminated-union schema + causes the LLM to emit the array/object as a JSON string instead of a native + structure. Returns the original string if parsing fails or yields the wrong + Python type. + """ + try: + parsed = json.loads(value) + except (ValueError, TypeError): + return value + if isinstance(parsed, expected_python_type): + logger.debug( + "coerce_tool_args: coerced string to %s via json.loads", + expected_python_type.__name__, + ) + return parsed return value -
scripts/release.py@@ -167,6 +167,7 @@ "[redacted:email]": "socrates1024", "[redacted:email]": "Salt-555", "[redacted:email]": "satelerd", + "[redacted:email]": "danklynn", "[redacted:email]": "nummanali", "[redacted:email]": "whitehatjr1001", "[redacted:email]": "0xNyk", -
tests/run_agent/test_tool_arg_coercion.py@@ -134,6 +134,31 @@ def test_union_with_string_preserves_original(self): """A non-numeric string in [number, string] should stay a string.""" assert _coerce_value("hello", ["number", "string"]) == "hello" + def test_array_type_parsed_from_json_string(self): + """Stringified JSON arrays are parsed into native lists.""" + assert _coerce_value('["a", "b"]', "array") == ["a", "b"] + assert _coerce_value("[1, 2, 3]", "array") == [1, 2, 3] + + def test_object_type_parsed_from_json_string(self): + """Stringified JSON objects are parsed into native dicts.""" + assert _coerce_value('{"k": "v"}', "object") == {"k": "v"} + assert _coerce_value('{"n": 1}', "object") == {"n": 1} + + def test_array_invalid_json_preserved(self): + """Unparseable strings are returned unchanged.""" + assert _coerce_value("not-json", "array") == "not-json" + + def test_object_invalid_json_preserved(self): + assert _coerce_value("not-json", "object") == "not-json" + + def test_array_type_wrong_shape_preserved(self): + """A JSON object passed for an 'array' slot is preserved as a string.""" + assert _coerce_value('{"k": "v"}', "array") == '{"k": "v"}' + + def test_object_type_wrong_shape_preserved(self): + """A JSON array passed for an 'object' slot is preserved as a string.""" + assert _coerce_value('["a"]', "object") == '["a"]' + # ── Full coerce_tool_args with registry ─────────────────────────────────── @@ -212,6 +237,32 @@ def test_preserves_non_string_values(self): assert result["items"] == [1, 2, 3] assert result["config"] == {"key": "val"} + def test_coerces_stringified_array_arg(self): + """Regression for #3947 — MCP servers using z.array() expect lists, not strings.""" + schema = self._mock_schema({ + "messageIds": {"type": "array", "items": {"type": "string"}}, + }) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"messageIds": '["abc", "def"]'} + result = coerce_tool_args("test_tool", args) + assert result["messageIds"] == ["abc", "def"] + + def test_coerces_stringified_object_arg(self): + """Stringified JSON objects get parsed into dicts.""" + schema = self._mock_schema({"config": {"type": "object"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"config": '{"max": 50}'} + result = coerce_tool_args("test_tool", args) + assert result["config"] == {"max": 50} + + def test_invalid_json_array_preserved_as_string(self): + """If the string isn't valid JSON, pass it through — let the tool decide.""" + schema = self._mock_schema({"items": {"type": "array"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"items": "not-json"} + result = coerce_tool_args("test_tool", args) + assert result["items"] == "not-json" + def test_extra_args_without_schema_left_alone(self): """Args not in the schema properties are not touched.""" schema = self._mock_schema({"limit": {"type": "integer"}})
-
4 PRs closed without merging
From the close comments: closed without comment 1 · done elsewhere 1 · duplicate 1 · superseded by another PR 1
-
#3690 fix(mcp): coerce JSON string params to native types + skip utility tools for unsupported servers · done elsewhere →
9ff2143,74c9c0e,#3682 -
#9728 fix(mcp): coerce JSON-string arrays and objects for tool args · superseded by another PR →
#14811 -
#23347 fix(agent): coerce tool args for anyOf/oneOf union schemas · duplicate →
#23213,#23129 - #25577 fix(model_tools): derive expected types from anyOf/oneOf union schemas · closed without comment
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- mcp
- Merged PRs
- #14811
- Closed issues
- 3
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 23 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.