from __future__ import annotations from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from textual.content import Content from textual.widgets.option_list import Option from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.cli.textual_ui.shortcut_hints import SHORTCUT_STYLE from vibe.cli.textual_ui.widgets.mcp_app import ( _LIST_VIEW_HELP_AUTH, _LIST_VIEW_HELP_TOOLS, MCPApp, MCPSourceKind, _sort_connector_names_for_menu, collect_mcp_tool_index, ) from vibe.core.config import MCPHttp, MCPOAuth, MCPStdio from vibe.core.tools.base import InvokeContext from vibe.core.tools.connectors.connector_registry import ( ConnectorAuthAction, RemoteTool, ) from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult, _OpenArgs from vibe.core.types import ToolStreamEvent def _make_tool_cls( *, is_mcp: bool, description: str = "", server_name: str | None = None, remote_name: str = "tool", ) -> type: if not is_mcp: return type("FakeTool", (), {"description": description}) async def _run( self: Any, args: _OpenArgs, ctx: InvokeContext | None = None ) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]: yield MCPToolResult(ok=True, server="", tool="", text=None) return type( "FakeMCPTool", (MCPTool,), { "description": description, "_server_name": server_name, "_remote_name": remote_name, "run": _run, }, ) def _make_tool_manager( all_tools: dict[str, type], available_tools: dict[str, type] | None = None ) -> MagicMock: mgr = MagicMock() mgr.registered_tools = all_tools mgr.available_tools = available_tools if available_tools is not None else all_tools return mgr def _make_oauth_server(*, name: str = "oauth", disabled: bool = False) -> MCPHttp: return MCPHttp( name=name, transport="http", url="http://mcp.example.test", auth=MCPOAuth(type="oauth", scopes=[]), disabled=disabled, ) class TestCollectMcpToolIndex: def test_non_mcp_tools_are_excluded(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] all_tools = { "srv_tool": _make_tool_cls(is_mcp=True, server_name="srv"), "bash": _make_tool_cls(is_mcp=False), } mgr = _make_tool_manager(all_tools) index = collect_mcp_tool_index(servers, mgr) assert "bash" not in str(index.server_tools) assert len(index.server_tools["srv"]) == 1 def test_counts_match_available_vs_all(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] tool_a = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_a") tool_b = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_b") all_tools = {"srv_tool_a": tool_a, "srv_tool_b": tool_b} available = {"srv_tool_a": tool_a} mgr = _make_tool_manager(all_tools, available) index = collect_mcp_tool_index(servers, mgr) assert len(index.server_tools["srv"]) == 2 enabled = sum( 1 for t, _ in index.server_tools["srv"] if t in index.enabled_tools ) assert enabled == 1 def test_tool_with_no_matching_server_is_skipped(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] all_tools = {"other_tool": _make_tool_cls(is_mcp=True, server_name="other")} mgr = _make_tool_manager(all_tools) index = collect_mcp_tool_index(servers, mgr) assert index.server_tools == {} def test_empty_servers_returns_empty(self) -> None: mgr = _make_tool_manager({ "srv_tool": _make_tool_cls(is_mcp=True, server_name="srv") }) index = collect_mcp_tool_index([], mgr) assert index.server_tools == {} class TestMCPAppInit: def test_viewing_server_none_when_no_initial_server(self) -> None: mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr) assert app._viewing_server is None def test_initial_server_stripped_and_stored(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr, initial_server=" srv ") assert app._viewing_server == "srv" def test_widget_id_is_mcp_app(self) -> None: mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr) assert app.id == "mcp-app" def test_refresh_view_unknown_server_falls_back_to_overview(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app.query_one = MagicMock() app._refresh_view("nonexistent") assert app._viewing_server is None def test_refresh_view_known_server_sets_viewing_server(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app.query_one = MagicMock() app._refresh_view("srv") assert app._viewing_server == "srv" def test_refresh_view_none_clears_viewing_server(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app._viewing_server = "srv" app.query_one = MagicMock() app._refresh_view(None) assert app._viewing_server is None def test_action_back_calls_refresh_view_none(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app._viewing_server = "srv" render_calls: list[str | None] = [] app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name) app.action_back() assert render_calls == [None] def test_action_back_noop_when_in_overview(self) -> None: mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr) app._viewing_server = None render_calls: list[str | None] = [] app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name) app.action_back() assert render_calls == [] @pytest.mark.asyncio async def test_action_refresh_dispatches_worker(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) refresh_callback = AsyncMock(return_value="Refreshed.") app = MCPApp( mcp_servers=servers, tool_manager=mgr, refresh_callback=refresh_callback ) app._viewing_server = "srv" app._set_help_text = MagicMock() app.run_worker = MagicMock() await app.action_refresh() assert app._status_message == "Refreshing..." assert app._refreshing is True app.run_worker.assert_called_once() def test_on_worker_state_changed_updates_after_refresh(self) -> None: from textual.worker import Worker servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app.refresh_index = MagicMock() worker = MagicMock(spec=Worker) worker.group = "refresh" worker.is_finished = True worker.result = "Refreshed." event = MagicMock(spec=Worker.StateChanged) event.worker = worker app.on_worker_state_changed(event) assert app._status_message == "Refreshed." assert app._refreshing is False app.refresh_index.assert_called_once() def test_close_blocked_while_refreshing(self) -> None: mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr) app._refreshing = True app.post_message = MagicMock() app.action_close() app.post_message.assert_not_called() def test_back_blocked_while_refreshing(self) -> None: servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr) app._viewing_server = "srv" app._refreshing = True render_calls: list[str | None] = [] app._refresh_view = lambda server_name, *, kind=None: render_calls.append( server_name ) app.action_back() assert render_calls == [] class TestConnectorMenuOrdering: def test_connectors_are_sorted_by_connected_state_then_name(self) -> None: registry = FakeConnectorRegistry( connectors={ "zeta": [], "alpha": [RemoteTool(name="lookup", description="Lookup")], "beta": [], } ) ordered = _sort_connector_names_for_menu( registry.get_connector_names(), registry, set() ) assert ordered == ["alpha", "beta", "zeta"] def test_sorting_is_case_insensitive(self) -> None: registry = FakeConnectorRegistry( connectors={ "Zeta": [], "alpha": [RemoteTool(name="lookup", description="Lookup")], "Beta": [], } ) ordered = _sort_connector_names_for_menu( registry.get_connector_names(), registry, set() ) assert ordered == ["alpha", "Beta", "Zeta"] def test_disabled_connectors_sink_to_bottom(self) -> None: registry = FakeConnectorRegistry( connectors={ "alpha": [RemoteTool(name="lookup", description="Lookup")], "beta": [RemoteTool(name="search", description="Search")], "gamma": [], } ) # alpha is enabled+connected, beta is enabled+connected but disabled in # config, gamma is enabled+disconnected. Expected: enabled-and-connected # first, then enabled-but-disconnected, then disabled at the bottom. ordered = _sort_connector_names_for_menu( registry.get_connector_names(), registry, {"beta"} ) assert ordered == ["alpha", "gamma", "beta"] def test_full_ordering_enabled_then_connected_then_alpha(self) -> None: registry = FakeConnectorRegistry( connectors={ "d_disabled_connected": [RemoteTool(name="t", description="t")], "b_enabled_disconnected": [], "a_enabled_connected": [RemoteTool(name="t", description="t")], "c_enabled_connected": [RemoteTool(name="t", description="t")], "e_disabled_disconnected": [], } ) ordered = _sort_connector_names_for_menu( registry.get_connector_names(), registry, {"d_disabled_connected", "e_disabled_disconnected"}, ) assert ordered == [ "a_enabled_connected", "c_enabled_connected", "b_enabled_disconnected", "d_disabled_connected", "e_disabled_disconnected", ] class TestHelpBarChanges: """The help bar should show 'Enter Authenticate' for disconnected connectors.""" def _make_app_with_connectors(self) -> MCPApp: registry = FakeConnectorRegistry( connectors={ "gmail": [RemoteTool(name="search", description="Search")], "slack": [], }, auth_actions={"slack": ConnectorAuthAction.OAUTH}, ) mgr = _make_tool_manager({}) return MCPApp( mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")], tool_manager=mgr, connector_registry=registry, ) def test_help_shows_show_tools_for_server(self) -> None: app = self._make_app_with_connectors() option = Option("", id="server:srv") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS def test_help_shows_show_tools_for_connected_connector(self) -> None: app = self._make_app_with_connectors() option = Option("", id="connector:gmail") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS def test_help_shows_authenticate_for_disconnected_connector(self) -> None: app = self._make_app_with_connectors() option = Option("", id="connector:slack") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH def test_help_shows_authenticate_for_oauth_server(self) -> None: server = _make_oauth_server() registry = FakeMCPRegistry() mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) option = Option("", id="server:oauth") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH def test_help_shows_tools_for_disabled_oauth_server(self) -> None: server = _make_oauth_server(disabled=True) registry = FakeMCPRegistry() mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) option = Option("", id="server:oauth") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS def test_help_shows_tools_for_degraded_connector(self) -> None: registry = FakeConnectorRegistry(connectors={"slack": []}) mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) option = Option("", id="connector:slack") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS def test_help_defaults_to_tools_for_unknown_option(self) -> None: app = self._make_app_with_connectors() option = Option("") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS def test_help_shows_tools_without_registry(self) -> None: mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr) option = Option("", id="connector:slack") assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS class TestConnectorAuthRequested: """Clicking a disconnected connector posts ConnectorAuthRequested.""" def test_disconnected_connector_posts_auth_requested(self) -> None: registry = FakeConnectorRegistry( connectors={"slack": []}, auth_actions={"slack": ConnectorAuthAction.OAUTH} ) mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view( "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR ) app.post_message.assert_called_once() msg = app.post_message.call_args.args[0] assert isinstance(msg, MCPApp.ConnectorAuthRequested) assert msg.connector_name == "slack" assert msg.connector_registry is registry assert msg.tool_manager is mgr def test_degraded_connector_shows_refresh_message(self) -> None: registry = FakeConnectorRegistry(connectors={"slack": []}) mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view( "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR ) app.post_message.assert_not_called() option_list.add_option.assert_called_once() prompt = option_list.add_option.call_args.args[0].prompt assert isinstance(prompt, Content) assert "press r to refresh" in prompt.plain assert any(span.style == SHORTCUT_STYLE for span in prompt.spans) def test_connected_connector_with_no_indexed_tools_shows_message(self) -> None: registry = MagicMock() registry.get_connector_names.return_value = ["slack"] registry.is_connected.return_value = True mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view( "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR ) app.post_message.assert_not_called() option_list.add_option.assert_called_once() assert "No tools discovered" in str( option_list.add_option.call_args.args[0].prompt ) def test_server_with_no_tools_shows_message(self) -> None: mgr = _make_tool_manager({}) app = MCPApp( mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")], tool_manager=mgr, ) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view("srv", option_list, app._index, kind=MCPSourceKind.SERVER) # Should NOT post ConnectorAuthRequested for servers app.post_message.assert_not_called() # Should add a "no tools" option instead option_list.add_option.assert_called_once() assert "No tools discovered" in str( option_list.add_option.call_args.args[0].prompt ) def test_oauth_server_with_no_tools_posts_auth_requested(self) -> None: server = _make_oauth_server() registry = FakeMCPRegistry() mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view( "oauth", option_list, app._index, kind=MCPSourceKind.SERVER ) app.post_message.assert_called_once() msg = app.post_message.call_args.args[0] assert isinstance(msg, MCPApp.MCPOAuthRequested) assert msg.server_name == "oauth" assert msg.mcp_registry is registry option_list.add_option.assert_not_called() def test_oauth_server_with_stale_tools_posts_auth_requested(self) -> None: server = _make_oauth_server() registry = FakeMCPRegistry() stale_tool = _make_tool_cls( is_mcp=True, server_name="oauth", remote_name="stale" ) mgr = _make_tool_manager({"oauth_stale": stale_tool}) app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) app.query_one = MagicMock() app.post_message = MagicMock() option_list = MagicMock() app._show_detail_view( "oauth", option_list, app._index, kind=MCPSourceKind.SERVER ) app.post_message.assert_called_once() assert isinstance(app.post_message.call_args.args[0], MCPApp.MCPOAuthRequested) option_list.add_option.assert_not_called() class TestServerStatusLabels: def test_servers_show_enabled_disabled_and_needs_auth(self) -> None: servers = [ MCPStdio(name="filesystem", transport="stdio", command="npx"), MCPHttp(name="search", transport="http", url="http://mcp.example.test"), _make_oauth_server(name="oauth"), _make_oauth_server(name="disabled-oauth", disabled=True), ] registry = FakeMCPRegistry() mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=servers, tool_manager=mgr, mcp_registry=registry) option_list = MagicMock() app._list_mcp_servers(option_list, app._index) prompts = [ str(call.args[0].prompt) for call in option_list.add_option.call_args_list ] assert any("filesystem" in prompt and "enabled" in prompt for prompt in prompts) assert any("search" in prompt and "enabled" in prompt for prompt in prompts) assert any("oauth" in prompt and "needs auth" in prompt for prompt in prompts) assert any( "disabled-oauth" in prompt and "disabled" in prompt for prompt in prompts ) def test_oauth_server_with_cached_tools_shows_connected(self) -> None: server = _make_oauth_server() registry = FakeMCPRegistry() registry.set_tools([server], {}) mgr = _make_tool_manager({}) app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry) option_list = MagicMock() app._list_mcp_servers(option_list, app._index) prompts = [ str(call.args[0].prompt) for call in option_list.add_option.call_args_list ] assert any("oauth" in prompt and "connected" in prompt for prompt in prompts)