Skip to content

Commit b800721

Browse files
teknium1Ryan
authored andcommitted
fix(cron): initialize MCP servers before constructing the cron AIAgent (NousResearch#21354)
cron/scheduler.py:run_job() constructed AIAgent(...) without ever calling discover_mcp_tools(). The CLI and gateway paths do this at startup; cron jobs inherited none of it and the user's configured mcp_servers were invisible inside every cron run. Insert discover_mcp_tools() right before AIAgent(), wrapped in try/except so a broken MCP server can't kill an otherwise-working cron job. The call is idempotent: register_mcp_servers() short-circuits on already-connected servers, so subsequent ticks in the same scheduler process pay ~0ms. Scoped to the LLM path only; no_agent script jobs skip it entirely. Closes NousResearch#4219.
1 parent 772e02e commit b800721

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

cron/scheduler.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
13331333
except Exception as e:
13341334
logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
13351335

1336+
# Initialize MCP servers so configured mcp_servers are available to
1337+
# the agent's tool registry before AIAgent is constructed. Without
1338+
# this, cron jobs never saw any MCP tools — only the gateway / CLI
1339+
# paths called discover_mcp_tools() at startup. Idempotent: subsequent
1340+
# ticks short-circuit on already-connected servers inside
1341+
# register_mcp_servers(). Non-fatal on failure: a broken MCP server
1342+
# shouldn't kill an otherwise-working cron job. See #4219.
1343+
try:
1344+
from tools.mcp_tool import discover_mcp_tools
1345+
_mcp_tools = discover_mcp_tools()
1346+
if _mcp_tools:
1347+
logger.info(
1348+
"Job '%s': %d MCP tool(s) available",
1349+
job_id, len(_mcp_tools),
1350+
)
1351+
except Exception as _mcp_exc:
1352+
logger.warning(
1353+
"Job '%s': MCP initialization failed (non-fatal): %s",
1354+
job_id, _mcp_exc,
1355+
)
1356+
13361357
agent = AIAgent(
13371358
model=model,
13381359
api_key=runtime.get("api_key"),
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Regression tests for MCP server availability in cron jobs.
2+
3+
Background
4+
==========
5+
``cron/scheduler.py:run_job()`` constructs ``AIAgent(...)`` directly without
6+
calling ``discover_mcp_tools()`` — the initialization that CLI and gateway
7+
paths do at startup. Cron jobs therefore never saw any MCP tools from
8+
``mcp_servers`` in config.yaml. See #4219.
9+
10+
The fix inserts ``discover_mcp_tools()`` before the ``AIAgent(...)`` call,
11+
wrapped in try/except so a broken MCP server can't kill an otherwise
12+
working cron job. ``discover_mcp_tools`` is idempotent — subsequent ticks
13+
short-circuit on already-connected servers.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from unittest.mock import patch, MagicMock
19+
20+
import pytest
21+
22+
23+
def test_run_job_calls_discover_mcp_tools_before_agent_construction():
24+
"""The LLM-path branch of run_job must call discover_mcp_tools() before
25+
the AIAgent construction, so MCP tools are in the registry by the time
26+
the agent asks for its tool schema."""
27+
from cron import scheduler
28+
29+
job = {
30+
"id": "mcp-cron-test",
31+
"name": "mcp-cron-test",
32+
"prompt": "test",
33+
}
34+
35+
call_order = []
36+
37+
def fake_discover():
38+
call_order.append("discover_mcp_tools")
39+
return ["mcp_server1_tool"]
40+
41+
# AIAgent is a class; replace with a recording stub
42+
class _FakeAgent:
43+
def __init__(self, *args, **kwargs):
44+
call_order.append("AIAgent.__init__")
45+
self._kwargs = kwargs
46+
self._interrupt_requested = False
47+
self.quiet_mode = True
48+
49+
def run_conversation(self, *args, **kwargs):
50+
return {
51+
"final_response": "ok",
52+
"messages": [],
53+
}
54+
55+
with patch("tools.mcp_tool.discover_mcp_tools", side_effect=fake_discover), \
56+
patch("run_agent.AIAgent", _FakeAgent), \
57+
patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None):
58+
scheduler.run_job(job)
59+
60+
# Discovery must be called, and must be called BEFORE agent construction.
61+
assert "discover_mcp_tools" in call_order, (
62+
"run_job did not call discover_mcp_tools — MCP tools unavailable in cron"
63+
)
64+
d_idx = call_order.index("discover_mcp_tools")
65+
a_idx = call_order.index("AIAgent.__init__")
66+
assert d_idx < a_idx, (
67+
f"discover_mcp_tools was called AFTER AIAgent construction "
68+
f"(indices discover={d_idx}, agent={a_idx}); MCP tools missed the "
69+
f"registry window. Full order: {call_order}"
70+
)
71+
72+
73+
def test_run_job_tolerates_discover_mcp_tools_failure():
74+
"""A broken MCP server must not kill an otherwise working cron job.
75+
discover_mcp_tools() raising should be caught and logged, and the agent
76+
should still run."""
77+
from cron import scheduler
78+
79+
job = {
80+
"id": "mcp-cron-fail",
81+
"name": "mcp-cron-fail",
82+
"prompt": "test",
83+
}
84+
85+
agent_was_constructed = []
86+
87+
class _FakeAgent:
88+
def __init__(self, *args, **kwargs):
89+
agent_was_constructed.append(True)
90+
self._interrupt_requested = False
91+
self.quiet_mode = True
92+
93+
def run_conversation(self, *args, **kwargs):
94+
return {"final_response": "ok", "messages": []}
95+
96+
def fake_discover_that_raises():
97+
raise RuntimeError("MCP server unreachable")
98+
99+
with patch(
100+
"tools.mcp_tool.discover_mcp_tools",
101+
side_effect=fake_discover_that_raises,
102+
), patch("run_agent.AIAgent", _FakeAgent), \
103+
patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None):
104+
# Should NOT raise
105+
success, doc, final_response, error = scheduler.run_job(job)
106+
107+
assert agent_was_constructed, (
108+
"AIAgent was not constructed after discover_mcp_tools raised — "
109+
"MCP failure incorrectly killed the cron job"
110+
)
111+
112+
113+
def test_no_agent_cron_job_does_not_initialize_mcp():
114+
"""Cron jobs with no_agent=True are script-only — no AIAgent, no MCP
115+
tools needed. We must NOT pay the MCP init cost for those."""
116+
from cron import scheduler
117+
118+
job = {
119+
"id": "noagent-job",
120+
"name": "noagent-job",
121+
"no_agent": True,
122+
"script": "/nonexistent/script.sh",
123+
}
124+
125+
discover_called = []
126+
127+
def fake_discover():
128+
discover_called.append(True)
129+
return []
130+
131+
# _run_job_script returns (ok, output); make it fail cleanly so we
132+
# don't need a real script file.
133+
with patch("tools.mcp_tool.discover_mcp_tools", side_effect=fake_discover), \
134+
patch("cron.scheduler._run_job_script", return_value=(False, "no such file")):
135+
scheduler.run_job(job)
136+
137+
assert not discover_called, (
138+
"discover_mcp_tools was called for a no_agent job — wasted MCP init "
139+
"for a script-only cron tick"
140+
)

0 commit comments

Comments
 (0)