{"repo":"NousResearch/hermes-agent","public_id":"14927","display_label":"MCP tool schema normalization and sanitization fixes","summary":"These issues and pull requests address malformed or invalid MCP tool schemas, specifically around `additionalProperties` values that need repair or sanitization. The work targets compatibility with backends such as llama.cpp and correct behavior in TUI contexts.","subsystem":"mcp","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15032,"merged_at":"2026-04-24T09:44:46+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-24T09:44:46+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14927,"pr_number":15032,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-24T09:44:46+00:00"}],"members":[{"kind":"issue","number":14927,"title":"MCP tool schema normalization should repair string additionalProperties values","url":null,"state":"closed"},{"kind":"pr","number":14870,"title":"fix: repair malformed MCP additionalProperties schemas","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"34c3e6710","number":0},{"kind":"pr","value":"#15032","number":15032}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15032,"title":"fix: sanitize tool schemas for llama.cpp backends; restore MCP in TUI","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["model_tools.py","tests/tools/test_schema_sanitizer.py","tools/browser_cdp_tool.py","tools/mcp_tool.py","tools/schema_sanitizer.py","tui_gateway/server.py"],"fix_hunks":[{"file":"model_tools.py","hunks":["@@ -343,6 +343,18 @@ def get_tool_definitions(\n     global _last_resolved_tool_names\n     _last_resolved_tool_names = [t[\"function\"][\"name\"] for t in filtered_tools]\n \n+    # Sanitize schemas for broad backend compatibility. llama.cpp's\n+    # json-schema-to-grammar converter (used by its OAI server to build\n+    # GBNF tool-call parsers) rejects some shapes that cloud providers\n+    # silently accept — bare \"type\": \"object\" with no properties,\n+    # string-valued schema nodes from malformed MCP servers, etc. This\n+    # is a no-op for schemas that are already well-formed.\n+    try:\n+        from tools.schema_sanitizer import sanitize_tool_schemas\n+        filtered_tools = sanitize_tool_schemas(filtered_tools)\n+    except Exception as e:  # pragma: no cover — defensive\n+        logger.warning(\"Schema sanitization skipped: %s\", e)\n+\n     return filtered_tools\n \n "]},{"file":"tests/tools/test_schema_sanitizer.py","hunks":["@@ -0,0 +1,205 @@\n+\"\"\"Tests for tools/schema_sanitizer.py.\n+\n+Targets the known llama.cpp ``json-schema-to-grammar`` failure modes that\n+cause ``HTTP 400: Unable to generate parser for this template. ...\n+Unrecognized schema: \"object\"`` errors on local inference backends.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import copy\n+\n+from tools.schema_sanitizer import sanitize_tool_schemas\n+\n+\n+def _tool(name: str, parameters: dict) -> dict:\n+    return {\"type\": \"function\", \"function\": {\"name\": name, \"parameters\": parameters}}\n+\n+\n+def test_object_without_properties_gets_empty_properties():\n+    tools = [_tool(\"t\", {\"type\": \"object\"})]\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"] == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+def test_nested_object_without_properties_gets_empty_properties():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"name\": {\"type\": \"string\"},\n+            \"arguments\": {\"type\": \"object\", \"description\": \"free-form\"},\n+        },\n+        \"required\": [\"name\"],\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    args = out[0][\"function\"][\"parameters\"][\"properties\"][\"arguments\"]\n+    assert args[\"type\"] == \"object\"\n+    assert args[\"properties\"] == {}\n+    assert args[\"description\"] == \"free-form\"\n+\n+\n+def test_bare_string_object_value_replaced_with_schema_dict():\n+    # Malformed: a property's schema value is the bare string \"object\".\n+    # This is the exact shape llama.cpp reports as `Unrecognized schema: \"object\"`.\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"payload\": \"object\",  # <-- invalid, should be {\"type\": \"object\"}\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    payload = out[0][\"function\"][\"parameters\"][\"properties\"][\"payload\"]\n+    assert isinstance(payload, dict)\n+    assert payload[\"type\"] == \"object\"\n+    assert payload[\"properties\"] == {}\n+\n+\n+def test_bare_string_primitive_value_replaced_with_schema_dict():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\"name\": \"string\"},\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"][\"properties\"][\"name\"] == {\"type\": \"string\"}\n+\n+\n+def test_nullable_type_array_collapsed_to_single_string():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"maybe_name\": {\"type\": [\"string\", \"null\"]},\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    prop = out[0][\"function\"][\"parameters\"][\"properties\"][\"maybe_name\"]\n+    assert prop[\"type\"] == \"string\"\n+    assert prop.get(\"nullable\") is True\n+\n+\n+def test_anyof_nested_objects_sanitized():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"opt\": {\n+                \"anyOf\": [\n+                    {\"type\": \"object\"},               # bare object\n+                    {\"type\": \"string\"},\n+                ],\n+            },\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    variants = out[0][\"function\"][\"parameters\"][\"properties\"][\"opt\"][\"anyOf\"]\n+    assert variants[0] == {\"type\": \"object\", \"properties\": {}}\n+    assert variants[1] == {\"type\": \"string\"}\n+\n+\n+def test_missing_parameters_gets_default_object_schema():\n+    tools = [{\"type\": \"function\", \"function\": {\"name\": \"t\"}}]\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"] == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+def test_non_dict_parameters_gets_default_object_schema():\n+    tools = [_tool(\"t\", \"object\")]  # pathological\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"] == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+def test_required_pruned_to_existing_properties():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\"name\": {\"type\": \"string\"}},\n+        \"required\": [\"name\", \"missing_field\"],\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"][\"required\"] == [\"name\"]\n+\n+\n+def test_required_all_missing_is_dropped():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {},\n+        \"required\": [\"x\", \"y\"],\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    assert \"required\" not in out[0][\"function\"][\"parameters\"]\n+\n+\n+def test_well_formed_schema_unchanged():\n+    schema = {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"path\": {\"type\": \"string\", \"description\": \"File path\"},\n+            \"offset\": {\"type\": \"integer\", \"minimum\": 1},\n+        },\n+        \"required\": [\"path\"],\n+    }\n+    tools = [_tool(\"read_file\", copy.deepcopy(schema))]\n+    out = sanitize_tool_schemas(tools)\n+    assert out[0][\"function\"][\"parameters\"] == schema\n+\n+\n+def test_additional_properties_bool_preserved():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"payload\": {\n+                \"type\": \"object\",\n+                \"properties\": {},\n+                \"additionalProperties\": True,\n+            },\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    payload = out[0][\"function\"][\"parameters\"][\"properties\"][\"payload\"]\n+    assert payload[\"additionalProperties\"] is True\n+\n+\n+def test_additional_properties_schema_sanitized():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"dict_field\": {\n+                \"type\": \"object\",\n+                \"additionalProperties\": {\"type\": \"object\"},  # bare object schema\n+            },\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    field = out[0][\"function\"][\"parameters\"][\"properties\"][\"dict_field\"]\n+    assert field[\"additionalProperties\"] == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+def test_deepcopy_does_not_mutate_input():\n+    original = {\n+        \"type\": \"object\",\n+        \"properties\": {\"x\": {\"type\": \"object\"}},\n+    }\n+    tools = [_tool(\"t\", original)]\n+    _ = sanitize_tool_schemas(tools)\n+    # Original should still lack properties on the nested object\n+    assert \"properties\" not in original[\"properties\"][\"x\"]\n+\n+\n+def test_items_sanitized_in_array_schema():\n+    tools = [_tool(\"t\", {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"bag\": {\n+                \"type\": \"array\",\n+                \"items\": {\"type\": \"object\"},  # bare object items\n+            },\n+        },\n+    })]\n+    out = sanitize_tool_schemas(tools)\n+    items = out[0][\"function\"][\"parameters\"][\"properties\"][\"bag\"][\"items\"]\n+    assert items == {\"type\": \"object\", \"properties\": {}}\n+\n+\n+def test_empty_tools_list_returns_empty():\n+    assert sanitize_tool_schemas([]) == []\n+\n+\n+def test_none_tools_returns_none():\n+    assert sanitize_tool_schemas(None) is None"]},{"file":"tools/browser_cdp_tool.py","hunks":["@@ -477,6 +477,7 @@ def browser_cdp(\n                     \"Method-specific parameters as a JSON object. Omit or \"\n                     \"pass {} for methods that take no parameters.\"\n                 ),\n+                \"properties\": {},\n                 \"additionalProperties\": True,\n             },\n             \"target_id\": {"]},{"file":"tools/mcp_tool.py","hunks":["@@ -2294,6 +2294,8 @@ def _build_utility_schemas(server_name: str) -> List[dict]:\n                         \"arguments\": {\n                             \"type\": \"object\",\n                             \"description\": \"Optional arguments to pass to the prompt\",\n+                            \"properties\": {},\n+                            \"additionalProperties\": True,\n                         },\n                     },\n                     \"required\": [\"name\"],"]},{"file":"tools/schema_sanitizer.py","hunks":["@@ -0,0 +1,186 @@\n+\"\"\"Sanitize tool JSON schemas for broad LLM-backend compatibility.\n+\n+Some local inference backends (notably llama.cpp's ``json-schema-to-grammar``\n+converter used to build GBNF tool-call parsers) are strict about what JSON\n+Schema shapes they accept. Schemas that OpenAI / Anthropic / most cloud\n+providers silently accept can make llama.cpp fail the entire request with:\n+\n+    HTTP 400: Unable to generate parser for this template.\n+    Automatic parser generation failed: JSON schema conversion failed:\n+    Unrecognized schema: \"object\"\n+\n+The failure modes we've seen in the wild:\n+\n+* ``{\"type\": \"object\"}`` with no ``properties`` — rejected as a node the\n+  grammar generator can't constrain.\n+* A schema value that is the bare string ``\"object\"`` instead of a dict\n+  (malformed MCP server output, e.g. ``additionalProperties: \"object\"``).\n+* ``\"type\": [\"string\", \"null\"]`` array types — many converters only accept\n+  single-string ``type``.\n+* Unconstrained ``additionalProperties`` on objects with empty properties.\n+\n+This module walks the final tool schema tree (after MCP-level normalization\n+and any per-tool dynamic rebuilds) and fixes the known-hostile constructs\n+in-place on a deep copy. It is intentionally conservative: it only modifies\n+shapes the LLM backend couldn't use anyway.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import copy\n+import logging\n+from typing import Any\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+def sanitize_tool_schemas(tools: list[dict]) -> list[dict]:\n+    \"\"\"Return a copy of ``tools`` with each tool's parameter schema sanitized.\n+\n+    Input is an OpenAI-format tool list:\n+    ``[{\"type\": \"function\", \"function\": {\"name\": ..., \"parameters\": {...}}}]``\n+\n+    The returned list is a deep copy — callers can safely mutate it without\n+    affecting the original registry entries.\n+    \"\"\"\n+    if not tools:\n+        return tools\n+\n+    sanitized: list[dict] = []\n+    for tool in tools:\n+        sanitized.append(_sanitize_single_tool(tool))\n+    return sanitized\n+\n+\n+def _sanitize_single_tool(tool: dict) -> dict:\n+    \"\"\"Deep-copy and sanitize a single OpenAI-format tool entry.\"\"\"\n+    out = copy.deepcopy(tool)\n+    fn = out.get(\"function\") if isinstance(out, dict) else None\n+    if not isinstance(fn, dict):\n+        return out\n+\n+    params = fn.get(\"parameters\")\n+    # Missing / non-dict parameters → substitute the minimal valid shape.\n+    if not isinstance(params, dict):\n+        fn[\"parameters\"] = {\"type\": \"object\", \"properties\": {}}\n+        return out\n+\n+    fn[\"parameters\"] = _sanitize_node(params, path=fn.get(\"name\", \"<tool>\"))\n+    # After recursion, guarantee the top-level is an object with properties.\n+    top = fn[\"parameters\"]\n+    if not isinstance(top, dict):\n+        fn[\"parameters\"] = {\"type\": \"object\", \"properties\": {}}\n+    else:\n+        if top.get(\"type\") != \"object\":\n+            top[\"type\"] = \"object\"\n+        if \"properties\" not in top or not isinstance(top.get(\"properties\"), dict):\n+            top[\"properties\"] = {}\n+    return out\n+\n+\n+def _sanitize_node(node: Any, path: str) -> Any:\n+    \"\"\"Recursively sanitize a JSON-Schema fragment.\n+\n+    - Replaces bare-string schema values (\"object\", \"string\", ...) with\n+      ``{\"type\": <value>}`` so downstream consumers see a dict.\n+    - Injects ``properties: {}`` into object-typed nodes missing it.\n+    - Normalizes ``type: [X, \"null\"]`` arrays to single ``type: X`` (keeping\n+      ``nullable: true`` as a hint).\n+    - Recurses into ``properties``, ``items``, ``additionalProperties``,\n+      ``anyOf``, ``oneOf``, ``allOf``, and ``$defs`` / ``definitions``.\n+    \"\"\"\n+    # Malformed: the schema position holds a bare string like \"object\".\n+    if isinstance(node, str):\n+        if node in {\"object\", \"string\", \"number\", \"integer\", \"boolean\", \"array\", \"null\"}:\n+            logger.debug(\n+                \"schema_sanitizer[%s]: replacing bare-string schema %r \"\n+                \"with {'type': %r}\",\n+                path, node, node,\n+            )\n+            return {\"type\": node} if node != \"object\" else {\n+                \"type\": \"object\",\n+                \"properties\": {},\n+            }\n+        # Any other stray string is not a schema — drop it by replacing with\n+        # a permissive object schema rather than propagate something the\n+        # backend will reject.\n+        logger.debug(\n+            \"schema_sanitizer[%s]: replacing non-schema string %r \"\n+            \"with empty object schema\", path, node,\n+        )\n+        return {\"type\": \"object\", \"properties\": {}}\n+\n+    if isinstance(node, list):\n+        return [_sanitize_node(item, f\"{path}[{i}]\") for i, item in enumerate(node)]\n+\n+    if not isinstance(node, dict):\n+        return node\n+\n+    out: dict = {}\n+    for key, value in node.items():\n+        # type: [X, \"null\"] → type: X (the backend's tool-call parser only\n+        # accepts singular string types; nullable is lost but the call still\n+        # succeeds, and the model can still pass null on its own.)\n+        if key == \"type\" and isinstance(value, list):\n+            non_null = [t for t in value if t != \"null\"]\n+            if len(non_null) == 1 and isinstance(non_null[0], str):\n+                out[\"type\"] = non_null[0]\n+                if \"null\" in value:\n+                    out.setdefault(\"nullable\", True)\n+                continue\n+            # Fallback: pick the first string type, drop the rest.\n+            first_str = next((t for t in value if isinstance(t, str) and t != \"null\"), None)\n+            if first_str:\n+                out[\"type\"] = first_str\n+                continue\n+            # All-null or empty list → treat as object.\n+            out[\"type\"] = \"object\"\n+            continue\n+\n+        if key in {\"properties\", \"$defs\", \"definitions\"} and isinstance(value, dict):\n+            out[key] = {\n+                sub_k: _sanitize_node(sub_v, f\"{path}.{key}.{sub_k}\")\n+                for sub_k, sub_v in value.items()\n+            }\n+        elif key in {\"items\", \"additionalProperties\"}:\n+            if isinstance(value, bool):\n+                # Keep bool ``additionalProperties`` as-is — it's a valid form\n+                # and widely accepted. ``items: true/false`` is non-standard\n+                # but we preserve rather than drop.\n+                out[key] = value\n+            else:\n+                out[key] = _sanitize_node(value, f\"{path}.{key}\")\n+        elif key in {\"anyOf\", \"oneOf\", \"allOf\"} and isinstance(value, list):\n+            out[key] = [\n+                _sanitize_node(item, f\"{path}.{key}[{i}]\")\n+                for i, item in enumerate(value)\n+            ]\n+        elif key in {\"required\", \"enum\", \"examples\"}:\n+            # Schema \"sibling\" keywords whose values are NOT schemas:\n+            #  - ``required``: list of property-name strings\n+            #  - ``enum``: list of literal values (any JSON type)\n+            #  - ``examples``: list of example values (any JSON type)\n+            # Recursing into these with _sanitize_node() would mis-interpret\n+            # literal strings like \"path\" as bare-string schemas and replace\n+            # them with {\"type\": \"object\"} dicts. Pass through unchanged.\n+            out[key] = copy.deepcopy(value) if isinstance(value, (list, dict)) else value\n+        else:\n+            out[key] = _sanitize_node(value, f\"{path}.{key}\") if isinstance(value, (dict, list)) else value\n+\n+    # Object nodes without properties: inject empty properties dict.\n+    # llama.cpp's grammar generator can't constrain a free-form object.\n+    if out.get(\"type\") == \"object\" and not isinstance(out.get(\"properties\"), dict):\n+        out[\"properties\"] = {}\n+\n+    # Prune ``required`` entries that don't exist in properties (defense\n+    # against malformed MCP schemas; also caught upstream for MCP tools, but\n+    # built-in tools or plugin tools may not have been through that path).\n+    if out.get(\"type\") == \"object\" and isinstance(out.get(\"required\"), list):\n+        props = out.get(\"properties\") or {}\n+        valid = [r for r in out[\"required\"] if isinstance(r, str) and r in props]\n+        if not valid:\n+            out.pop(\"required\", None)\n+        elif len(valid) != len(out[\"required\"]):\n+            out[\"required\"] = valid\n+\n+    return out"]},{"file":"tui_gateway/server.py","hunks":["@@ -590,8 +590,14 @@ def _load_enabled_toolsets() -> list[str] | None:\n         from hermes_cli.config import load_config\n         from hermes_cli.tools_config import _get_platform_tools\n \n+        # Runtime toolset resolution must include default MCP servers so the\n+        # agent can actually call them. Passing ``False`` here is the\n+        # config-editing variant — used when we need to persist a toolset\n+        # list without baking in implicit MCP defaults. Using the wrong\n+        # variant at agent creation time makes MCP tools silently missing\n+        # from the TUI. See PR #3252 for the original design split.\n         enabled = sorted(\n-            _get_platform_tools(load_config(), \"cli\", include_default_mcp_servers=False)\n+            _get_platform_tools(load_config(), \"cli\", include_default_mcp_servers=True)\n         )\n         return enabled or None\n     except Exception:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}