diff --git a/src/kiro_crew/slack/events_home_tab.py b/src/kiro_crew/slack/events_home_tab.py index 32d7f35dc4c..f6f185d3f02 100644 --- a/src/kiro_crew/slack/events_home_tab.py +++ b/src/kiro_crew/slack/events_home_tab.py @@ -118,7 +118,7 @@ async def _publish_home_tab(orch: GatewayOrchestrator, user_id: str) -> None: # aggregate of the launch predicate over every scope, so a server # switched off in the shared config -- or muted by a non-boolean # ``disabled`` -- is not advertised as a capability here. - servers = [s for s in list_servers() if not s.disabled] + servers = [s for s in await asyncio.to_thread(list_servers) if not s.disabled] skills = await asyncio.to_thread(lambda: _get_skills_loader().list_skills()) # Slack caps a single section's text at 3000 chars. MCP servers and diff --git a/test/test_slack_home_tab.py b/test/test_slack_home_tab.py index d12b31972af..057eec8652a 100644 --- a/test/test_slack_home_tab.py +++ b/test/test_slack_home_tab.py @@ -911,6 +911,43 @@ def get_loader(): loader.close() +@pytest.mark.asyncio +async def test_home_tab_mcp_server_listing_runs_off_loop(monkeypatch): + """``list_servers`` reads the agent config, ``mcp.json`` and every + provider's global config (``~/.claude.json`` is routinely megabytes), so + opening the Home tab must not run it on the event loop thread. The + disabled filter still applies to what the worker thread returns.""" + import threading + + loop_thread = threading.get_ident() + threads = [] + + def list_servers(): + threads.append(threading.get_ident()) + return [ + SimpleNamespace(name="builder-mcp", disabled=False), + SimpleNamespace(name="figma", disabled=True), + ] + + loader = MagicMock() + loader.list_skills.return_value = [] + monkeypatch.setattr(events_mod, "list_servers", list_servers) + monkeypatch.setattr(events_mod, "_get_skills_loader", lambda: loader) + monkeypatch.setattr(events_mod, "is_yolo_mode", lambda: False) + monkeypatch.setattr("kiro_crew.sso_status.get_sso_status_line", AsyncMock(return_value="ready")) + orch = _make_orch(slack=SimpleNamespace(views_publish=AsyncMock())) + + await _publish_home_tab(orch, "U123") + + orch.slack.views_publish.assert_awaited_once() + assert len(threads) == 1 + assert threads[0] != loop_thread + text = str(orch.slack.views_publish.call_args[1]["view"]["blocks"]) + assert "MCP Integrations (1)" in text + assert "builder-mcp" in text + assert "figma" not in text + + @dataclass class FakeCronJobTZ: """A cron job that carries its own timezone, like the real CronJob."""