# NousResearch/hermes-agent · Cron jobs lack MCP server initialization

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #21354. Verify it addresses your case.
- **Recommended:** read PR #21354 (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: `resolved` · deciding PR(s): `pr-21354`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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.

## Closed / duplicate issues

- #4219: Cron jobs cannot use skills, plugins, and MCP servers

## Prior & competing PRs

- **#4213: fix(cli): use display width for chat input; fix(cron): init MCP servers**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#21354: fix(cron): initialize MCP servers before constructing the cron AIAgent**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cron/scheduler.py`, `tests/cron/test_scheduler_mcp_init.py`
  - fix hunks:
  - `cron/scheduler.py`

    ```diff
    @@ -1261,6 +1261,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
                 except Exception as e:
                     logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
     
    +        # Initialize MCP servers so configured mcp_servers are available to
    +        # the agent's tool registry before AIAgent is constructed. Without
    +        # this, cron jobs never saw any MCP tools — only the gateway / CLI
    +        # paths called discover_mcp_tools() at startup. Idempotent: subsequent
    +        # ticks short-circuit on already-connected servers inside
    +        # register_mcp_servers(). Non-fatal on failure: a broken MCP server
    +        # shouldn't kill an otherwise-working cron job. See #4219.
    +        try:
    +            from tools.mcp_tool import discover_mcp_tools
    +            _mcp_tools = discover_mcp_tools()
    +            if _mcp_tools:
    +                logger.info(
    +                    "Job '%s': %d MCP tool(s) available",
    +                    job_id, len(_mcp_tools),
    +                )
    +        except Exception as _mcp_exc:
    +            logger.warning(
    +                "Job '%s': MCP initialization failed (non-fatal): %s",
    +                job_id, _mcp_exc,
    +            )
    +
             agent = AIAgent(
                 model=model,
                 api_key=runtime.get("api_key"),
    ```
  - `tests/cron/test_scheduler_mcp_init.py`

    ```diff
    @@ -0,0 +1,140 @@
    +"""Regression tests for MCP server availability in cron jobs.
    +
    +Background
    +==========
    +``cron/scheduler.py:run_job()`` constructs ``AIAgent(...)`` directly without
    +calling ``discover_mcp_tools()`` — the initialization that CLI and gateway
    +paths do at startup. Cron jobs therefore never saw any MCP tools from
    +``mcp_servers`` in config.yaml. See #4219.
    +
    +The fix inserts ``discover_mcp_tools()`` before the ``AIAgent(...)`` call,
    +wrapped in try/except so a broken MCP server can't kill an otherwise
    +working cron job. ``discover_mcp_tools`` is idempotent — subsequent ticks
    +short-circuit on already-connected servers.
    +"""
    +
    +from __future__ import annotations
    +
    +from unittest.mock import patch, MagicMock
    +
    +import pytest
    +
    +
    +def test_run_job_calls_discover_mcp_tools_before_agent_construction():
    +    """The LLM-path branch of run_job must call discover_mcp_tools() before
    +    the AIAgent construction, so MCP tools are in the registry by the time
    +    the agent asks for its tool schema."""
    +    from cron import scheduler
    +
    +    job = {
    +        "id": "mcp-cron-test",
    +        "name": "mcp-cron-test",
    +        "prompt": "test",
    +    }
    +
    +    call_order = []
    +
    +    def fake_discover():
    +        call_order.append("discover_mcp_tools")
    +        return ["mcp_server1_tool"]
    +
    +    # AIAgent is a class; replace with a recording stub
    +    class _FakeAgent:
    +        def __init__(self, *args, **kwargs):
    +            call_order.append("AIAgent.__init__")
    +            self._kwargs = kwargs
    +            self._interrupt_requested = False
    +            self.quiet_mode = True
    +
    +        def run_conversation(self, *args, **kwargs):
    +            return {
    +                "final_response": "ok",
    +                "messages": [],
    +            }
    +
    +    with patch("tools.mcp_tool.discover_mcp_tools", side_effect=fake_discover), \
    +         patch("run_agent.AIAgent", _FakeAgent), \
    +         patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None):
    +        scheduler.run_job(job)
    +
    +    # Discovery must be called, and must be called BEFORE agent construction.
    +    assert "discover_mcp_tools" in call_order, (
    +        "run_job did not call discover_mcp_tools — MCP tools unavailable in cron"
    +    )
    +    d_idx = call_order.index("discover_mcp_tools")
    +    a_idx = call_order.index("AIAgent.__init__")
    +    assert d_idx < a_idx, (
    +        f"discover_mcp_tools was called AFTER AIAgent construction "
    +        f"(indices discover={d_idx}, agent={a_idx}); MCP tools missed the "
    +        f"registry window. Full order: {call_order}"
    +    )
    +
    +
    +def test_run_job_tolerates_discover_mcp_tools_failure():
    +    """A broken MCP server must not kill an otherwise working cron job.
    +    discover_mcp_tools() raising should be caught and logged, and the agent
    +    should still run."""
    +    from cron import scheduler
    +
    +    job = {
    +        "id": "mcp-cron-fail",
    +        "name": "mcp-cron-fail",
    +        "prompt": "test",
    +    }
    +
    +    agent_was_constructed = []
    +
    +    class _FakeAgent:
    +        def __init__(self, *args, **kwargs):
    +            agent_was_constructed.append(True)
    +            self._interrupt_requested = False
    +            self.quiet_mode = True
    +
    +        def run_conversation(self, *args, **kwargs):
    +            return {"final_response": "ok", "messages": []}
    +
    +    def fake_discover_that_raises():
    +        raise RuntimeError("MCP server unreachable")
    +
    +    with patch(
    +        "tools.mcp_tool.discover_mcp_tools",
    +        side_effect=fake_discover_that_raises,
    +    ), patch("run_agent.AIAgent", _FakeAgent), \
    +         patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None):
    +        # Should NOT raise
    +        success, doc, final_response, error = scheduler.run_job(job)
    +
    +    assert agent_was_constructed, (
    +        "AIAgent was not constructed after discover_mcp_tools raised — "
    +        "MCP failure incorrectly killed the cron job"
    +    )
    +
    +
    +def test_no_agent_cron_job_does_not_initialize_mcp():
    +    """Cron jobs with no_agent=True are script-only — no AIAgent, no MCP
    +    tools needed. We must NOT pay the MCP init cost for those."""
    +    from cron import scheduler
    +
    +    job = {
    +        "id": "noagent-job",
    +        "name": "noagent-job",
    +        "no_agent": True,
    +        "script": "/nonexistent/script.sh",
    +    }
    +
    +    discover_called = []
    +
    +    def fake_discover():
    +        discover_called.append(True)
    +        return []
    +
    +    # _run_job_script returns (ok, output); make it fail cleanly so we
    +    # don't need a real script file.
    +    with patch("tools.mcp_tool.discover_mcp_tools", side_effect=fake_discover), \
    +         patch("cron.scheduler._run_job_script", return_value=(False, "no such file")):
    +        scheduler.run_job(job)
    +
    +    assert not discover_called, (
    +        "discover_mcp_tools was called for a no_agent job — wasted MCP init "
    +        "for a script-only cron tick"
    +    )
    ```

## Fix status

- Merged to main in PR #21354 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-07`.
- Subsystem: `cron`
