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

2open issues
2open PRs
12items grouped
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.

These 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

  • Issue#23129[Bug]: MCP HTTP client double-encodes anyOf:[string,array] arguments — Jina read_url always fails
  • Issue#60306[Bug]: Unreal MCP tool arguments do not reach nested tools through dynamic call_tool dispatch

Open PRs2

  • PR#23213fix(mcp): resolve anyOf/oneOf union types in coerce_tool_args
  • PR#60307[codex] Fix MCP dynamic dispatch arguments for Unreal toolsets

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #14811 fix(mcp): coerce stringified arrays/objects in tool args (salvage #12640)

    merged to main

    files: model_tools.py, scripts/release.py, tests/run_agent/test_tool_arg_coercion.py

    Show 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
  • Closed#3682MCP tool parameter type coercion missing: JSON strings not parsed before call_tool
  • Closed#3947MCP bridge serializes all tool parameters as strings, breaking number and array types
  • Closed#5256MCP tool parameters serialized as strings instead of native types
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.00 means 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 Lab

At 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.

For your agent

The same facts, typed.