diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 07f4349a9..e8fc5b1bf 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -313,10 +313,9 @@ class RuntimeMCPSession: # that is reconnecting is handled above and waits for availability. # # Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry - # wrapper can short-circuit (retrying is pointless when Box is - # deliberately off) and the frontend can render a localized, - # actionable message instead of this raw RuntimeError. Keep the - # message itself short — the frontend ignores it for this phase. + # wrapper can distinguish a deliberately disabled Box from an enabled + # runtime that is still reconnecting. Keep the message itself short — + # the frontend ignores it for this phase. box_service = getattr(self.ap, 'box_service', None) if box_service is not None and not getattr(box_service, 'available', False): self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE @@ -620,17 +619,29 @@ class RuntimeMCPSession: await asyncio.sleep(2) continue except Exception as e: - self.retry_count = attempt + 1 if self._shutdown_event.is_set(): return # Shutdown requested, don't retry - # BOX_UNAVAILABLE is a deliberate refusal, not a transient - # failure — retrying produces log spam and a misleading - # "Failed after N attempts" message. Surface it immediately. if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE: + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'enabled', True): + # Box is configured and may recover independently of + # this MCP session. Keep retrying without consuming the + # fatal budget; _wait_for_box_runtime() rate-limits the + # loop to one warning per startup timeout. + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(1) + continue + # Explicitly disabled Box is a deliberate refusal, not a + # transient failure. Surface it immediately without log + # spam or a misleading "Failed after N attempts" message. + self.retry_count = attempt + 1 self.status = MCPSessionStatus.ERROR self.error_message = str(e) self._ready_event.set() return + self.retry_count = attempt + 1 if attempt >= self._MAX_RETRIES: self.status = MCPSessionStatus.ERROR self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}' diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index b234218bf..caf06bf6d 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -793,6 +793,73 @@ class TestGetRuntimeInfoDict: assert ap.box_service.available is True + @pytest.mark.asyncio + async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch): + ap = _make_ap() + ap.box_service.available = False + ap.box_service.enabled = True + session = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + }, + ap=ap, + ) + attempts = 0 + + async def lifecycle(): + nonlocal attempts + attempts += 1 + if attempts == 1: + session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE + raise RuntimeError('Box runtime is not available after 1 seconds') + + session._lifecycle_loop = lifecycle + sleep = AsyncMock() + monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep) + + await session._lifecycle_loop_with_retry() + + assert attempts == 2 + assert session.retry_count == 0 + sleep.assert_awaited_once_with(1) + + @pytest.mark.asyncio + async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module): + ap = _make_ap() + ap.box_service.available = False + ap.box_service.enabled = False + session = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'stdio', + 'command': 'python', + 'args': [], + }, + ap=ap, + ) + attempts = 0 + + async def lifecycle(): + nonlocal attempts + attempts += 1 + session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE + raise RuntimeError('box_disabled_in_config') + + session._lifecycle_loop = lifecycle + + await session._lifecycle_loop_with_retry() + + assert attempts == 1 + assert session.status == mcp_module.MCPSessionStatus.ERROR + assert session.retry_count == 1 + def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module): ap = _make_ap() del ap.box_service