mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 20:50:04 -04:00
feat(code): surface deferred MCP reconnect state in /mcp (#3612)
After OAuth login completes, the running LangGraph server still holds the pre-login tool set until `/mcp reconnect`. Until now `/mcp` kept showing those servers as `unauthenticated`, which read as a failed login. A new `awaiting_reconnect` state captures the in-between, with a distinct glyph and a "ready to load" label that points at the reconnect chord.
This commit is contained in:
@@ -1302,7 +1302,7 @@ class DeepAgentsApp(App):
|
||||
"""Total tool count across MCP servers, displayed in the status bar."""
|
||||
|
||||
self._mcp_unauthenticated = sum(
|
||||
1 for s in (mcp_server_info or []) if s.status == "unauthenticated"
|
||||
1 for s in (mcp_server_info or []) if s.needs_attention()
|
||||
)
|
||||
"""MCP servers awaiting a `dcode mcp login` run."""
|
||||
|
||||
@@ -2509,7 +2509,7 @@ class DeepAgentsApp(App):
|
||||
task.add_done_callback(_log_task_exception)
|
||||
self._mcp_tool_count = sum(len(s.tools) for s in (event.mcp_server_info or []))
|
||||
self._mcp_unauthenticated = sum(
|
||||
1 for s in (event.mcp_server_info or []) if s.status == "unauthenticated"
|
||||
1 for s in (event.mcp_server_info or []) if s.needs_attention()
|
||||
)
|
||||
self._mcp_errored = sum(
|
||||
1 for s in (event.mcp_server_info or []) if s.status == "error"
|
||||
@@ -8493,6 +8493,19 @@ class DeepAgentsApp(App):
|
||||
self._pending_mcp_disable_reconnect_servers
|
||||
)
|
||||
|
||||
def _refresh_welcome_banner_mcp_counts(self) -> None:
|
||||
"""Push current MCP counts into the welcome banner when it is mounted."""
|
||||
try:
|
||||
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
||||
except NoMatches:
|
||||
logger.debug("Welcome banner not mounted during MCP count refresh")
|
||||
return
|
||||
banner.set_connected(
|
||||
self._mcp_tool_count,
|
||||
mcp_unauthenticated=self._mcp_unauthenticated,
|
||||
mcp_errored=self._mcp_errored,
|
||||
)
|
||||
|
||||
async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None:
|
||||
"""Restart the server to pick up any deferred MCP login tokens.
|
||||
|
||||
@@ -8747,6 +8760,47 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
self._mcp_server_info = updated
|
||||
|
||||
def _apply_optimistic_mcp_login_pending_state(self, server_name: str) -> None:
|
||||
"""Mark a just-authenticated server as waiting for reconnect.
|
||||
|
||||
OAuth tokens are already persisted at this point, but the running
|
||||
LangGraph server still has the old MCP tool set. This keeps `/mcp`
|
||||
from continuing to label the server as unauthenticated after the
|
||||
user explicitly chose to defer the reconnect.
|
||||
"""
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
|
||||
info = self._mcp_server_info
|
||||
if not info:
|
||||
return
|
||||
|
||||
updated: list[MCPServerInfo] = []
|
||||
matched = False
|
||||
for entry in info:
|
||||
if entry.name != server_name:
|
||||
updated.append(entry)
|
||||
continue
|
||||
matched = True
|
||||
updated.append(
|
||||
MCPServerInfo(
|
||||
name=entry.name,
|
||||
transport=entry.transport,
|
||||
status="awaiting_reconnect",
|
||||
error="Authenticated — run `/mcp reconnect` to load tools.",
|
||||
),
|
||||
)
|
||||
self._mcp_server_info = updated
|
||||
self._mcp_unauthenticated = sum(
|
||||
1 for s in self._mcp_server_info if s.needs_attention()
|
||||
)
|
||||
self._mcp_errored = sum(1 for s in self._mcp_server_info if s.status == "error")
|
||||
if not matched:
|
||||
logger.warning(
|
||||
"MCP login completed for unknown server %r; pending state unchanged",
|
||||
server_name,
|
||||
)
|
||||
self._refresh_welcome_banner_mcp_counts()
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Surface login worker failures that escaped the inner error handling."""
|
||||
from textual.worker import WorkerState
|
||||
@@ -8963,6 +9017,7 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
self._pending_mcp_login_reconnect = True
|
||||
self._sync_pending_mcp_reconnect()
|
||||
self._apply_optimistic_mcp_login_pending_state(server_name)
|
||||
self.notify(
|
||||
f"Logged in to {server_name!r} but the reconnect prompt "
|
||||
"failed. Run `/mcp reconnect` when ready to load the new tools.",
|
||||
@@ -9028,6 +9083,7 @@ class DeepAgentsApp(App):
|
||||
# an action they didn't take.
|
||||
self._pending_mcp_login_reconnect = True
|
||||
self._sync_pending_mcp_reconnect()
|
||||
self._apply_optimistic_mcp_login_pending_state(server_name)
|
||||
if choice == "later":
|
||||
self.notify(
|
||||
f"Logged in to {server_name!r}. Run `/mcp reconnect` when ready "
|
||||
|
||||
@@ -51,12 +51,29 @@ class MCPToolInfo:
|
||||
"""
|
||||
|
||||
|
||||
MCPServerStatus = Literal["ok", "unauthenticated", "error", "disabled"]
|
||||
MCPServerStatus = Literal[
|
||||
"ok",
|
||||
"unauthenticated",
|
||||
"awaiting_reconnect",
|
||||
"error",
|
||||
"disabled",
|
||||
]
|
||||
"""Load states a configured MCP server can end up in.
|
||||
|
||||
`ok` means the server loaded successfully and has an authoritative tool list.
|
||||
|
||||
`unauthenticated` means the server requires OAuth login before tools can load.
|
||||
|
||||
`error` means the server failed to load after a connection or configuration
|
||||
failure.
|
||||
|
||||
`disabled` is set when the user has turned the server off via the TUI
|
||||
(`/mcp` -> F2). No connection is attempted and no tools are loaded, but
|
||||
the entry is still surfaced in the viewer so the user can re-enable it.
|
||||
|
||||
`awaiting_reconnect` is a transient UI-only state used after OAuth login
|
||||
has succeeded but before the LangGraph server has restarted and loaded
|
||||
the newly available MCP tools.
|
||||
"""
|
||||
|
||||
|
||||
@@ -77,7 +94,11 @@ class MCPServerInfo:
|
||||
"""Tools exposed by this server (empty when `status != "ok"`)."""
|
||||
|
||||
status: MCPServerStatus = "ok"
|
||||
"""Load status — `ok`, `unauthenticated`, `error`, or `disabled`."""
|
||||
"""Load status.
|
||||
|
||||
One of `ok`, `unauthenticated`, `awaiting_reconnect`, `error`, or
|
||||
`disabled`.
|
||||
"""
|
||||
|
||||
error: str | None = None
|
||||
"""Human-readable reason when `status != "ok"`."""
|
||||
@@ -111,6 +132,14 @@ class MCPServerInfo:
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Return whether this server has successfully loaded tools."""
|
||||
return self.status == "ok"
|
||||
|
||||
def needs_attention(self) -> bool:
|
||||
"""Return whether this server is blocked on user login."""
|
||||
return self.status == "unauthenticated"
|
||||
|
||||
|
||||
_SUPPORTED_REMOTE_TYPES = {"sse", "http"}
|
||||
"""Supported transport types for remote MCP servers (SSE and HTTP)."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, assert_never
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
@@ -34,6 +34,20 @@ server name returned by `MCPServerInfo.name`, so callers can branch on
|
||||
this exact string without weakening the existing server-name dispatch.
|
||||
"""
|
||||
|
||||
MCP_RECONNECT_KEY = "ctrl+r"
|
||||
"""Textual `Binding` key for the in-viewer reconnect action.
|
||||
|
||||
Kept as a module constant so the footer hint and the help-text rendered
|
||||
in server headers stay in sync with the bound chord.
|
||||
"""
|
||||
|
||||
MCP_RECONNECT_KEY_LABEL = "Ctrl+R"
|
||||
"""Display label for `MCP_RECONNECT_KEY`.
|
||||
|
||||
Shown in the footer hint chip and inline header prompts so the user
|
||||
sees the same chord text the binding will fire on.
|
||||
"""
|
||||
|
||||
|
||||
def _status_glyph(status: MCPServerStatus, glyphs: Glyphs) -> str:
|
||||
"""Return the glyph character for a server `status`.
|
||||
@@ -42,7 +56,8 @@ def _status_glyph(status: MCPServerStatus, glyphs: Glyphs) -> str:
|
||||
(`✓ ⚠ ✗` -> `[OK] [!] [X]`). No new glyph definitions needed.
|
||||
|
||||
Args:
|
||||
status: One of `ok` / `unauthenticated` / `error` / `disabled`.
|
||||
status: One of `ok` / `unauthenticated` / `awaiting_reconnect` /
|
||||
`error` / `disabled`.
|
||||
glyphs: Active `Glyphs` table (Unicode or ASCII).
|
||||
|
||||
Returns:
|
||||
@@ -52,9 +67,13 @@ def _status_glyph(status: MCPServerStatus, glyphs: Glyphs) -> str:
|
||||
return glyphs.checkmark
|
||||
if status == "unauthenticated":
|
||||
return glyphs.warning
|
||||
if status == "awaiting_reconnect":
|
||||
return glyphs.circle_empty
|
||||
if status == "disabled":
|
||||
return glyphs.pause
|
||||
return glyphs.error
|
||||
if status == "error":
|
||||
return glyphs.error
|
||||
assert_never(status)
|
||||
|
||||
|
||||
def _status_color(status: MCPServerStatus, colors: theme.ThemeColors) -> str:
|
||||
@@ -67,7 +86,8 @@ def _status_color(status: MCPServerStatus, colors: theme.ThemeColors) -> str:
|
||||
without code changes.
|
||||
|
||||
Args:
|
||||
status: One of `ok` / `unauthenticated` / `error` / `disabled`.
|
||||
status: One of `ok` / `unauthenticated` / `awaiting_reconnect` /
|
||||
`error` / `disabled`.
|
||||
colors: Active theme palette (typically from `theme.get_theme_colors`).
|
||||
|
||||
Returns:
|
||||
@@ -77,9 +97,13 @@ def _status_color(status: MCPServerStatus, colors: theme.ThemeColors) -> str:
|
||||
return colors.success
|
||||
if status == "unauthenticated":
|
||||
return colors.warning
|
||||
if status == "awaiting_reconnect":
|
||||
return colors.warning
|
||||
if status == "disabled":
|
||||
return colors.muted
|
||||
return colors.error
|
||||
if status == "error":
|
||||
return colors.error
|
||||
assert_never(status)
|
||||
|
||||
|
||||
def _styled(inner: str, style: str) -> str:
|
||||
@@ -148,13 +172,15 @@ def _sanitize_inline(text: str, *, max_length: int = 200) -> str:
|
||||
def _sort_servers_for_display(
|
||||
server_info: list[MCPServerInfo],
|
||||
) -> list[MCPServerInfo]:
|
||||
"""Return `server_info` with `unauthenticated` servers floated to the top.
|
||||
"""Return `server_info` with attention-needed servers floated to the top.
|
||||
|
||||
Stable sort so the user's config order is preserved within each group.
|
||||
Surfacing unauthenticated servers first makes the auth prompt visible
|
||||
without scrolling on configs with many `ok` servers.
|
||||
Surfacing unauthenticated and awaiting-reconnect servers first makes
|
||||
the next action visible without scrolling on configs with many `ok`
|
||||
servers.
|
||||
"""
|
||||
return sorted(server_info, key=lambda s: 0 if s.status == "unauthenticated" else 1)
|
||||
priority = {"unauthenticated": 0, "awaiting_reconnect": 1}
|
||||
return sorted(server_info, key=lambda s: priority.get(s.status, 2))
|
||||
|
||||
|
||||
def _visible_tools_for(
|
||||
@@ -440,7 +466,7 @@ def _render_server_header(
|
||||
dim_style = "" if selected else "dim"
|
||||
tool_count = len(visible_tools)
|
||||
t_label = "tool" if tool_count == 1 else "tools"
|
||||
if server.status == "ok":
|
||||
if server.is_loaded():
|
||||
summary = f" {server.transport} {glyphs.bullet} {tool_count} {t_label}"
|
||||
return Content.assemble(
|
||||
(f"{indicator_glyph} ", indicator_color),
|
||||
@@ -448,7 +474,7 @@ def _render_server_header(
|
||||
(summary, dim_style),
|
||||
)
|
||||
error_text = _sanitize_inline(server.error or "")
|
||||
if server.status == "unauthenticated":
|
||||
if server.needs_attention():
|
||||
login_hint = " — Enter to log in"
|
||||
return Content.assemble(
|
||||
(f"{indicator_glyph} ", indicator_color),
|
||||
@@ -457,13 +483,23 @@ def _render_server_header(
|
||||
(f" {glyphs.bullet} {server.status}", indicator_color),
|
||||
(login_hint, dim_style),
|
||||
)
|
||||
return Content.assemble(
|
||||
(f"{indicator_glyph} ", indicator_color),
|
||||
(server.name, "bold"),
|
||||
(f" {server.transport}", dim_style),
|
||||
(f" {glyphs.bullet} {server.status}", indicator_color),
|
||||
(f" — {error_text}", dim_style) if error_text else "",
|
||||
)
|
||||
if server.status == "awaiting_reconnect":
|
||||
return Content.assemble(
|
||||
(f"{indicator_glyph} ", indicator_color),
|
||||
(server.name, "bold"),
|
||||
(f" {server.transport}", dim_style),
|
||||
(f" {glyphs.bullet} ready to load", indicator_color),
|
||||
(f" — {MCP_RECONNECT_KEY_LABEL} to load tools", dim_style),
|
||||
)
|
||||
if server.status in {"error", "disabled"}:
|
||||
return Content.assemble(
|
||||
(f"{indicator_glyph} ", indicator_color),
|
||||
(server.name, "bold"),
|
||||
(f" {server.transport}", dim_style),
|
||||
(f" {glyphs.bullet} {server.status}", indicator_color),
|
||||
(f" — {error_text}", dim_style) if error_text else "",
|
||||
)
|
||||
assert_never(server.status)
|
||||
|
||||
|
||||
class MCPServerHeaderItem(Static):
|
||||
@@ -598,7 +634,7 @@ class MCPServerHeaderItem(Static):
|
||||
screen = self.screen
|
||||
if not isinstance(screen, MCPViewerScreen):
|
||||
return
|
||||
if self._selected and self._server.status == "unauthenticated":
|
||||
if self._selected and self._server.needs_attention():
|
||||
screen.dismiss(self._server.name)
|
||||
return
|
||||
screen._move_to(self.index)
|
||||
@@ -633,7 +669,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
Binding("ctrl+e", "toggle_all", "Toggle all", show=False, priority=True),
|
||||
Binding("pageup", "page_up", "Page up", show=False, priority=True),
|
||||
Binding("pagedown", "page_down", "Page down", show=False, priority=True),
|
||||
Binding("ctrl+r", "reconnect", "Reconnect", show=False, priority=True),
|
||||
Binding(MCP_RECONNECT_KEY, "reconnect", "Reconnect", show=False, priority=True),
|
||||
Binding("f2", "toggle_disable", "Toggle disable", show=False, priority=True),
|
||||
Binding("escape", "cancel", "Close", show=False, priority=True),
|
||||
]
|
||||
@@ -1070,7 +1106,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
"Ctrl+E expand all",
|
||||
]
|
||||
if self._pending_reconnect:
|
||||
help_parts.append("Ctrl+R reconnect")
|
||||
help_parts.append(f"{MCP_RECONNECT_KEY_LABEL} reconnect")
|
||||
help_parts.extend(["type to filter", "Esc close"])
|
||||
return f" {glyphs.bullet} ".join(help_parts)
|
||||
|
||||
@@ -1086,7 +1122,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
|
||||
if not self._server_info:
|
||||
placeholder = (
|
||||
"Loading MCP tools — server is starting up..."
|
||||
"Loading MCP tools..."
|
||||
if self._connecting
|
||||
else ("No MCP servers configured.\nUse `--mcp-config` to load servers.")
|
||||
)
|
||||
@@ -1311,7 +1347,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
Tool rows expand/collapse as before; activating a header row for
|
||||
a server in `unauthenticated` state dismisses the viewer with the
|
||||
server name so the app can drive in-TUI OAuth login. Headers for
|
||||
other states (ok, error) remain no-ops.
|
||||
other states (ok, awaiting reconnect, error, disabled) remain no-ops.
|
||||
"""
|
||||
if not self._row_widgets:
|
||||
return
|
||||
@@ -1324,7 +1360,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
self.call_after_refresh(row.scroll_visible)
|
||||
return
|
||||
server = row.server
|
||||
if server.status == "unauthenticated":
|
||||
if server.needs_attention():
|
||||
self.dismiss(server.name)
|
||||
|
||||
def action_toggle_all(self) -> None:
|
||||
|
||||
@@ -8903,6 +8903,90 @@ class TestMCPLoginCommand:
|
||||
assert app._mcp_server_info == [original]
|
||||
assert app._mcp_optimistic_original_server_info == {}
|
||||
|
||||
def test_optimistic_mcp_login_pending_state_relabels_only_target(self) -> None:
|
||||
"""Deferred OAuth login updates the target without touching siblings."""
|
||||
from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
|
||||
from deepagents_code.widgets.welcome import WelcomeBanner
|
||||
|
||||
ok = MCPServerInfo(
|
||||
name="filesystem",
|
||||
transport="stdio",
|
||||
tools=(MCPToolInfo(name="read_file", description="Read a file"),),
|
||||
)
|
||||
errored = MCPServerInfo(
|
||||
name="broken",
|
||||
transport="http",
|
||||
status="error",
|
||||
error="connection refused",
|
||||
)
|
||||
target = MCPServerInfo(
|
||||
name="github",
|
||||
transport="http",
|
||||
status="unauthenticated",
|
||||
error="needs re-authentication",
|
||||
)
|
||||
app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[ok, errored, target])
|
||||
banner = MagicMock(spec=WelcomeBanner)
|
||||
app.query_one = MagicMock(return_value=banner) # type: ignore[assignment]
|
||||
|
||||
app._apply_optimistic_mcp_login_pending_state("github")
|
||||
|
||||
assert app._mcp_server_info is not None
|
||||
assert app._mcp_server_info[0] == ok
|
||||
assert app._mcp_server_info[1] == errored
|
||||
assert app._mcp_server_info[2].status == "awaiting_reconnect"
|
||||
assert app._mcp_server_info[2].error == (
|
||||
"Authenticated — run `/mcp reconnect` to load tools."
|
||||
)
|
||||
assert app._mcp_unauthenticated == 0
|
||||
assert app._mcp_errored == 1
|
||||
banner.set_connected.assert_called_once_with(
|
||||
1,
|
||||
mcp_unauthenticated=0,
|
||||
mcp_errored=1,
|
||||
)
|
||||
|
||||
def test_optimistic_mcp_login_pending_state_warns_for_unknown_server(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unexpected OAuth callback does not fail silently."""
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
|
||||
original = MCPServerInfo(
|
||||
name="github",
|
||||
transport="http",
|
||||
status="unauthenticated",
|
||||
error="needs re-authentication",
|
||||
)
|
||||
app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[original])
|
||||
app.query_one = MagicMock(side_effect=NoMatches("welcome-banner")) # type: ignore[assignment]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
|
||||
app._apply_optimistic_mcp_login_pending_state("notion")
|
||||
|
||||
assert app._mcp_server_info == [original]
|
||||
assert app._mcp_unauthenticated == 1
|
||||
assert any(
|
||||
"unknown server 'notion'" in record.message for record in caplog.records
|
||||
)
|
||||
|
||||
def test_refresh_welcome_banner_mcp_counts_ignores_missing_banner(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""MCP count refresh is best-effort before the welcome banner mounts."""
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
app.query_one = MagicMock(side_effect=NoMatches("welcome-banner")) # type: ignore[assignment]
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="deepagents_code.app"):
|
||||
app._refresh_welcome_banner_mcp_counts()
|
||||
|
||||
assert any(
|
||||
"Welcome banner not mounted during MCP count refresh" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
async def test_disable_then_reenable_before_reconnect_clears_pending_notice(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -9345,7 +9429,19 @@ class TestMCPLoginCommand:
|
||||
The viewer is the obvious launchpad for the next login, so deferring
|
||||
routes the user back there instead of dropping them at the chat input.
|
||||
"""
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
|
||||
app = DeepAgentsApp(
|
||||
agent=MagicMock(),
|
||||
mcp_server_info=[
|
||||
MCPServerInfo(
|
||||
name="notion",
|
||||
transport="http",
|
||||
status="unauthenticated",
|
||||
error="needs re-authentication",
|
||||
)
|
||||
],
|
||||
)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app._pending_mcp_reconnect is False
|
||||
@@ -9365,6 +9461,8 @@ class TestMCPLoginCommand:
|
||||
|
||||
restart.assert_not_called()
|
||||
assert app._pending_mcp_reconnect is True
|
||||
assert app._mcp_server_info is not None
|
||||
assert app._mcp_server_info[0].status == "awaiting_reconnect"
|
||||
notify.assert_called_once()
|
||||
message = notify.call_args.args[0]
|
||||
assert "notion" in message
|
||||
|
||||
@@ -47,7 +47,7 @@ def _sample_info() -> list[MCPServerInfo]:
|
||||
|
||||
|
||||
def _mixed_status_info() -> list[MCPServerInfo]:
|
||||
"""Three servers covering all `MCPServerStatus` values."""
|
||||
"""Servers covering all `MCPServerStatus` values."""
|
||||
return [
|
||||
MCPServerInfo(
|
||||
name="filesystem",
|
||||
@@ -60,12 +60,24 @@ def _mixed_status_info() -> list[MCPServerInfo]:
|
||||
status="unauthenticated",
|
||||
error="Run: dcode mcp login github",
|
||||
),
|
||||
MCPServerInfo(
|
||||
name="notion",
|
||||
transport="http",
|
||||
status="awaiting_reconnect",
|
||||
error="Authenticated — run `/mcp reconnect` to load tools.",
|
||||
),
|
||||
MCPServerInfo(
|
||||
name="broken",
|
||||
transport="sse",
|
||||
status="error",
|
||||
error="Connection refused",
|
||||
),
|
||||
MCPServerInfo(
|
||||
name="paused",
|
||||
transport="stdio",
|
||||
status="disabled",
|
||||
error="Disabled in this session",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -1360,12 +1372,34 @@ class TestMCPViewerScreen:
|
||||
app.push_screen(screen, on_dismiss)
|
||||
await pilot.pause()
|
||||
|
||||
# After `unauthenticated`-first sort: github(0), filesystem(1),
|
||||
# read_file tool(2), broken(3).
|
||||
for _ in range(3):
|
||||
# Attention-needed states are floated to the top: github(0),
|
||||
# notion(1), filesystem(2), read_file tool(3), broken(4).
|
||||
for _ in range(4):
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert screen._row_widgets[3]._server.name == "broken" # type: ignore[union-attr]
|
||||
assert screen._row_widgets[4]._server.name == "broken" # type: ignore[union-attr]
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert dismissed_with == []
|
||||
|
||||
async def test_enter_on_awaiting_reconnect_header_is_noop(self) -> None:
|
||||
"""Activating a pending-reconnect header does not restart login."""
|
||||
app = MCPViewerTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
dismissed_with: list[str | None] = []
|
||||
|
||||
def on_dismiss(result: str | None) -> None:
|
||||
dismissed_with.append(result)
|
||||
|
||||
screen = MCPViewerScreen(server_info=_mixed_status_info())
|
||||
app.push_screen(screen, on_dismiss)
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert screen._row_widgets[1]._server.name == "notion" # type: ignore[union-attr]
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
@@ -1565,7 +1599,7 @@ class TestMCPViewerScreen:
|
||||
assert "filter" in text
|
||||
assert "esc" in text
|
||||
|
||||
async def test_three_state_status_indicators_render(self) -> None:
|
||||
async def test_status_indicators_render(self) -> None:
|
||||
"""Each `MCPServerStatus` produces a visually distinct header line.
|
||||
|
||||
We assert on rendered text + glyph (the user-visible signal); the
|
||||
@@ -1579,13 +1613,16 @@ class TestMCPViewerScreen:
|
||||
await pilot.pause()
|
||||
|
||||
headers = screen.query(".mcp-server-header")
|
||||
assert len(headers) == 3
|
||||
assert len(headers) == 5
|
||||
|
||||
# `unauthenticated` servers float to the top, so the order is:
|
||||
# github (unauth), filesystem (ok), broken (err).
|
||||
# github (unauth), notion (ready to load), filesystem (ok),
|
||||
# broken (err), paused (disabled).
|
||||
unauth_text = _widget_text(headers[0])
|
||||
ok_text = _widget_text(headers[1])
|
||||
err_text = _widget_text(headers[2])
|
||||
pending_text = _widget_text(headers[1])
|
||||
ok_text = _widget_text(headers[2])
|
||||
err_text = _widget_text(headers[3])
|
||||
disabled_text = _widget_text(headers[4])
|
||||
|
||||
assert "filesystem" in ok_text
|
||||
assert "stdio" in ok_text
|
||||
@@ -1596,11 +1633,18 @@ class TestMCPViewerScreen:
|
||||
# user to leave the app and run `dcode mcp login`.
|
||||
assert "Enter to log in" in unauth_text
|
||||
|
||||
assert "notion" in pending_text
|
||||
assert "ready to load" in pending_text
|
||||
assert "Ctrl+R to load tools" in pending_text
|
||||
|
||||
assert "broken" in err_text
|
||||
assert "error" in err_text
|
||||
assert "Connection refused" in err_text
|
||||
|
||||
def test_status_color_maps_three_states(self) -> None:
|
||||
assert "paused" in disabled_text
|
||||
assert "disabled" in disabled_text
|
||||
|
||||
def test_status_color_maps_all_states(self) -> None:
|
||||
"""Unit-level: each status maps to the correct theme color attribute."""
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.widgets.mcp_viewer import _status_color
|
||||
@@ -1608,7 +1652,9 @@ class TestMCPViewerScreen:
|
||||
colors = theme.get_theme_colors()
|
||||
assert _status_color("ok", colors) == colors.success
|
||||
assert _status_color("unauthenticated", colors) == colors.warning
|
||||
assert _status_color("awaiting_reconnect", colors) == colors.warning
|
||||
assert _status_color("error", colors) == colors.error
|
||||
assert _status_color("disabled", colors) == colors.muted
|
||||
|
||||
async def test_status_indicator_glyphs_use_glyph_set(self) -> None:
|
||||
"""Status icons reuse existing `Glyphs` (unicode by default)."""
|
||||
@@ -1622,10 +1668,13 @@ class TestMCPViewerScreen:
|
||||
|
||||
glyphs = get_glyphs()
|
||||
headers = screen.query(".mcp-server-header")
|
||||
# `unauthenticated` floats to the top: warning, then ok, then error.
|
||||
# Attention-needed states float to the top: unauth (warning),
|
||||
# awaiting_reconnect (empty circle), ok, error, disabled.
|
||||
assert glyphs.warning in _widget_text(headers[0])
|
||||
assert glyphs.checkmark in _widget_text(headers[1])
|
||||
assert glyphs.error in _widget_text(headers[2])
|
||||
assert glyphs.circle_empty in _widget_text(headers[1])
|
||||
assert glyphs.checkmark in _widget_text(headers[2])
|
||||
assert glyphs.error in _widget_text(headers[3])
|
||||
assert glyphs.pause in _widget_text(headers[4])
|
||||
|
||||
async def test_synthetic_config_error_entry_renders(self) -> None:
|
||||
"""A `<config:foo>` entry from a malformed config file does not crash."""
|
||||
@@ -1710,13 +1759,19 @@ class TestModuleLevelHelpers:
|
||||
|
||||
# --- _sort_servers_for_display ---
|
||||
|
||||
def test_sort_servers_floats_unauthenticated_to_top(self) -> None:
|
||||
"""`unauthenticated` servers move ahead of `ok` and `error` servers."""
|
||||
def test_sort_servers_floats_attention_needed_to_top(self) -> None:
|
||||
"""Actionable servers move ahead of `ok` and `error` servers."""
|
||||
from deepagents_code.widgets.mcp_viewer import _sort_servers_for_display
|
||||
|
||||
info = _mixed_status_info()
|
||||
ordered = _sort_servers_for_display(info)
|
||||
assert [s.name for s in ordered] == ["github", "filesystem", "broken"]
|
||||
assert [s.name for s in ordered] == [
|
||||
"github",
|
||||
"notion",
|
||||
"filesystem",
|
||||
"broken",
|
||||
"paused",
|
||||
]
|
||||
|
||||
def test_sort_servers_is_stable_within_groups(self) -> None:
|
||||
"""Original config order is preserved among same-priority servers."""
|
||||
|
||||
Reference in New Issue
Block a user