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:
Mason Daugherty
2026-05-26 22:48:38 -04:00
committed by GitHub
parent 771e55f171
commit d8205c2a39
5 changed files with 320 additions and 46 deletions
+58 -2
View File
@@ -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 "
+31 -2
View File
@@ -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)."""
+60 -24
View File
@@ -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:
+99 -1
View File
@@ -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
+72 -17
View File
@@ -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."""