{"repo":"NousResearch/hermes-agent","public_id":"18051","display_label":"MCP utility stubs gated on server capabilities","summary":"These items address the registration of MCP utility stub methods (list_prompts, get_prompt, list_resources, read_resource) being loaded regardless of what the server advertises, and introduce capability-based gating to control which stubs are registered.","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":21347,"merged_at":"2026-05-07T14:39:50+00:00"},"resolution":{"state":"resolved","by":"pr-21347"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T14:39:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":18051,"pr_number":21347,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T14:39:50+00:00"}],"members":[{"kind":"issue","number":18051,"title":"MCP utility stubs (`list_prompts` / `get_prompt` / `list_resources` / `read_resource`) are registered even when the server doesn't advertise the corresponding capabilities","url":null,"state":"closed"},{"kind":"pr","number":18052,"title":"fix(mcp): gate utility stubs on server-advertised capabilities","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"74c9c0eec9","number":0},{"kind":"pr","value":"#21347","number":21347},{"kind":"pr","value":"#18051","number":18051}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21347,"title":"fix(mcp): gate utility stubs on server-advertised capabilities","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_mcp_utility_capability_gating.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"tests/tools/test_mcp_utility_capability_gating.py","hunks":["@@ -0,0 +1,175 @@\n+\"\"\"Regression tests for capability-gated MCP utility schema registration.\n+\n+Background\n+==========\n+For every connected MCP server, hermes-agent used to register four \"utility\"\n+tool schemas (``mcp_<server>_list_resources``, ``read_resource``,\n+``list_prompts``, ``get_prompt``) regardless of whether the server actually\n+advertises those capabilities. The old gate used ``hasattr(server.session,\n+method)`` which always returned True because ``mcp.ClientSession`` defines\n+all four methods on the class — independent of what the remote server\n+supports.\n+\n+Tools-only servers like ``@upstash/context7-mcp`` advertise\n+``{\\\"tools\\\": {\\\"listChanged\\\": true}}`` in their ``initialize`` response —\n+no ``prompts`` or ``resources`` keys — and they return JSON-RPC\n+``-32601 Method not found`` for ``prompts/list``, ``prompts/get``,\n+``resources/list``, ``resources/read``. The model would try the stubs,\n+get the error, and incorrectly conclude the MCP server was broken.\n+\n+The fix captures the ``InitializeResult`` from\n+``await session.initialize()`` into ``MCPServerTask.initialize_result``\n+and gates utility schema registration on the advertised\n+``capabilities.resources`` / ``capabilities.prompts`` sub-objects. See\n+#18051 for the reporter's repro (Context7) and analysis.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+\n+def _make_init_result(*, resources: bool, prompts: bool):\n+    \"\"\"Build a fake ``InitializeResult`` whose ``capabilities`` sub-object\n+    matches a server that advertises exactly the given capability set.\n+\n+    MCP spec shape: ``capabilities.resources`` / ``capabilities.prompts``\n+    are non-None iff the server implements the corresponding request\n+    family. We mirror that with ``SimpleNamespace`` because the real SDK\n+    models are pydantic and we don't want the test to couple to pydantic\n+    versioning.\n+    \"\"\"\n+    caps_attrs: dict = {\"tools\": SimpleNamespace(listChanged=True)}\n+    caps_attrs[\"resources\"] = SimpleNamespace(listChanged=True) if resources else None\n+    caps_attrs[\"prompts\"] = SimpleNamespace(listChanged=True) if prompts else None\n+    return SimpleNamespace(capabilities=SimpleNamespace(**caps_attrs))\n+\n+\n+def _make_fake_server(*, initialize_result):\n+    \"\"\"Build a stand-in ``MCPServerTask`` that exposes just the fields\n+    ``_select_utility_schemas`` inspects: ``name``, ``session``,\n+    ``initialize_result``.\n+\n+    A plain ``MCPServerTask`` uses ``__slots__`` and needs an asyncio\n+    loop for the ``Event``/``Lock`` init — overkill for unit scope.\n+    \"\"\"\n+    server = MagicMock()\n+    server.name = \"test-server\"\n+    # session must satisfy the legacy ``hasattr`` fallback too\n+    server.session = MagicMock(\n+        spec=[\"list_resources\", \"read_resource\", \"list_prompts\", \"get_prompt\"]\n+    )\n+    server.initialize_result = initialize_result\n+    return server\n+\n+\n+def _handler_keys(selected):\n+    return {entry[\"handler_key\"] for entry in selected}\n+\n+\n+class TestCapabilityGatedRegistration:\n+    def test_tools_only_server_gets_no_utility_schemas(self):\n+        \"\"\"Context7-shaped server (tools only, no prompts / resources) should\n+        get zero utility stubs registered — this is the exact scenario\n+        from the #18051 bug report.\"\"\"\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=False, prompts=False)\n+        )\n+        selected = _select_utility_schemas(\"context7\", server, {})\n+        assert _handler_keys(selected) == set(), (\n+            f\"tools-only server should have zero utility stubs, got \"\n+            f\"{_handler_keys(selected)}\"\n+        )\n+\n+    def test_resources_only_server_gets_resource_stubs_only(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=True, prompts=False)\n+        )\n+        selected = _select_utility_schemas(\"res-only\", server, {})\n+        assert _handler_keys(selected) == {\"list_resources\", \"read_resource\"}\n+\n+    def test_prompts_only_server_gets_prompt_stubs_only(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=False, prompts=True)\n+        )\n+        selected = _select_utility_schemas(\"prompt-only\", server, {})\n+        assert _handler_keys(selected) == {\"list_prompts\", \"get_prompt\"}\n+\n+    def test_fully_capable_server_gets_all_four_stubs(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=True, prompts=True)\n+        )\n+        selected = _select_utility_schemas(\"full\", server, {})\n+        assert _handler_keys(selected) == {\n+            \"list_resources\", \"read_resource\", \"list_prompts\", \"get_prompt\",\n+        }\n+\n+\n+class TestConfigFilterStillApplies:\n+    \"\"\"Per-server config flags ``tools.resources: false`` / ``tools.prompts: false``\n+    must continue to override even when the server DOES advertise the capability.\"\"\"\n+\n+    def test_config_disables_resources_even_when_advertised(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=True, prompts=True)\n+        )\n+        selected = _select_utility_schemas(\n+            \"full-but-filtered\",\n+            server,\n+            {\"tools\": {\"resources\": False}},\n+        )\n+        assert _handler_keys(selected) == {\"list_prompts\", \"get_prompt\"}\n+\n+    def test_config_disables_prompts_even_when_advertised(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(\n+            initialize_result=_make_init_result(resources=True, prompts=True)\n+        )\n+        selected = _select_utility_schemas(\n+            \"full-but-filtered\",\n+            server,\n+            {\"tools\": {\"prompts\": False}},\n+        )\n+        assert _handler_keys(selected) == {\"list_resources\", \"read_resource\"}\n+\n+\n+class TestLegacyFallback:\n+    \"\"\"When ``initialize_result`` is missing (older test fixtures or code\n+    paths that haven't captured it yet), fall back to the legacy hasattr\n+    check so pre-existing tests and servers keep working.\"\"\"\n+\n+    def test_no_initialize_result_falls_back_to_hasattr_check(self):\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(initialize_result=None)\n+        # With the legacy fallback, session.spec includes all four methods,\n+        # so all four stubs should register (old behavior).\n+        selected = _select_utility_schemas(\"legacy\", server, {})\n+        assert _handler_keys(selected) == {\n+            \"list_resources\", \"read_resource\", \"list_prompts\", \"get_prompt\",\n+        }\n+\n+    def test_no_initialize_result_respects_session_spec(self):\n+        \"\"\"Legacy fallback still filters by ``hasattr(session, method)``, so\n+        a session whose spec lacks a method is correctly skipped.\"\"\"\n+        from tools.mcp_tool import _select_utility_schemas\n+\n+        server = _make_fake_server(initialize_result=None)\n+        # Override session to a spec that only has list_resources\n+        server.session = MagicMock(spec=[\"list_resources\"])\n+        selected = _select_utility_schemas(\"legacy-partial\", server, {})\n+        assert _handler_keys(selected) == {\"list_resources\"}"]},{"file":"tools/mcp_tool.py","hunks":["@@ -950,6 +950,7 @@ class MCPServerTask:\n         \"_tools\", \"_error\", \"_config\",\n         \"_sampling\", \"_registered_tool_names\", \"_auth_type\", \"_refresh_lock\",\n         \"_rpc_lock\", \"_pending_refresh_tasks\",\n+        \"initialize_result\",\n     )\n \n     def __init__(self, name: str):","@@ -980,6 +981,12 @@ def __init__(self, name: str):\n         # transports for conservative per-server ordering.\n         self._rpc_lock = asyncio.Lock()\n         self._pending_refresh_tasks: set[asyncio.Task] = set()\n+        # Captures the ``InitializeResult`` returned by\n+        # ``await session.initialize()`` so downstream code can inspect the\n+        # server's real advertised capabilities (``.capabilities.resources``,\n+        # ``.capabilities.prompts``) instead of assuming every ``ClientSession``\n+        # method attribute corresponds to a supported server method. See #18051.\n+        self.initialize_result: Optional[Any] = None\n \n     def _is_http(self) -> bool:\n         \"\"\"Check if this server uses HTTP transport.\"\"\"","@@ -1225,7 +1232,7 @@ async def _run_stdio(self, config: dict):\n                 async with ClientSession(\n                     read_stream, write_stream, **sampling_kwargs\n                 ) as session:\n-                    await session.initialize()\n+                    self.initialize_result = await session.initialize()\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()","@@ -1324,7 +1331,7 @@ async def _run_http(self, config: dict):\n                 async with ClientSession(\n                     read_stream, write_stream, **sampling_kwargs\n                 ) as session:\n-                    await session.initialize()\n+                    self.initialize_result = await session.initialize()\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()","@@ -1371,7 +1378,7 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                     read_stream, write_stream, _get_session_id,\n                 ):\n                     async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:\n-                        await session.initialize()\n+                        self.initialize_result = await session.initialize()\n                         self.session = session\n                         await self._discover_tools()\n                         self._ready.set()","@@ -1394,7 +1401,7 @@ async def _strip_auth_on_cross_origin_redirect(response):\n                 read_stream, write_stream, _get_session_id,\n             ):\n                 async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:\n-                    await session.initialize()\n+                    self.initialize_result = await session.initialize()\n                     self.session = session\n                     await self._discover_tools()\n                     self._ready.set()","@@ -2806,6 +2813,23 @@ def _parse_boolish(value: Any, default: bool = True) -> bool:\n     \"get_prompt\": \"get_prompt\",\n }\n \n+# Maps each utility handler to the MCP capability key that must be non-None\n+# on the server's ``initialize`` response for the handler to be registered.\n+# Source of truth: MCP spec — capabilities.resources / capabilities.prompts\n+# are present on the response only when the server actually implements\n+# those request families. Without this gate, tools-only servers (e.g.\n+# Context7 @upstash/context7-mcp, which advertises only ``tools``) had\n+# all four utility stubs registered and every model call to them came\n+# back with JSON-RPC ``-32601 Method not found``, which made the model\n+# conclude the server was broken even when the real tools worked. See\n+# #18051.\n+_UTILITY_CAPABILITY_ATTRS = {\n+    \"list_resources\": \"resources\",\n+    \"read_resource\": \"resources\",\n+    \"list_prompts\": \"prompts\",\n+    \"get_prompt\": \"prompts\",\n+}\n+\n \n def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:\n     \"\"\"Select utility schemas based on config and server capabilities.\"\"\"","@@ -2813,6 +2837,16 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic\n     resources_enabled = _parse_boolish(tools_filter.get(\"resources\"), default=True)\n     prompts_enabled = _parse_boolish(tools_filter.get(\"prompts\"), default=True)\n \n+    # ``initialize_result.capabilities`` is the source of truth: its sub-objects\n+    # (``resources``, ``prompts``) are non-None iff the server advertises that\n+    # request family. ``hasattr(server.session, ...)`` was the old gate but\n+    # ClientSession always has the four method attributes defined on the class,\n+    # so it never filtered anything.\n+    advertised_caps = None\n+    init_result = getattr(server, \"initialize_result\", None)\n+    if init_result is not None:\n+        advertised_caps = getattr(init_result, \"capabilities\", None)\n+\n     selected: List[dict] = []\n     for entry in _build_utility_schemas(server_name):\n         handler_key = entry[\"handler_key\"]","@@ -2823,15 +2857,33 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic\n             logger.debug(\"MCP server '%s': skipping utility '%s' (prompts disabled)\", server_name, handler_key)\n             continue\n \n-        required_method = _UTILITY_CAPABILITY_METHODS[handler_key]\n-        if not hasattr(server.session, required_method):\n-            logger.debug(\n-                \"MCP server '%s': skipping utility '%s' (session lacks %s)\",\n-                server_name,\n-                handler_key,\n-                required_method,\n-            )\n-            continue\n+        # Preferred gate: check the server's advertised capabilities. Skip\n+        # if the capability is explicitly not advertised.\n+        if advertised_caps is not None:\n+            cap_attr = _UTILITY_CAPABILITY_ATTRS[handler_key]\n+            if getattr(advertised_caps, cap_attr, None) is None:\n+                logger.debug(\n+                    \"MCP server '%s': skipping utility '%s' \"\n+                    \"(server does not advertise '%s' capability)\",\n+                    server_name,\n+                    handler_key,\n+                    cap_attr,\n+                )\n+                continue\n+        else:\n+            # Legacy fallback for test fixtures or older code paths where\n+            # initialize_result wasn't captured. Preserves the old behavior\n+            # of registering every stub in that case rather than regressing\n+            # any server that was working before this fix.\n+            required_method = _UTILITY_CAPABILITY_METHODS[handler_key]\n+            if not hasattr(server.session, required_method):\n+                logger.debug(\n+                    \"MCP server '%s': skipping utility '%s' (session lacks %s)\",\n+                    server_name,\n+                    handler_key,\n+                    required_method,\n+                )\n+                continue\n         selected.append(entry)\n     return selected\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}