Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
402 lines
15 KiB
Python
402 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from textual.widgets.option_list import Option
|
|
|
|
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
|
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 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
|
|
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
assert ordered == ["alpha", "Beta", "Zeta"]
|
|
|
|
|
|
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_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()
|
|
assert "press R to refresh" in str(
|
|
option_list.add_option.call_args.args[0].prompt
|
|
)
|
|
|
|
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
|
|
)
|