From d45c893f68123008df5fdfdbd70f5bd22049709b Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:52:02 +0800 Subject: [PATCH] fix(webui): surface MCP runtime connection failures (#5331) --- nanobot/agent/context.py | 4 + nanobot/agent/loop.py | 7 +- nanobot/agent/tools/mcp.py | 98 +++++- nanobot/channels/manager.py | 5 +- nanobot/cli/gateway_runtime.py | 1 + nanobot/webui/gateway_services.py | 3 + nanobot/webui/mcp_presets_api.py | 81 ++++- nanobot/webui/settings_routes.py | 6 +- nanobot/webui/settings_system.py | 4 +- nanobot/webui/ws_http.py | 5 +- tests/agent/test_mcp_connection.py | 27 +- tests/cli/test_commands.py | 4 + tests/tools/test_mcp_tool.py | 66 +++++ tests/webui/test_mcp_presets_api.py | 62 ++++ tests/webui/test_settings_routes.py | 53 +++- .../settings/system/AppsSettings.tsx | 279 ++++++++++++------ .../system/createSystemSettingsActions.ts | 6 +- .../system/useSystemSettingsEffects.ts | 40 ++- webui/src/i18n/locales/en/common.json | 3 + webui/src/i18n/locales/es/common.json | 3 + webui/src/i18n/locales/fr/common.json | 3 + webui/src/i18n/locales/id/common.json | 3 + webui/src/i18n/locales/ja/common.json | 3 + webui/src/i18n/locales/ko/common.json | 3 + webui/src/i18n/locales/pt-BR/common.json | 3 + webui/src/i18n/locales/vi/common.json | 3 + webui/src/i18n/locales/zh-CN/common.json | 3 + webui/src/i18n/locales/zh-TW/common.json | 3 + webui/src/lib/api.ts | 2 +- webui/src/lib/types.ts | 1 + webui/src/tests/settings-apps-oauth.test.tsx | 177 ++++++++++- 31 files changed, 838 insertions(+), 123 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 393e9fd80..44169ba9b 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -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) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 23c555cf4..35514c5e7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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, diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1fe4a6bab..e590d130c 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -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() diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 15fd52768..437651813 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -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, ) diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 10a3e061e..3f48cff14 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -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), ) diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index f5c65a83d..0460f582a 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -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, ) diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 581cf8023..24221a4d7 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -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", text.strip()) scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1", 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, + ) diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 66b906a18..633bff4d5 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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, diff --git a/nanobot/webui/settings_system.py b/nanobot/webui/settings_system.py index 826ee989f..f57394742 100644 --- a/nanobot/webui/settings_system.py +++ b/nanobot/webui/settings_system.py @@ -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: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 4942bc7d3..19ef9014b 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -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, ) diff --git a/tests/agent/test_mcp_connection.py b/tests/agent/test_mcp_connection.py index 05f03eb98..50476553b 100644 --- a/tests/agent/test_mcp_connection.py +++ b/tests/agent/test_mcp_connection.py @@ -6,7 +6,7 @@ import asyncio from contextlib import AsyncExitStack from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import anyio import pytest @@ -155,6 +155,31 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch assert attempts == 2 assert loop._mcp_stacks == {} + assert loop.mcp_runtime_status() == {"test": "failed"} + + +@pytest.mark.asyncio +async def test_connect_mcp_does_not_report_failure_before_oauth_authorization( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + cfg = MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.example.com/mcp", + ) + loop = _make_loop(tmp_path, mcp_servers={"oauth-app": cfg}) + connect = AsyncMock() + monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect) + monkeypatch.setattr( + "nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials", + lambda _name, _url: False, + ) + + await loop._connect_mcp() + + connect.assert_not_awaited() + assert loop.mcp_runtime_status() == {} @pytest.mark.asyncio diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index bfc88bd72..55d77edb5 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -84,6 +84,10 @@ class _GatewayAgentContractStub: tools = ToolRegistry() + @staticmethod + def mcp_runtime_status() -> dict[str, str]: + return {} + @staticmethod def pending_cron_job_ids_for_session(_session_key: str) -> set[str]: return set() diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index d94050ad6..008c887f7 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -181,6 +181,72 @@ async def test_connect_missing_servers_propagates_external_cancellation(monkeypa assert state._mcp_connecting is False +@pytest.mark.asyncio +async def test_saved_oauth_http_403_projects_failed_runtime_without_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class OAuthAuth(httpx.Auth): + def auth_flow(self, request: httpx.Request): + request.headers["Authorization"] = "Bearer saved-oauth-secret" + yield request + + class AuthorizationRequiredError(RuntimeError): + pass + + async def create_auth(_name: str, _url: str, _handlers=None) -> httpx.Auth: + return OAuthAuth() + + @asynccontextmanager + async def rejected_streamable_http(url: str, http_client=None): + request = httpx.Request("POST", f"{url}?access_token=saved-oauth-secret") + response = httpx.Response(403, request=request) + raise httpx.HTTPStatusError( + "403 Forbidden for saved-oauth-secret", + request=request, + response=response, + ) + yield object(), object(), object() + + async def reachable(_url: str) -> bool: + return True + + oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth") + oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined] + oauth_mod.create_mcp_oauth_auth = create_auth # type: ignore[attr-defined] + oauth_mod.mcp_oauth_has_credentials = lambda _name, _url: True # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod) + monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, "")) + monkeypatch.setattr(mcp_mod, "_probe_http_url", reachable) + monkeypatch.setattr( + sys.modules["mcp.client.streamable_http"], + "streamable_http_client", + rejected_streamable_http, + ) + + class State: + pass + + state = State() + state._mcp_closing = False + state._mcp_servers = { + "xmind": MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://app.xmind.com/api/mcp", + ) + } + state._mcp_stacks = {} + state._mcp_runtime_statuses = {} + state._mcp_connecting = False + + await mcp_mod.connect_missing_servers(state, ToolRegistry()) + + snapshot = mcp_mod.runtime_status(state) + assert snapshot == {"xmind": "failed"} + assert "saved-oauth-secret" not in str(snapshot) + assert "app.xmind.com" not in str(snapshot) + + def test_wrapper_preserves_non_nullable_unions() -> None: tool_def = SimpleNamespace( name="demo", diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 65677451d..91b89bf19 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -172,10 +172,72 @@ async def test_oauth_preset_is_one_click_configured_after_token_storage( assert row["configured"] is True assert row["status"] == "configured" + failed = mcp_presets_payload(runtime_status={"xmind": "failed"}) + row = next(item for item in failed["presets"] if item["name"] == "xmind") + assert row["configured"] is True + assert row["status"] == "configured" + assert row["runtime_status"] == "failed" + assert "secret" not in str(row) + + healthy = mcp_presets_payload(runtime_status={"xmind": "connected"}) + row = next(item for item in healthy["presets"] if item["name"] == "xmind") + assert row["runtime_status"] == "connected" + mcp_presets_action("remove", {"name": ["xmind"]}) assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None +@pytest.mark.asyncio +async def test_settings_list_projects_runtime_snapshot_and_reconnects_custom_server( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + custom_mcp_action( + "custom", + { + "name": ["team-docs"], + "transport": ["streamableHttp"], + "url": ["https://mcp.example.com/mcp"], + }, + ) + statuses = {"team-docs": "failed"} + reload_calls = 0 + + async def reload_mcp() -> dict[str, object]: + nonlocal reload_calls + reload_calls += 1 + statuses["team-docs"] = "connected" + return { + "ok": True, + "connected": ["team-docs"], + "failed": [], + "requires_restart": False, + } + + listed = await mcp_presets_settings_action( + None, + {}, + reload_mcp=reload_mcp, + mcp_runtime_status=lambda: statuses, + ) + row = next(item for item in listed["presets"] if item["name"] == "team-docs") + assert row["configured"] is True + assert row["runtime_status"] == "failed" + assert reload_calls == 0 + + reconnected = await mcp_presets_settings_action( + "reconnect", + {"name": ["team-docs"]}, + reload_mcp=reload_mcp, + mcp_runtime_status=lambda: statuses, + ) + row = next(item for item in reconnected["presets"] if item["name"] == "team-docs") + assert row["runtime_status"] == "connected" + assert reconnected["requires_restart"] is False + assert reload_calls == 1 + + def test_enable_browserbase_writes_scrubbed_config_payload( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index 92607743c..c048d8ec1 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +from collections.abc import Callable, Mapping +from pathlib import Path from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock from urllib.parse import parse_qs, urlsplit @@ -10,13 +12,19 @@ from websockets.datastructures import Headers from nanobot.config.loader import get_config_path from nanobot.webui.http_utils import http_json_response +from nanobot.webui.mcp_presets_api import custom_mcp_action from nanobot.webui.settings_routes import WebUISettingsRouter from nanobot.webui.settings_services import WebUISettingsServices -def _router(*, authorized: bool = True) -> WebUISettingsRouter: +def _router( + *, + authorized: bool = True, + config_path: Path | None = None, + mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, +) -> WebUISettingsRouter: return WebUISettingsRouter( - settings=WebUISettingsServices.create(get_config_path()), + settings=WebUISettingsServices.create(config_path or get_config_path()), bus=SimpleNamespace(), logger=SimpleNamespace(exception=lambda *_args: None), check_api_token=lambda _request: authorized, @@ -28,6 +36,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter: ), runtime_surface="browser", runtime_capabilities={}, + mcp_runtime_status=mcp_runtime_status, mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback", ) @@ -40,6 +49,46 @@ def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace: return request +@pytest.mark.asyncio +async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> None: + config_path = tmp_path / "config.json" + custom_mcp_action( + "custom", + { + "name": ["team-docs"], + "transport": ["streamableHttp"], + "url": ["https://mcp.example.com/mcp"], + }, + config_path=config_path, + ) + snapshot_calls = 0 + + def runtime_snapshot() -> Mapping[str, str]: + nonlocal snapshot_calls + snapshot_calls += 1 + return {"team-docs": "failed"} + + router = _router( + config_path=config_path, + mcp_runtime_status=runtime_snapshot, + ) + request = SimpleNamespace( + path="/api/settings/mcp-presets", + headers=Headers(), + ) + + response = await router.dispatch(None, request, "/api/settings/mcp-presets") + + assert response is not None + assert response.status_code == 200 + payload = json.loads(response.body) + row = next(item for item in payload["presets"] if item["name"] == "team-docs") + assert row["status"] == "configured" + assert row["runtime_status"] == "failed" + assert b'"runtime_status": "failed"' in response.body + assert snapshot_calls == 1 + + @pytest.mark.asyncio async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: config = SimpleNamespace( diff --git a/webui/src/components/settings/system/AppsSettings.tsx b/webui/src/components/settings/system/AppsSettings.tsx index 14354491c..18d34ad1e 100644 --- a/webui/src/components/settings/system/AppsSettings.tsx +++ b/webui/src/components/settings/system/AppsSettings.tsx @@ -17,6 +17,7 @@ import { Database, ExternalLink, Loader2, + MoreHorizontal, PauseCircle, PlayCircle, Plus, @@ -24,6 +25,7 @@ import { Search, Server, SlidersHorizontal, + TriangleAlert, Trash2, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -158,8 +160,8 @@ export function AppsCatalogSettings({ onQueryChange: (value: string) => void; onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; - onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; - onMcpOAuthConnect: (name: string) => void; + onMcpAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record) => void; + onMcpOAuthConnect: (name: string, reset?: boolean) => void; onMcpOAuthCancel: () => void; onMcpOAuthOpen: () => void; onMcpOAuthCallbackUrlChange: (value: string) => void; @@ -324,6 +326,7 @@ export function AppsCatalogSettings({ oauthCompleting={mcpOAuthCompleting} oauthCallbackError={mcpOAuthCallbackError} showBrandLogos={showBrandLogos} + showTypeBadge={filter !== "mcp"} onFieldChange={onMcpFieldChange} onAction={onMcpAction} onOAuthConnect={onMcpOAuthConnect} @@ -487,6 +490,7 @@ function McpAppsCatalogRow({ oauthCompleting, oauthCallbackError, showBrandLogos, + showTypeBadge, onFieldChange, onAction, onOAuthConnect, @@ -505,9 +509,10 @@ function McpAppsCatalogRow({ oauthCompleting: boolean; oauthCallbackError: string | null; showBrandLogos: boolean; + showTypeBadge: boolean; onFieldChange: (presetName: string, fieldName: string, value: string) => void; - onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; - onOAuthConnect: (name: string) => void; + onAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record) => void; + onOAuthConnect: (name: string, reset?: boolean) => void; onOAuthCancel: () => void; onOAuthOpen: () => void; onOAuthCallbackUrlChange: (value: string) => void; @@ -522,17 +527,29 @@ function McpAppsCatalogRow({ const disableBusy = actionKey === `disable:${preset.name}`; const removeBusy = actionKey === `remove:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`; + const reconnectBusy = actionKey === `reconnect:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; const oauthBusy = actionKey === `oauth:${preset.name}`; const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; - const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy || oauthBusy; + const busy = enableBusy || disableBusy || removeBusy || testBusy || reconnectBusy || toolsBusy || oauthBusy; const agentPlugin = preset.source === "agent-plugin"; const toggleable = preset.enabled !== undefined; const isOAuth = preset.auth === "oauth"; const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const hasFields = preset.required_fields.length > 0; const needsSetupInput = missingFields.length > 0; - const readyInstalled = preset.enabled ?? (preset.installed && preset.configured); + const configuredInstalled = preset.installed && preset.configured; + const readyInstalled = preset.enabled ?? configuredInstalled; + const runtimeConnected = !toggleable && preset.runtime_status === "connected"; + const runtimeConnecting = !toggleable && preset.runtime_status === "connecting"; + const runtimeFailed = !toggleable && preset.runtime_status === "failed"; + const statusLabel = toggleable + ? tx("settings.nanobotFeatures.enabled", "Enabled") + : runtimeConnected + ? tx("settings.mcp.connected", "Connected.") + : mcpPresetStatusLabel(preset.status, tx); + const failureLabel = tx("settings.mcp.connectionFailed", "Connection failed."); + const reconnectLabel = tx("settings.mcp.reconnect", "Reconnect"); const canEnable = preset.install_supported && (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); @@ -544,9 +561,6 @@ function McpAppsCatalogRow({ const detail = agentPlugin && preset.requires ? `${description} · ${preset.requires}` : description || preset.requires; - const statusLabel = toggleable - ? tx("settings.nanobotFeatures.enabled", "Enabled") - : mcpPresetStatusLabel(preset.status, tx); const manualCallback = oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url); const callbackInputId = `mcp-oauth-callback-${preset.name}`; @@ -583,33 +597,121 @@ function McpAppsCatalogRow({ return (
-
+
-
+

{preset.display_name}

- - {agentPlugin - ? tx("settings.apps.filterPlugins", "Plugins") - : tx("settings.apps.mcpLabel", "MCP")} - + {showTypeBadge ? ( + + {agentPlugin + ? tx("settings.apps.filterPlugins", "Plugins") + : tx("settings.apps.mcpLabel", "MCP")} + + ) : null}
-

{detail}

+

+ {runtimeFailed && configuredInstalled ? ( + + ) : null} + + {runtimeFailed && configuredInstalled ? failureLabel : detail} + +

-
- {readyInstalled ? ( +
+ {oauthFlow ? ( + <> + + + + ) : runtimeConnecting && configuredInstalled ? ( + <> + + onAction("remove", preset.name)} + > + + + + ) : runtimeFailed && configuredInstalled ? ( + <> + { + if (isOAuth) onOAuthConnect(preset.name, true); + else onAction("reconnect", preset.name); + }} + > + + + + + + + + + + onAction("test", preset.name)}> + + {tx("settings.mcp.test", "Test")} + + {toolNames.length ? ( + setToolsOpen((open) => !open)}> + + {tx("settings.mcp.toolScope", "Tools")} + + ) : null} + onAction("remove", preset.name)} + > + + {tx("settings.mcp.remove", "Remove")} + + + + + ) : readyInstalled ? ( <> @@ -618,9 +720,13 @@ function McpAppsCatalogRow({ visibleLabel={statusLabel} busy={testBusy || toolsBusy || disableBusy} disabled={busy} - tone="installed" + tone={toggleable || runtimeConnected ? "installed" : "default"} > - + {toggleable || runtimeConnected ? ( + + ) : ( + + )} @@ -667,23 +773,6 @@ function McpAppsCatalogRow({ busy={enableBusy} onClick={() => onAction("enable", preset.name, values)} /> - ) : oauthFlow ? ( - <> - - - ) : isOAuth && preset.install_supported ? ( ) : null} - {toolsOpen && readyInstalled && toolNames.length ? ( + {toolsOpen && configuredInstalled && toolNames.length ? (
@@ -966,47 +1055,56 @@ function AppsTypeBadge({ children }: { children: ReactNode }) { ); } -export const AppsActionButton = forwardRef & { +type AppsActionButtonProps = Omit< + ComponentPropsWithoutRef, + "aria-label" | "children" | "disabled" | "size" | "variant" +> & { ariaLabel: string; visibleLabel?: string; busy?: boolean; + disabled?: boolean; tone?: "default" | "installed" | "danger"; -}>(function AppsActionButton({ - ariaLabel, - visibleLabel, - busy, - disabled, - tone = "default", - className, - children, - ...props -}, ref) { - return ( - - ); -}); + children?: ReactNode; +}; + +export const AppsActionButton = forwardRef( + function AppsActionButton({ + ariaLabel, + visibleLabel, + busy, + disabled, + tone = "default", + children, + className, + ...buttonProps + }, ref) { + return ( + + ); + }, +); function appsTitle(item: AppsCatalogItem): string { return item.kind === "cli" ? item.app.display_name : item.preset.display_name; @@ -1014,7 +1112,10 @@ function appsTitle(item: AppsCatalogItem): string { function appsReady(item: AppsCatalogItem): boolean { if (item.kind === "cli") return item.app.installed; - return item.preset.enabled ?? (item.preset.installed && item.preset.configured); + if (item.preset.enabled !== undefined) return item.preset.enabled; + return item.preset.installed && + item.preset.configured && + item.preset.runtime_status === "connected"; } function appsSearchText(item: AppsCatalogItem): string { diff --git a/webui/src/components/settings/system/createSystemSettingsActions.ts b/webui/src/components/settings/system/createSystemSettingsActions.ts index 2f991049f..88d58cc46 100644 --- a/webui/src/components/settings/system/createSystemSettingsActions.ts +++ b/webui/src/components/settings/system/createSystemSettingsActions.ts @@ -423,7 +423,7 @@ export function createSystemSettingsActions({ } }; - const handleMcpOAuthConnect = async (name: string) => { + const handleMcpOAuthConnect = async (name: string, reset = false) => { openMcpOAuthPopup(); const key = `oauth:${name}`; setMcpPresetAction(key); @@ -433,7 +433,7 @@ export function createSystemSettingsActions({ setMcpOAuthCompleting(false); setMcpOAuthCallbackError(null); try { - const flow = await startMcpOAuth(client, name); + const flow = await startMcpOAuth(client, name, reset); mcpOAuthFlowRef.current = flow; setMcpOAuthFlow(flow); navigateMcpOAuthPopup(flow); @@ -521,7 +521,7 @@ export function createSystemSettingsActions({ }; const handleMcpPresetAction = async ( - action: "enable" | "disable" | "remove" | "test", + action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values: Record = {}, ) => { diff --git a/webui/src/components/settings/system/useSystemSettingsEffects.ts b/webui/src/components/settings/system/useSystemSettingsEffects.ts index f736b8e68..e929df31b 100644 --- a/webui/src/components/settings/system/useSystemSettingsEffects.ts +++ b/webui/src/components/settings/system/useSystemSettingsEffects.ts @@ -21,6 +21,8 @@ interface SystemSettingsEffectsOptions { pageVisible: boolean; } +const MCP_RUNTIME_STATUS_REFRESH_MS = 1_000; + export function useSystemSettingsEffects({ state, activeSection, @@ -149,26 +151,36 @@ export function useSystemSettingsEffects({ }, [activeSection, getToken]); useEffect(() => { - if (activeSection !== "apps") return; + if (activeSection !== "apps" || !pageVisible) return; let cancelled = false; - setMcpPresetsLoading(true); - fetchMcpPresets(getToken()) - .then((payload) => { - if (!cancelled) { + let retry: number | null = null; + const loadMcpPresets = (showLoading: boolean) => { + if (showLoading) setMcpPresetsLoading(true); + fetchMcpPresets(getToken()) + .then((payload) => { + if (cancelled) return; setMcpPresets(payload); setMcpError(null); - } - }) - .catch((err) => { - if (!cancelled) setMcpError((err as Error).message); - }) - .finally(() => { - if (!cancelled) setMcpPresetsLoading(false); - }); + if (payload.presets.some((preset) => preset.runtime_status === "connecting")) { + retry = window.setTimeout(() => { + retry = null; + loadMcpPresets(false); + }, MCP_RUNTIME_STATUS_REFRESH_MS); + } + }) + .catch((err) => { + if (!cancelled) setMcpError((err as Error).message); + }) + .finally(() => { + if (!cancelled && showLoading) setMcpPresetsLoading(false); + }); + }; + loadMcpPresets(true); return () => { cancelled = true; + if (retry !== null) window.clearTimeout(retry); }; - }, [activeSection, getToken]); + }, [activeSection, getToken, pageVisible]); const refreshAutomations = useCallback( async (showLoading = false) => { diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 6339ff56b..a66379d35 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -354,7 +354,10 @@ "enabled": "Enabled", "setup": "Connect", "configure": "Connect", + "reconnect": "Reconnect", "connectTitle": "Connect {{name}}", + "reconnectTitle": "Reconnect {{name}}", + "actionsTitle": "Actions for {{name}}", "connectHint": "Add the key from your account settings.", "saveAndEnable": "Save and enable", "updateSetup": "Update setup", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 09484c1bd..b5ccab76c 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -539,7 +539,10 @@ "enabled": "Habilitado", "setup": "Conectar", "configure": "Conectar", + "reconnect": "Reconectar", "connectTitle": "Conectar {{name}}", + "reconnectTitle": "Reconectar {{name}}", + "actionsTitle": "Acciones de {{name}}", "connectHint": "Añade la clave desde la configuración de tu cuenta.", "saveAndEnable": "Guardar y habilitar", "updateSetup": "Actualizar configuración", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 4eff41b0f..a305e8de1 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -539,7 +539,10 @@ "enabled": "Activé", "setup": "Connecter", "configure": "Connecter", + "reconnect": "Reconnecter", "connectTitle": "Connecter {{name}}", + "reconnectTitle": "Reconnecter {{name}}", + "actionsTitle": "Actions pour {{name}}", "connectHint": "Ajoutez la clé depuis les paramètres de votre compte.", "saveAndEnable": "Enregistrer et activer", "updateSetup": "Mettre à jour la configuration", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 56231fe22..0f1eecb22 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -539,7 +539,10 @@ "enabled": "Aktif", "setup": "Hubungkan", "configure": "Hubungkan", + "reconnect": "Hubungkan kembali", "connectTitle": "Hubungkan {{name}}", + "reconnectTitle": "Hubungkan kembali {{name}}", + "actionsTitle": "Tindakan untuk {{name}}", "connectHint": "Tambahkan kunci dari pengaturan akun Anda.", "saveAndEnable": "Simpan dan aktifkan", "updateSetup": "Perbarui konfigurasi", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index f9e60db06..67022ae30 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -539,7 +539,10 @@ "enabled": "有効", "setup": "接続", "configure": "接続", + "reconnect": "再接続", "connectTitle": "{{name}} に接続", + "reconnectTitle": "{{name}} に再接続", + "actionsTitle": "{{name}} の操作", "connectHint": "アカウント設定からキーを追加します。", "saveAndEnable": "保存して有効化", "updateSetup": "設定を更新", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 511d6f142..11ee620f6 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -539,7 +539,10 @@ "enabled": "활성화됨", "setup": "연결", "configure": "연결", + "reconnect": "다시 연결", "connectTitle": "{{name}} 연결", + "reconnectTitle": "{{name}} 다시 연결", + "actionsTitle": "{{name}} 작업", "connectHint": "계정 설정에서 키를 추가하세요.", "saveAndEnable": "저장 후 활성화", "updateSetup": "설정 업데이트", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 7d9d4e191..e34e61551 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -354,7 +354,10 @@ "enabled": "Habilitado", "setup": "Conectar", "configure": "Conectar", + "reconnect": "Reconectar", "connectTitle": "Conectar {{name}}", + "reconnectTitle": "Reconectar {{name}}", + "actionsTitle": "Ações para {{name}}", "connectHint": "Adicione a chave a partir das configurações da sua conta.", "saveAndEnable": "Salvar e habilitar", "updateSetup": "Atualizar configuração", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 6e3a99a4f..875280a25 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -539,7 +539,10 @@ "enabled": "Đã bật", "setup": "Kết nối", "configure": "Kết nối", + "reconnect": "Kết nối lại", "connectTitle": "Kết nối {{name}}", + "reconnectTitle": "Kết nối lại {{name}}", + "actionsTitle": "Thao tác cho {{name}}", "connectHint": "Thêm khóa từ phần cài đặt tài khoản của bạn.", "saveAndEnable": "Lưu và bật", "updateSetup": "Cập nhật thiết lập", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index e21a8d92e..95ec90dd5 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -354,7 +354,10 @@ "enabled": "已启用", "setup": "连接", "configure": "连接", + "reconnect": "重新连接", "connectTitle": "连接 {{name}}", + "reconnectTitle": "重新连接 {{name}}", + "actionsTitle": "{{name}} 操作", "connectHint": "填入账户中的密钥。", "saveAndEnable": "保存并启用", "updateSetup": "更新配置", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 1983bc23e..69ae9fd42 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -539,7 +539,10 @@ "enabled": "已啟用", "setup": "連線", "configure": "連線", + "reconnect": "重新連線", "connectTitle": "連線 {{name}}", + "reconnectTitle": "重新連線 {{name}}", + "actionsTitle": "{{name}} 操作", "connectHint": "請從帳號設定新增金鑰。", "saveAndEnable": "儲存並啟用", "updateSetup": "更新設定", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 976b109fb..3147a7bb9 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -766,7 +766,7 @@ export async function fetchProviderModels( export async function runMcpPresetAction( transport: WebUIMutationTransport, - action: "enable" | "disable" | "remove" | "test", + action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values: Record = {}, ): Promise { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 2f58de2a2..b9f6910e3 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -965,6 +965,7 @@ export interface McpPresetInfo { enabled?: boolean; available: boolean; status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string; + runtime_status?: "connecting" | "connected" | "failed" | string; logo_url?: string | null; brand_color?: string | null; required_fields: McpPresetField[]; diff --git a/webui/src/tests/settings-apps-oauth.test.tsx b/webui/src/tests/settings-apps-oauth.test.tsx index bd978c050..54e2baa72 100644 --- a/webui/src/tests/settings-apps-oauth.test.tsx +++ b/webui/src/tests/settings-apps-oauth.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; import { installSettingsViewTestHooks, jsonResponse, @@ -140,6 +141,180 @@ describe("SettingsView Apps catalog", () => { ); }); + it("shows a real OAuth runtime failure and restarts authorization without a success check", async () => { + const failedPreset = { + ...xmindMcpPreset, + installed: true, + configured: true, + available: true, + status: "configured", + runtime_status: "failed", + connection_summary: "https://app.xmind.com/api/mcp", + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [failedPreset], installed_count: 1 }); + } + return { ok: false, status: 404, text: async () => "Not found" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("open", vi.fn(() => null)); + requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion")); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "Ready" })); + expect(await screen.findByText("No tools are ready yet.")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Xmind" })).not.toBeInTheDocument(); + + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + + const heading = await screen.findByRole("heading", { name: "Xmind" }); + const row = heading.closest("article"); + expect(row).not.toBeNull(); + expect(row?.parentElement).toHaveClass("xl:grid-cols-2"); + expect(within(row as HTMLElement).queryByText("MCP")).not.toBeInTheDocument(); + const failed = within(row as HTMLElement).getByText("Connection failed."); + expect(failed.closest("button")).toBeNull(); + expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull(); + expect(row?.querySelector(".lucide-check")).toBeNull(); + expect(within(row as HTMLElement).getByRole("button", { name: "Reconnect Xmind" })).toHaveTextContent( + "Reconnect", + ); + const actions = within(row as HTMLElement).getByRole("button", { name: "Actions for Xmind" }); + expect(actions).toHaveAttribute("aria-haspopup", "menu"); + fireEvent.pointerDown(actions, { button: 0, ctrlKey: false }); + expect(await screen.findByRole("menuitem", { name: "Test" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + + await act(() => i18n.changeLanguage("zh-CN")); + expect(within(row as HTMLElement).getByText("连接失败。")).toBeInTheDocument(); + expect(within(row as HTMLElement).getByRole("button", { name: "Xmind 操作" })) + .toBeInTheDocument(); + const reconnect = screen.getByRole("button", { name: "重新连接 Xmind" }); + expect(reconnect).toHaveTextContent("重新连接"); + fireEvent.click(reconnect); + + await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( + "settings.mcp.oauth_start", + { name: "xmind", reset: true }, + 30_000, + )); + await act(() => i18n.changeLanguage("en")); + }); + + it("refreshes a connecting MCP snapshot until the runtime attempt settles", async () => { + const connectingPreset = { + ...xmindMcpPreset, + installed: true, + configured: true, + available: true, + status: "configured", + runtime_status: "connecting", + connection_summary: "https://app.xmind.com/api/mcp", + }; + let mcpPresetRequests = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + mcpPresetRequests += 1; + return jsonResponse({ + presets: [{ + ...connectingPreset, + runtime_status: mcpPresetRequests === 1 ? "connecting" : "connected", + }], + installed_count: 1, + }); + } + return { ok: false, status: 404, text: async () => "Not found" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + + expect(await screen.findByRole("button", { name: "Xmind: Connecting…" })) + .toHaveTextContent("Connecting…"); + expect(await screen.findByRole( + "button", + { name: "Xmind: Connected." }, + { timeout: 2_500 }, + )).toHaveTextContent("Connected."); + expect(mcpPresetRequests).toBe(2); + + fireEvent.click(screen.getByRole("button", { name: "Ready" })); + expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument(); + }); + + it("retries a failed custom MCP and only shows a success check after it connects", async () => { + const failedCustom = { + ...xmindMcpPreset, + name: "team-docs", + display_name: "team-docs", + auth: null, + source: "custom", + installed: true, + configured: true, + available: true, + status: "configured", + runtime_status: "failed", + connection_summary: "https://mcp.example.com/mcp", + }; + const connectedCustom = { ...failedCustom, runtime_status: "connected" }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [failedCustom], installed_count: 1 }); + } + return { ok: false, status: 404, text: async () => "Not found" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + presets: [connectedCustom], + installed_count: 1, + requires_restart: false, + hot_reload: { + ok: true, + message: "MCP connections refreshed without restarting nanobot.", + connected: ["team-docs"], + failed: [], + }, + last_action: { ok: true, message: "Retried connection for MCP server team-docs." }, + }); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + expect(await screen.findByText("Connection failed.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" })); + + await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( + "settings.mcp.reconnect", + { name: "team-docs" }, + 20_000, + )); + const connected = await screen.findByRole("button", { name: "team-docs: Connected." }); + expect(connected).toHaveTextContent("Connected."); + expect(connected.querySelector(".lucide-check")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Ready" })); + const readyHeading = await screen.findByRole("heading", { name: "team-docs" }); + expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP")) + .toBeInTheDocument(); + }); + it("configures OAuth for a custom remote MCP without importing JSON", async () => { const customPreset = { ...xmindMcpPreset,