# NousResearch/hermes-agent · MCP tool argument type coercion and schema resolution

## Open now

- 2 issues, 2 PRs open on GitHub.
- #23129: \[Bug\]: MCP HTTP client double-encodes anyOf:\[string,array\] arguments — Jina read_url always fails
- #60306: \[Bug\]: Unreal MCP tool arguments do not reach nested tools through dynamic call_tool dispatch
- PR #23213: fix(mcp): resolve anyOf/oneOf union types in coerce_tool_args
- PR #60307: \[codex\] Fix MCP dynamic dispatch arguments for Unreal toolsets

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.

## TL;DR

- 12 member(s): 2 open issue(s), 3 closed issue(s), 2 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #14811. Verify it addresses your case.
- **Recommended:** read PR #14811 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #3682: MCP tool parameter type coercion missing: JSON strings not parsed before call_tool
- #3947: MCP bridge serializes all tool parameters as strings, breaking number and array types
- #5256: MCP tool parameters serialized as strings instead of native types

## Prior & competing PRs

- **#3690: fix(mcp): coerce JSON string params to native types + skip utility tools for unsupported servers**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9ff21437a03a28cfa33394bb330a4a41768f8c1c`, `74c9c0eec903749443e4aa9ad1427d1859acae2c`, `#3682`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#14811: fix(mcp): coerce stringified arrays/objects in tool args (salvage #12640)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `model_tools.py`, `scripts/release.py`, `tests/run_agent/test_tool_arg_coercion.py`
  - fix hunks:
  - `model_tools.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 ───────────────────────────────────
     
    ```

    ```diff
    @@ -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"}})
    ```
- **#9728: fix(mcp): coerce JSON-string arrays and objects for tool args**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14811`
- **#23213: fix(mcp): resolve anyOf/oneOf union types in coerce_tool_args**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `8a99e33503`, `d6da3dcbb0`, `d6da3dcbb0`, `d6da3dcbb0`
  - files: `tests/run_agent/test_tool_arg_coercion.py`, `tools/arg_coercion.py`
- **#23347: fix(agent): coerce tool args for anyOf/oneOf union schemas**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#23213`, `#23129`
- **#25577: fix(model_tools): derive expected types from anyOf/oneOf union schemas**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#60307: \[codex\] Fix MCP dynamic dispatch arguments for Unreal toolsets**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/tool_executor.py`, `model_tools.py`, `tests/tools/test_mcp_dynamic_dispatch_args.py`, `tools/mcp_debug.py`, `tools/mcp_tool.py`

## Fix status

- Merged to main in PR #14811 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-23`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `mcp`
