mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 20:50:04 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user