Skip to content

Commit 73e47ea

Browse files
committed
fix(mcp): keep waiting for box recovery
1 parent 06e03af commit 73e47ea

2 files changed

Lines changed: 86 additions & 8 deletions

File tree

src/langbot/pkg/provider/tools/loaders/mcp.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,9 @@ async def _init_stdio_python_server(self):
313313
# that is reconnecting is handled above and waits for availability.
314314
#
315315
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
316-
# wrapper can short-circuit (retrying is pointless when Box is
317-
# deliberately off) and the frontend can render a localized,
318-
# actionable message instead of this raw RuntimeError. Keep the
319-
# message itself short — the frontend ignores it for this phase.
316+
# wrapper can distinguish a deliberately disabled Box from an enabled
317+
# runtime that is still reconnecting. Keep the message itself short —
318+
# the frontend ignores it for this phase.
320319
box_service = getattr(self.ap, 'box_service', None)
321320
if box_service is not None and not getattr(box_service, 'available', False):
322321
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
@@ -620,17 +619,29 @@ async def _lifecycle_loop_with_retry(self):
620619
await asyncio.sleep(2)
621620
continue
622621
except Exception as e:
623-
self.retry_count = attempt + 1
624622
if self._shutdown_event.is_set():
625623
return # Shutdown requested, don't retry
626-
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
627-
# failure — retrying produces log spam and a misleading
628-
# "Failed after N attempts" message. Surface it immediately.
629624
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
625+
box_service = getattr(self.ap, 'box_service', None)
626+
if box_service is not None and getattr(box_service, 'enabled', True):
627+
# Box is configured and may recover independently of
628+
# this MCP session. Keep retrying without consuming the
629+
# fatal budget; _wait_for_box_runtime() rate-limits the
630+
# loop to one warning per startup timeout.
631+
self.status = MCPSessionStatus.CONNECTING
632+
self.error_message = None
633+
self.error_phase = None
634+
await asyncio.sleep(1)
635+
continue
636+
# Explicitly disabled Box is a deliberate refusal, not a
637+
# transient failure. Surface it immediately without log
638+
# spam or a misleading "Failed after N attempts" message.
639+
self.retry_count = attempt + 1
630640
self.status = MCPSessionStatus.ERROR
631641
self.error_message = str(e)
632642
self._ready_event.set()
633643
return
644+
self.retry_count = attempt + 1
634645
if attempt >= self._MAX_RETRIES:
635646
self.status = MCPSessionStatus.ERROR
636647
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'

tests/unit_tests/provider/test_mcp_box_integration.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,73 @@ async def reconnect_box(_delay):
793793

794794
assert ap.box_service.available is True
795795

796+
@pytest.mark.asyncio
797+
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
798+
ap = _make_ap()
799+
ap.box_service.available = False
800+
ap.box_service.enabled = True
801+
session = _make_session(
802+
mcp_module,
803+
{
804+
'name': 'test',
805+
'uuid': 'test-uuid',
806+
'mode': 'stdio',
807+
'command': 'python',
808+
'args': [],
809+
},
810+
ap=ap,
811+
)
812+
attempts = 0
813+
814+
async def lifecycle():
815+
nonlocal attempts
816+
attempts += 1
817+
if attempts == 1:
818+
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
819+
raise RuntimeError('Box runtime is not available after 1 seconds')
820+
821+
session._lifecycle_loop = lifecycle
822+
sleep = AsyncMock()
823+
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
824+
825+
await session._lifecycle_loop_with_retry()
826+
827+
assert attempts == 2
828+
assert session.retry_count == 0
829+
sleep.assert_awaited_once_with(1)
830+
831+
@pytest.mark.asyncio
832+
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
833+
ap = _make_ap()
834+
ap.box_service.available = False
835+
ap.box_service.enabled = False
836+
session = _make_session(
837+
mcp_module,
838+
{
839+
'name': 'test',
840+
'uuid': 'test-uuid',
841+
'mode': 'stdio',
842+
'command': 'python',
843+
'args': [],
844+
},
845+
ap=ap,
846+
)
847+
attempts = 0
848+
849+
async def lifecycle():
850+
nonlocal attempts
851+
attempts += 1
852+
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
853+
raise RuntimeError('box_disabled_in_config')
854+
855+
session._lifecycle_loop = lifecycle
856+
857+
await session._lifecycle_loop_with_retry()
858+
859+
assert attempts == 1
860+
assert session.status == mcp_module.MCPSessionStatus.ERROR
861+
assert session.retry_count == 1
862+
796863
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
797864
ap = _make_ap()
798865
del ap.box_service

0 commit comments

Comments
 (0)