{"repo":"NousResearch/hermes-agent","public_id":"4219","display_label":"Cron jobs lack MCP server initialization","summary":"A bug where cron jobs cannot access MCP servers due to initialization order, along with related fixes. Includes a CLI display width fix bundled in one of the patches.","subsystem":"cron","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":21354,"merged_at":"2026-05-07T14:53:04+00:00"},"resolution":{"state":"resolved","by":"pr-21354"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T14:53:04+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4219,"pr_number":21354,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T14:53:04+00:00"}],"members":[{"kind":"issue","number":4219,"title":"Cron jobs cannot use skills, plugins, and MCP servers","url":null,"state":"closed"},{"kind":"pr","number":4213,"title":"fix(cli): use display width for chat input; fix(cron): init MCP servers","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21354,"title":"fix(cron): initialize MCP servers before constructing the cron AIAgent","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler_mcp_init.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -1261,6 +1261,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n             except Exception as e:\n                 logger.debug(\"Job '%s': failed to load credential pool for %s: %s\", job_id, runtime_provider, e)\n \n+        # Initialize MCP servers so configured mcp_servers are available to\n+        # the agent's tool registry before AIAgent is constructed. Without\n+        # this, cron jobs never saw any MCP tools — only the gateway / CLI\n+        # paths called discover_mcp_tools() at startup. Idempotent: subsequent\n+        # ticks short-circuit on already-connected servers inside\n+        # register_mcp_servers(). Non-fatal on failure: a broken MCP server\n+        # shouldn't kill an otherwise-working cron job. See #4219.\n+        try:\n+            from tools.mcp_tool import discover_mcp_tools\n+            _mcp_tools = discover_mcp_tools()\n+            if _mcp_tools:\n+                logger.info(\n+                    \"Job '%s': %d MCP tool(s) available\",\n+                    job_id, len(_mcp_tools),\n+                )\n+        except Exception as _mcp_exc:\n+            logger.warning(\n+                \"Job '%s': MCP initialization failed (non-fatal): %s\",\n+                job_id, _mcp_exc,\n+            )\n+\n         agent = AIAgent(\n             model=model,\n             api_key=runtime.get(\"api_key\"),"]},{"file":"tests/cron/test_scheduler_mcp_init.py","hunks":["@@ -0,0 +1,140 @@\n+\"\"\"Regression tests for MCP server availability in cron jobs.\n+\n+Background\n+==========\n+``cron/scheduler.py:run_job()`` constructs ``AIAgent(...)`` directly without\n+calling ``discover_mcp_tools()`` — the initialization that CLI and gateway\n+paths do at startup. Cron jobs therefore never saw any MCP tools from\n+``mcp_servers`` in config.yaml. See #4219.\n+\n+The fix inserts ``discover_mcp_tools()`` before the ``AIAgent(...)`` call,\n+wrapped in try/except so a broken MCP server can't kill an otherwise\n+working cron job. ``discover_mcp_tools`` is idempotent — subsequent ticks\n+short-circuit on already-connected servers.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+\n+def test_run_job_calls_discover_mcp_tools_before_agent_construction():\n+    \"\"\"The LLM-path branch of run_job must call discover_mcp_tools() before\n+    the AIAgent construction, so MCP tools are in the registry by the time\n+    the agent asks for its tool schema.\"\"\"\n+    from cron import scheduler\n+\n+    job = {\n+        \"id\": \"mcp-cron-test\",\n+        \"name\": \"mcp-cron-test\",\n+        \"prompt\": \"test\",\n+    }\n+\n+    call_order = []\n+\n+    def fake_discover():\n+        call_order.append(\"discover_mcp_tools\")\n+        return [\"mcp_server1_tool\"]\n+\n+    # AIAgent is a class; replace with a recording stub\n+    class _FakeAgent:\n+        def __init__(self, *args, **kwargs):\n+            call_order.append(\"AIAgent.__init__\")\n+            self._kwargs = kwargs\n+            self._interrupt_requested = False\n+            self.quiet_mode = True\n+\n+        def run_conversation(self, *args, **kwargs):\n+            return {\n+                \"final_response\": \"ok\",\n+                \"messages\": [],\n+            }\n+\n+    with patch(\"tools.mcp_tool.discover_mcp_tools\", side_effect=fake_discover), \\\n+         patch(\"run_agent.AIAgent\", _FakeAgent), \\\n+         patch(\"cron.scheduler._resolve_cron_enabled_toolsets\", return_value=None):\n+        scheduler.run_job(job)\n+\n+    # Discovery must be called, and must be called BEFORE agent construction.\n+    assert \"discover_mcp_tools\" in call_order, (\n+        \"run_job did not call discover_mcp_tools — MCP tools unavailable in cron\"\n+    )\n+    d_idx = call_order.index(\"discover_mcp_tools\")\n+    a_idx = call_order.index(\"AIAgent.__init__\")\n+    assert d_idx < a_idx, (\n+        f\"discover_mcp_tools was called AFTER AIAgent construction \"\n+        f\"(indices discover={d_idx}, agent={a_idx}); MCP tools missed the \"\n+        f\"registry window. Full order: {call_order}\"\n+    )\n+\n+\n+def test_run_job_tolerates_discover_mcp_tools_failure():\n+    \"\"\"A broken MCP server must not kill an otherwise working cron job.\n+    discover_mcp_tools() raising should be caught and logged, and the agent\n+    should still run.\"\"\"\n+    from cron import scheduler\n+\n+    job = {\n+        \"id\": \"mcp-cron-fail\",\n+        \"name\": \"mcp-cron-fail\",\n+        \"prompt\": \"test\",\n+    }\n+\n+    agent_was_constructed = []\n+\n+    class _FakeAgent:\n+        def __init__(self, *args, **kwargs):\n+            agent_was_constructed.append(True)\n+            self._interrupt_requested = False\n+            self.quiet_mode = True\n+\n+        def run_conversation(self, *args, **kwargs):\n+            return {\"final_response\": \"ok\", \"messages\": []}\n+\n+    def fake_discover_that_raises():\n+        raise RuntimeError(\"MCP server unreachable\")\n+\n+    with patch(\n+        \"tools.mcp_tool.discover_mcp_tools\",\n+        side_effect=fake_discover_that_raises,\n+    ), patch(\"run_agent.AIAgent\", _FakeAgent), \\\n+         patch(\"cron.scheduler._resolve_cron_enabled_toolsets\", return_value=None):\n+        # Should NOT raise\n+        success, doc, final_response, error = scheduler.run_job(job)\n+\n+    assert agent_was_constructed, (\n+        \"AIAgent was not constructed after discover_mcp_tools raised — \"\n+        \"MCP failure incorrectly killed the cron job\"\n+    )\n+\n+\n+def test_no_agent_cron_job_does_not_initialize_mcp():\n+    \"\"\"Cron jobs with no_agent=True are script-only — no AIAgent, no MCP\n+    tools needed. We must NOT pay the MCP init cost for those.\"\"\"\n+    from cron import scheduler\n+\n+    job = {\n+        \"id\": \"noagent-job\",\n+        \"name\": \"noagent-job\",\n+        \"no_agent\": True,\n+        \"script\": \"/nonexistent/script.sh\",\n+    }\n+\n+    discover_called = []\n+\n+    def fake_discover():\n+        discover_called.append(True)\n+        return []\n+\n+    # _run_job_script returns (ok, output); make it fail cleanly so we\n+    # don't need a real script file.\n+    with patch(\"tools.mcp_tool.discover_mcp_tools\", side_effect=fake_discover), \\\n+         patch(\"cron.scheduler._run_job_script\", return_value=(False, \"no such file\")):\n+        scheduler.run_job(job)\n+\n+    assert not discover_called, (\n+        \"discover_mcp_tools was called for a no_agent job — wasted MCP init \"\n+        \"for a script-only cron tick\"\n+    )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}