{"repo":"NousResearch/hermes-agent","public_id":"3682","display_label":"MCP tool argument type coercion and schema resolution","summary":"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.","subsystem":"mcp","counts":{"members":12,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":3,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14811},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-23T23:38:38+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1333333333333333},"fix_edges":[{"issue_number":3947,"pr_number":14811,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3682,"title":"MCP tool parameter type coercion missing: JSON strings not parsed before call_tool","url":null,"state":"closed"},{"kind":"issue","number":3947,"title":"MCP bridge serializes all tool parameters as strings, breaking number and array types","url":null,"state":"closed"},{"kind":"issue","number":5256,"title":"MCP tool parameters serialized as strings instead of native types","url":null,"state":"closed"},{"kind":"issue","number":23129,"title":"[Bug]: MCP HTTP client double-encodes anyOf:[string,array] arguments — Jina read_url always fails","url":null,"state":"open"},{"kind":"issue","number":60306,"title":"[Bug]: Unreal MCP tool arguments do not reach nested tools through dynamic call_tool dispatch","url":null,"state":"open"},{"kind":"pr","number":3690,"title":"fix(mcp): coerce JSON string params to native types + skip utility tools for unsupported servers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9ff21437a03a28cfa33394bb330a4a41768f8c1c","number":0},{"kind":"commit","value":"74c9c0eec903749443e4aa9ad1427d1859acae2c","number":0},{"kind":"pr","value":"#3682","number":3682}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":9728,"title":"fix(mcp): coerce JSON-string arrays and objects for tool args","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14811","number":14811}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14811,"title":"fix(mcp): coerce stringified arrays/objects in tool args (salvage #12640)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["model_tools.py","scripts/release.py","tests/run_agent/test_tool_arg_coercion.py"],"fix_hunks":[{"file":"model_tools.py","hunks":["@@ -418,6 +418,31 @@ def _coerce_value(value: str, expected_type):\n         return _coerce_number(value, integer_only=(expected_type == \"integer\"))\n     if expected_type == \"boolean\":\n         return _coerce_boolean(value)\n+    if expected_type == \"array\":\n+        return _coerce_json(value, list)\n+    if expected_type == \"object\":\n+        return _coerce_json(value, dict)\n+    return value\n+\n+\n+def _coerce_json(value: str, expected_python_type: type):\n+    \"\"\"Parse *value* as JSON when the schema expects an array or object.\n+\n+    Handles model output drift where a complex oneOf/discriminated-union schema\n+    causes the LLM to emit the array/object as a JSON string instead of a native\n+    structure.  Returns the original string if parsing fails or yields the wrong\n+    Python type.\n+    \"\"\"\n+    try:\n+        parsed = json.loads(value)\n+    except (ValueError, TypeError):\n+        return value\n+    if isinstance(parsed, expected_python_type):\n+        logger.debug(\n+            \"coerce_tool_args: coerced string to %s via json.loads\",\n+            expected_python_type.__name__,\n+        )\n+        return parsed\n     return value\n \n "]},{"file":"scripts/release.py","hunks":["@@ -167,6 +167,7 @@\n     \"[redacted:email]\": \"socrates1024\",\n     \"[redacted:email]\": \"Salt-555\",\n     \"[redacted:email]\": \"satelerd\",\n+    \"[redacted:email]\": \"danklynn\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"[redacted:email]\": \"0xNyk\","]},{"file":"tests/run_agent/test_tool_arg_coercion.py","hunks":["@@ -134,6 +134,31 @@ def test_union_with_string_preserves_original(self):\n         \"\"\"A non-numeric string in [number, string] should stay a string.\"\"\"\n         assert _coerce_value(\"hello\", [\"number\", \"string\"]) == \"hello\"\n \n+    def test_array_type_parsed_from_json_string(self):\n+        \"\"\"Stringified JSON arrays are parsed into native lists.\"\"\"\n+        assert _coerce_value('[\"a\", \"b\"]', \"array\") == [\"a\", \"b\"]\n+        assert _coerce_value(\"[1, 2, 3]\", \"array\") == [1, 2, 3]\n+\n+    def test_object_type_parsed_from_json_string(self):\n+        \"\"\"Stringified JSON objects are parsed into native dicts.\"\"\"\n+        assert _coerce_value('{\"k\": \"v\"}', \"object\") == {\"k\": \"v\"}\n+        assert _coerce_value('{\"n\": 1}', \"object\") == {\"n\": 1}\n+\n+    def test_array_invalid_json_preserved(self):\n+        \"\"\"Unparseable strings are returned unchanged.\"\"\"\n+        assert _coerce_value(\"not-json\", \"array\") == \"not-json\"\n+\n+    def test_object_invalid_json_preserved(self):\n+        assert _coerce_value(\"not-json\", \"object\") == \"not-json\"\n+\n+    def test_array_type_wrong_shape_preserved(self):\n+        \"\"\"A JSON object passed for an 'array' slot is preserved as a string.\"\"\"\n+        assert _coerce_value('{\"k\": \"v\"}', \"array\") == '{\"k\": \"v\"}'\n+\n+    def test_object_type_wrong_shape_preserved(self):\n+        \"\"\"A JSON array passed for an 'object' slot is preserved as a string.\"\"\"\n+        assert _coerce_value('[\"a\"]', \"object\") == '[\"a\"]'\n+\n \n # ── Full coerce_tool_args with registry ───────────────────────────────────\n ","@@ -212,6 +237,32 @@ def test_preserves_non_string_values(self):\n             assert result[\"items\"] == [1, 2, 3]\n             assert result[\"config\"] == {\"key\": \"val\"}\n \n+    def test_coerces_stringified_array_arg(self):\n+        \"\"\"Regression for #3947 — MCP servers using z.array() expect lists, not strings.\"\"\"\n+        schema = self._mock_schema({\n+            \"messageIds\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}},\n+        })\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"messageIds\": '[\"abc\", \"def\"]'}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"messageIds\"] == [\"abc\", \"def\"]\n+\n+    def test_coerces_stringified_object_arg(self):\n+        \"\"\"Stringified JSON objects get parsed into dicts.\"\"\"\n+        schema = self._mock_schema({\"config\": {\"type\": \"object\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"config\": '{\"max\": 50}'}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"config\"] == {\"max\": 50}\n+\n+    def test_invalid_json_array_preserved_as_string(self):\n+        \"\"\"If the string isn't valid JSON, pass it through — let the tool decide.\"\"\"\n+        schema = self._mock_schema({\"items\": {\"type\": \"array\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"items\": \"not-json\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"items\"] == \"not-json\"\n+\n     def test_extra_args_without_schema_left_alone(self):\n         \"\"\"Args not in the schema properties are not touched.\"\"\"\n         schema = self._mock_schema({\"limit\": {\"type\": \"integer\"}})\n"]}]},{"kind":"pr","number":23213,"title":"fix(mcp): resolve anyOf/oneOf union types in coerce_tool_args","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"8a99e33503","number":0},{"kind":"commit","value":"d6da3dcbb0","number":0},{"kind":"commit","value":"d6da3dcbb0","number":0},{"kind":"commit","value":"d6da3dcbb0","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23347,"title":"fix(agent): coerce tool args for anyOf/oneOf union schemas","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23213","number":23213},{"kind":"pr","value":"#23129","number":23129}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":25577,"title":"fix(model_tools): derive expected types from anyOf/oneOf union schemas","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":60307,"title":"[codex] Fix MCP dynamic dispatch arguments for Unreal toolsets","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}