fix(webui): surface MCP runtime connection failures (#5331)

This commit is contained in:
chengyongru
2026-08-11 23:52:02 +08:00
committed by GitHub
parent 1edfd268db
commit d45c893f68
31 changed files with 838 additions and 123 deletions
+4
View File
@@ -46,6 +46,10 @@ async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
return mcp_tools.runtime_status(state)
async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)
+6 -1
View File
@@ -95,7 +95,7 @@ from nanobot.utils.runtime import (
)
if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import (
ChannelsConfig,
Config,
@@ -401,6 +401,7 @@ class AgentLoop:
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
@@ -646,6 +647,10 @@ class AgentLoop:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return connection state learned from real MCP runtime attempts."""
return agent_context.mcp_runtime_status(self)
def register_runtime_context_provider(
self,
provider: RuntimeContextProvider,
+96 -2
View File
@@ -9,7 +9,7 @@ import shutil
import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx
@@ -62,6 +62,10 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
_SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
("connecting", "connected", "failed")
)
class MCPConnection(Protocol):
@@ -1297,17 +1301,92 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def _runtime_status_store(
state: Any,
*,
create: bool = False,
) -> dict[str, MCPRuntimeStatus] | None:
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
if isinstance(raw_statuses, dict):
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
if not create:
return None
statuses: dict[str, MCPRuntimeStatus] = {}
state._mcp_runtime_statuses = statuses
return statuses
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
"""Return the latest connection-attempt result for configured MCP servers."""
statuses = _runtime_status_store(state)
raw_configured: object = getattr(state, "_mcp_servers", None)
if statuses is None or not isinstance(raw_configured, dict):
return {}
configured = cast(dict[str, Any], raw_configured)
return {
name: status
for name, status in statuses.items()
if name in configured and status in _MCP_RUNTIME_STATUSES
}
def _set_runtime_status(
state: Any,
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
status: MCPRuntimeStatus,
) -> None:
statuses = _runtime_status_store(state, create=True)
assert statuses is not None
for name in server_names:
statuses[name] = status
def _record_connection_result(
state: Any,
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
) -> None:
attempted_names = set(attempted)
connected_names = set(connected)
_set_runtime_status(state, connected_names, "connected")
_set_runtime_status(state, attempted_names - connected_names, "failed")
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live."""
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return
missing_servers = {
configured_missing = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
}
oauth_servers = {
name: cfg
for name, cfg in configured_missing.items()
if getattr(cfg, "auth", None) == "oauth"
}
authorization_pending: set[str] = set()
if oauth_servers:
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
authorization_pending = {
name
for name, cfg in oauth_servers.items()
if not mcp_oauth_has_credentials(name, cfg.url)
}
statuses = _runtime_status_store(state)
if statuses is not None:
for name in authorization_pending:
statuses.pop(name, None)
missing_servers = {
name: cfg
for name, cfg in configured_missing.items()
if name not in authorization_pending
}
if state._mcp_connecting or not missing_servers:
return
state._mcp_connecting = True
_set_runtime_status(state, missing_servers, "connecting")
try:
connected = await connect_mcp_servers(missing_servers, registry)
if getattr(state, "_mcp_closing", False):
@@ -1315,6 +1394,7 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
await connection.aclose()
return
state._mcp_stacks.update(connected)
_record_connection_result(state, missing_servers, connected)
_attach_reconnect_handlers(state, registry, connected)
if connected:
logger.info("MCP connected servers: {}", sorted(connected))
@@ -1323,8 +1403,10 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
except asyncio.CancelledError:
if task_is_cancelling():
raise
_set_runtime_status(state, missing_servers, "failed")
logger.warning("MCP connection cancelled (will retry next message)")
except BaseException as e:
_set_runtime_status(state, missing_servers, "failed")
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
finally:
state._mcp_connecting = False
@@ -1380,6 +1462,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, name)
runtime_statuses = _runtime_status_store(state)
if runtime_statuses is not None:
for name in [*removed, *authorization_pending]:
runtime_statuses.pop(name, None)
state._mcp_servers = next_servers
retry_missing = sorted(
name
@@ -1394,6 +1481,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
_set_runtime_status(state, to_connect, "connecting")
connected = await connect_mcp_servers(to_connect, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
@@ -1404,6 +1492,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
state._mcp_stacks.update(connected)
_record_connection_result(state, to_connect, connected)
_attach_reconnect_handlers(state, registry, connected)
failed = sorted(set(to_connect) - set(connected))
@@ -1562,12 +1651,14 @@ async def _refresh_terminated_server(
_unregister_server_tools(registry, server_name)
await _close_server(state, server_name)
_set_runtime_status(state, {server_name}, "connecting")
connected = await connect_mcp_servers({server_name: cfg}, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
await connection.aclose()
return None
state._mcp_stacks.update(connected)
_record_connection_result(state, {server_name}, connected)
_attach_reconnect_handlers(state, registry, connected)
if server_name not in connected:
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
@@ -1621,6 +1712,9 @@ async def close_mcp_servers(state: Any) -> None:
async with _reload_lock(state):
connections = list(state._mcp_stacks.items())
state._mcp_stacks.clear()
statuses = _runtime_status_store(state)
if statuses is not None:
statuses.clear()
for name, connection in connections:
try:
await connection.aclose()
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -100,6 +100,7 @@ class ChannelManager:
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None,
):
@@ -119,6 +120,7 @@ class ChannelManager:
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {}
@@ -187,6 +189,7 @@ class ChannelManager:
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status,
mcp_runtime_status=self._webui_mcp_runtime_status,
skill_state_action=self._webui_skill_state_action,
logger=logger,
)
+1
View File
@@ -668,6 +668,7 @@ def _run_gateway(
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_mcp_runtime_status=agent.mcp_runtime_status,
webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path),
)
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
@@ -64,6 +65,7 @@ def build_gateway_services(
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
@@ -116,6 +118,7 @@ def build_gateway_services(
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
skill_state_action=skill_state_action,
log=logger,
)
+76 -5
View File
@@ -60,7 +60,10 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
McpRuntimeStatus = Callable[[], Mapping[str, str]]
class McpPresetError(Exception):
@@ -943,6 +946,7 @@ def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
tool_preview: Mapping[str, list[str]] | None = None,
runtime_status: Mapping[str, str] | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config()
@@ -970,7 +974,38 @@ def mcp_presets_payload(
}
if last_action is not None:
payload["last_action"] = last_action
return payload
return attach_mcp_runtime_status(payload, runtime_status)
def attach_mcp_runtime_status(
payload: dict[str, Any],
runtime_status: Mapping[str, str] | None,
) -> dict[str, Any]:
"""Project safe, connection-attempt state onto configured MCP rows."""
if runtime_status is None:
return payload
projected = dict(payload)
raw_rows: object = payload.get("presets", [])
preset_rows = cast(list[object], raw_rows) if isinstance(raw_rows, list) else []
rows: list[Any] = []
for raw_row in preset_rows:
if not isinstance(raw_row, dict):
rows.append(raw_row)
continue
row = dict(cast(dict[str, Any], raw_row))
name = row.get("name")
status = runtime_status.get(name) if isinstance(name, str) else None
if (
status in _MCP_RUNTIME_STATUSES
and row.get("installed") is True
and row.get("configured") is True
):
row["runtime_status"] = status
else:
row.pop("runtime_status", None)
rows.append(row)
projected["presets"] = rows
return projected
def _display_name_for(name: str, preset: McpPreset | None = None) -> str:
@@ -1003,6 +1038,7 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
"import-cursor": "Imported",
"tools": "Updated tools for",
"remove": "Removed",
"reconnect": "Retried connection for",
}.get(action, "Updated")
payload: dict[str, Any] = {
"ok": ok,
@@ -1017,6 +1053,24 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
return payload
def mcp_reconnect_action(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Validate a configured server before asking the live runtime to retry it."""
name = _validated_server_name((_query_first(query, "name") or "").strip())
config = load_config(config_path) if config_path is not None else load_config()
if name not in config.tools.mcp_servers:
raise McpPresetError("unknown MCP server", status=404)
payload = mcp_presets_payload(
last_action=_server_action_message("reconnect", name),
config_path=config_path,
)
payload["requires_restart"] = True
return payload
def _scrub_test_error(text: str) -> str:
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
@@ -1571,12 +1625,16 @@ async def mcp_presets_settings_action(
query: QueryParams,
*,
reload_mcp: McpReload | None = None,
mcp_runtime_status: McpRuntimeStatus | None = None,
config: WebUISettingsConfig | None = None,
) -> dict[str, Any]:
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
config_path = config.path if config is not None else None
if action is None:
return mcp_presets_payload(config_path=config_path)
return mcp_presets_payload(
runtime_status=mcp_runtime_status() if mcp_runtime_status is not None else None,
config_path=config_path,
)
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config()
@@ -1601,8 +1659,18 @@ async def mcp_presets_settings_action(
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
if action == "test":
return await mcp_presets_test_action(query, config_path=config_path)
if config is not None:
payload = await mcp_presets_test_action(query, config_path=config_path)
return attach_mcp_runtime_status(
payload,
mcp_runtime_status() if mcp_runtime_status is not None else None,
)
if action == "reconnect":
payload = await asyncio.to_thread(
mcp_reconnect_action,
query,
config_path=config_path,
)
elif config is not None:
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
payload = await asyncio.to_thread(
config.run_serialized,
@@ -1614,4 +1682,7 @@ async def mcp_presets_settings_action(
payload = await asyncio.to_thread(mcp_presets_action, action, query)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
return attach_mcp_runtime_status(
payload,
mcp_runtime_status() if mcp_runtime_status is not None else None,
)
+5 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import html
import json
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any, cast
from websockets.http11 import Request as WsRequest
@@ -94,6 +94,7 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/reconnect": "reconnect",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
@@ -225,6 +226,7 @@ class WebUISettingsRouter:
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None:
self.settings = settings
@@ -238,6 +240,7 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status
self._mcp_runtime_status = mcp_runtime_status
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set()
@@ -470,6 +473,7 @@ class WebUISettingsRouter:
deny_code=deny_code,
mcp_presets_action=mcp_presets_settings_action,
reload_mcp=lambda: request_mcp_reload(self.bus),
mcp_runtime_status=self._mcp_runtime_status,
check_for_update=check_for_update,
channel_feature_action=self._channel_feature_action,
channel_runtime_status=self._channel_runtime_status,
+3 -1
View File
@@ -6,7 +6,7 @@ import asyncio
import inspect
import re
import time
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast
@@ -55,6 +55,7 @@ class SystemSettingsOperations:
deny_code: SettingsOperation
mcp_presets_action: SettingsOperation
reload_mcp: SettingsOperation
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
check_for_update: SettingsOperation
channel_feature_action: SettingsOperation | None = None
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
@@ -928,6 +929,7 @@ class SystemSettingsHandler:
action,
request.query,
reload_mcp=operations.reload_mcp,
mcp_runtime_status=operations.mcp_runtime_status,
config=self.settings.config,
)
except Exception as exc:
+4 -1
View File
@@ -14,7 +14,7 @@ import json
import mimetypes
import re
import time
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote, urlsplit, urlunsplit
@@ -163,6 +163,7 @@ _WEBUI_MUTATION_PATHS = {
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.reconnect": "/api/settings/mcp-presets/reconnect",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
"settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
@@ -306,6 +307,7 @@ class GatewayHTTPHandler:
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
log: Any = logger,
) -> None:
@@ -348,6 +350,7 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
)