feat(code): surface MCP servers awaiting reconnect on splash banner (#3615)

After a deferred `/mcp login`, the affected server flips to
`status="awaiting_reconnect"` (PR #3612), but the splash banner only
counted `unauthenticated` and `error` — so the "N MCP servers need
login" line decremented with nothing replacing it, even though the
server still can't be used until `/mcp reconnect` runs. Adds a third
counter so the user sees what they need to do next.
This commit is contained in:
Mason Daugherty
2026-05-26 23:44:40 -04:00
committed by GitHub
parent d225cb41f4
commit 24c5258ae6
4 changed files with 232 additions and 0 deletions
+34
View File
@@ -1311,6 +1311,15 @@ class DeepAgentsApp(App):
)
"""MCP servers that failed to load (config or network error)."""
self._mcp_awaiting_reconnect = sum(
1 for s in (mcp_server_info or []) if s.status == "awaiting_reconnect"
)
"""MCP servers that completed OAuth login but are blocked on
`/mcp reconnect` before their tools can load.
See `MCPServerStatus` for the underlying state machine.
"""
self._active_mcp_viewer: Any = None
"""Handle to the `/mcp` modal so server-ready events can refresh it."""
@@ -1734,6 +1743,7 @@ class DeepAgentsApp(App):
mcp_tool_count=self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
connecting=self._connecting,
resuming=self._resume_thread_intent is not None,
local_server=self._server_kwargs is not None,
@@ -2514,6 +2524,9 @@ class DeepAgentsApp(App):
self._mcp_errored = sum(
1 for s in (event.mcp_server_info or []) if s.status == "error"
)
self._mcp_awaiting_reconnect = sum(
1 for s in (event.mcp_server_info or []) if s.status == "awaiting_reconnect"
)
# Update welcome banner to show ready state
try:
@@ -2522,6 +2535,7 @@ class DeepAgentsApp(App):
self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
)
except NoMatches:
logger.warning("Welcome banner not found during server ready transition")
@@ -7721,6 +7735,7 @@ class DeepAgentsApp(App):
self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
)
except NoMatches:
pass
@@ -8504,8 +8519,23 @@ class DeepAgentsApp(App):
self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
)
def _clear_mcp_login_reconnect_banner_counts(self, server_name: str) -> None:
"""Optimistically clear splash login/reconnect prompts before restart.
Args:
server_name: Server whose successful login triggered the reconnect.
"""
self._mcp_unauthenticated = sum(
1
for s in self._mcp_server_info or []
if s.name != server_name and s.needs_attention()
)
self._mcp_awaiting_reconnect = 0
self._refresh_welcome_banner_mcp_counts()
async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None:
"""Restart the server to pick up any deferred MCP login tokens.
@@ -8794,6 +8824,9 @@ class DeepAgentsApp(App):
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")
self._mcp_awaiting_reconnect = sum(
1 for s in self._mcp_server_info if s.status == "awaiting_reconnect"
)
if not matched:
logger.warning(
"MCP login completed for unknown server %r; pending state unchanged",
@@ -9072,6 +9105,7 @@ class DeepAgentsApp(App):
self._pending_mcp_login_reconnect = False
self._pending_mcp_disable_reconnect_servers.clear()
self._sync_pending_mcp_reconnect()
self._clear_mcp_login_reconnect_banner_counts(server_name)
await self._restart_server_for_mcp_refresh(server_name)
return
@@ -120,6 +120,7 @@ class WelcomeBanner(Static):
*,
mcp_unauthenticated: int = 0,
mcp_errored: int = 0,
mcp_awaiting_reconnect: int = 0,
connecting: bool = False,
resuming: bool = False,
local_server: bool = False,
@@ -133,6 +134,9 @@ class WelcomeBanner(Static):
mcp_tool_count: Number of MCP tools loaded at startup.
mcp_unauthenticated: Number of MCP servers awaiting login.
mcp_errored: Number of MCP servers that failed to load.
mcp_awaiting_reconnect: Number of MCP servers that completed OAuth
login but are waiting for `/mcp reconnect` before their tools
can load.
connecting: When `True`, show a "Connecting..." footer instead of
the normal ready prompt. Call `set_connected` to transition.
resuming: When `True`, the connecting footer says "Resuming..."
@@ -155,6 +159,7 @@ class WelcomeBanner(Static):
self._mcp_tool_count = mcp_tool_count
self._mcp_unauthenticated = mcp_unauthenticated
self._mcp_errored = mcp_errored
self._mcp_awaiting_reconnect = mcp_awaiting_reconnect
self._connecting = connecting
self._resuming = resuming
self._local_server = local_server
@@ -271,6 +276,7 @@ class WelcomeBanner(Static):
*,
mcp_unauthenticated: int = 0,
mcp_errored: int = 0,
mcp_awaiting_reconnect: int = 0,
) -> None:
"""Transition from "connecting" to "ready" state.
@@ -278,6 +284,9 @@ class WelcomeBanner(Static):
mcp_tool_count: Number of MCP tools loaded during connection.
mcp_unauthenticated: Number of MCP servers awaiting login.
mcp_errored: Number of MCP servers that failed to load.
mcp_awaiting_reconnect: Number of MCP servers that completed OAuth
login but are waiting for `/mcp reconnect` before their tools
can load.
"""
self._connecting = False
self._reconnecting = False
@@ -288,6 +297,7 @@ class WelcomeBanner(Static):
self._mcp_tool_count = mcp_tool_count
self._mcp_unauthenticated = mcp_unauthenticated
self._mcp_errored = mcp_errored
self._mcp_awaiting_reconnect = mcp_awaiting_reconnect
self.update(self._build_banner(self._project_url))
def set_connecting(self) -> None:
@@ -449,6 +459,18 @@ class WelcomeBanner(Static):
(errored_text, "dim"),
]
)
if self._mcp_awaiting_reconnect > 0:
server_label = "server" if self._mcp_awaiting_reconnect == 1 else "servers"
awaiting_text = (
f"{self._mcp_awaiting_reconnect} MCP {server_label} ready to load "
"— run `/mcp reconnect`\n"
)
parts.extend(
[
(f"{get_glyphs().warning} ", warn_color),
(awaiting_text, "dim"),
]
)
show_connecting = self._connecting and not self._defer_connecting_display
if show_connecting:
+111
View File
@@ -8940,10 +8940,12 @@ class TestMCPLoginCommand:
)
assert app._mcp_unauthenticated == 0
assert app._mcp_errored == 1
assert app._mcp_awaiting_reconnect == 1
banner.set_connected.assert_called_once_with(
1,
mcp_unauthenticated=0,
mcp_errored=1,
mcp_awaiting_reconnect=1,
)
def test_optimistic_mcp_login_pending_state_warns_for_unknown_server(
@@ -8987,6 +8989,66 @@ class TestMCPLoginCommand:
for record in caplog.records
)
def test_init_counts_awaiting_reconnect_servers(self) -> None:
"""Constructor seeds `_mcp_awaiting_reconnect` from initial server info."""
from deepagents_code.mcp_tools import MCPServerInfo
waiting = MCPServerInfo(
name="github",
transport="http",
status="awaiting_reconnect",
error="Authenticated — run `/mcp reconnect` to load tools.",
)
app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[waiting])
assert app._mcp_awaiting_reconnect == 1
async def test_server_ready_drops_awaiting_reconnect_after_reconnect(
self,
) -> None:
"""A successful reconnect clears the awaiting-reconnect counter.
Locks in the contract that `MCPServerReady` recomputes the new counter
from authoritative server info, so the splash line disappears as soon
as the LangGraph server reloads tools.
"""
from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
from deepagents_code.widgets.welcome import WelcomeBanner
waiting = MCPServerInfo(
name="github",
transport="http",
status="awaiting_reconnect",
error="Authenticated — run `/mcp reconnect` to load tools.",
)
app = DeepAgentsApp(agent=MagicMock(), mcp_server_info=[waiting])
assert app._mcp_awaiting_reconnect == 1
banner = MagicMock(spec=WelcomeBanner)
app.query_one = MagicMock(return_value=banner) # type: ignore[assignment]
app.call_after_refresh = lambda cb: cb() # type: ignore[assignment]
loaded = MCPServerInfo(
name="github",
transport="http",
tools=(MCPToolInfo(name="search_repos", description="Search repos"),),
)
app.on_deep_agents_app_server_ready(
app.ServerReady(
agent=MagicMock(), server_proc=None, mcp_server_info=[loaded]
),
)
for _ in range(3):
await asyncio.sleep(0)
assert app._mcp_awaiting_reconnect == 0
banner.set_connected.assert_called_once_with(
1,
mcp_unauthenticated=0,
mcp_errored=0,
mcp_awaiting_reconnect=0,
)
async def test_disable_then_reenable_before_reconnect_clears_pending_notice(
self,
) -> None:
@@ -9423,6 +9485,55 @@ class TestMCPLoginCommand:
restart.assert_awaited_once_with("notion")
assert app._pending_mcp_reconnect is False
async def test_prompt_mcp_reconnect_restart_choice_clears_splash_prompts(
self,
) -> None:
"""Choosing `reconnect` clears stale login/reconnect splash counters."""
from deepagents_code.mcp_tools import MCPServerInfo
from deepagents_code.widgets.welcome import WelcomeBanner
app = DeepAgentsApp(
agent=MagicMock(),
mcp_server_info=[
MCPServerInfo(
name="github",
transport="http",
status="awaiting_reconnect",
error="Authenticated — run `/mcp reconnect` to load tools.",
),
MCPServerInfo(
name="notion",
transport="http",
status="unauthenticated",
error="needs re-authentication",
),
],
)
async with app.run_test() as pilot:
await pilot.pause()
app._pending_mcp_reconnect = True
banner = MagicMock(spec=WelcomeBanner)
def _push_screen(_screen: object, callback: Any) -> None: # noqa: ANN401 # callback signature matches Textual's variant
callback("reconnect")
with (
patch.object(app, "push_screen", side_effect=_push_screen),
patch.object(app, "query_one", return_value=banner),
patch.object(app, "_restart_server_for_mcp_refresh", new=AsyncMock()),
):
await app._prompt_mcp_reconnect("notion")
assert app._mcp_unauthenticated == 0
assert app._mcp_awaiting_reconnect == 0
banner.set_connected.assert_called_once_with(
0,
mcp_unauthenticated=0,
mcp_errored=0,
mcp_awaiting_reconnect=0,
)
async def test_prompt_mcp_reconnect_later_choice_defers(self) -> None:
"""Choosing `later` marks the reconnect pending and reopens the switcher.
@@ -681,3 +681,68 @@ class TestDeferredConnectingDisplay:
widget.set_connecting()
assert widget._defer_connecting_display is False
assert "Connecting to server..." in widget._build_banner().plain
class TestMcpServerCounters:
"""Tests for the MCP server status counter lines on the splash banner."""
def test_unauthenticated_line_singular(self) -> None:
"""A single unauthenticated server reads `'server'`, not `'servers'`."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(mcp_unauthenticated=1)
plain = widget._build_banner().plain
assert "1 MCP server need login" in plain
def test_errored_line_plural(self) -> None:
"""Two errored servers read `'servers'` and route to `/mcp` for details."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(mcp_errored=2)
plain = widget._build_banner().plain
assert "2 MCP servers failed to load" in plain
assert "open /mcp for details" in plain
def test_awaiting_reconnect_line_singular(self) -> None:
"""A single awaiting-reconnect server prompts `/mcp reconnect`."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(mcp_awaiting_reconnect=1)
plain = widget._build_banner().plain
assert "1 MCP server ready to load" in plain
assert "/mcp reconnect" in plain
def test_awaiting_reconnect_line_plural(self) -> None:
"""Multiple awaiting-reconnect servers use plural noun."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(mcp_awaiting_reconnect=3)
plain = widget._build_banner().plain
assert "3 MCP servers ready to load" in plain
def test_no_counter_lines_when_all_zero(self) -> None:
"""Banner has no MCP status warning when all counters are zero."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner()
plain = widget._build_banner().plain
assert "need login" not in plain
assert "ready to load" not in plain
assert "failed to load" not in plain
def test_all_three_counters_render_independently(self) -> None:
"""Unauth, errored, and awaiting-reconnect lines can coexist."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(
mcp_unauthenticated=1,
mcp_errored=1,
mcp_awaiting_reconnect=1,
)
plain = widget._build_banner().plain
assert "need login" in plain
assert "failed to load" in plain
assert "ready to load" in plain
def test_set_connected_updates_awaiting_reconnect(self) -> None:
"""`set_connected` plumbs the new counter onto the banner."""
with patch.dict("os.environ", {}, clear=True):
widget = WelcomeBanner(connecting=True)
with patch.object(widget, "update"):
widget.set_connected(0, mcp_awaiting_reconnect=2)
assert widget._mcp_awaiting_reconnect == 2
assert "2 MCP servers ready to load" in widget._build_banner().plain