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) 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: async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state) await mcp_tools.close_mcp_servers(state)
+6 -1
View File
@@ -95,7 +95,7 @@ from nanobot.utils.runtime import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import ( from nanobot.config.schema import (
ChannelsConfig, ChannelsConfig,
Config, Config,
@@ -401,6 +401,7 @@ class AgentLoop:
self._running = False self._running = False
self._mcp_servers = mcp_servers or {} self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {} self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = [] self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {} self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
@@ -646,6 +647,10 @@ class AgentLoop:
"""Connect configured MCP servers.""" """Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools) 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( def register_runtime_context_provider(
self, self,
provider: RuntimeContextProvider, provider: RuntimeContextProvider,
+96 -2
View File
@@ -9,7 +9,7 @@ import shutil
import urllib.parse import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress 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 from weakref import WeakKeyDictionary
import httpx import httpx
@@ -62,6 +62,10 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
_SANITIZE_RE = re.compile(r"_+") _SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary() _RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]] _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): 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 {} 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: async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live.""" """Connect configured MCP servers that are not currently live."""
async with _reload_lock(state): async with _reload_lock(state):
if getattr(state, "_mcp_closing", False): if getattr(state, "_mcp_closing", False):
return return
missing_servers = { configured_missing = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks 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: if state._mcp_connecting or not missing_servers:
return return
state._mcp_connecting = True state._mcp_connecting = True
_set_runtime_status(state, missing_servers, "connecting")
try: try:
connected = await connect_mcp_servers(missing_servers, registry) connected = await connect_mcp_servers(missing_servers, registry)
if getattr(state, "_mcp_closing", False): if getattr(state, "_mcp_closing", False):
@@ -1315,6 +1394,7 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
await connection.aclose() await connection.aclose()
return return
state._mcp_stacks.update(connected) state._mcp_stacks.update(connected)
_record_connection_result(state, missing_servers, connected)
_attach_reconnect_handlers(state, registry, connected) _attach_reconnect_handlers(state, registry, connected)
if connected: if connected:
logger.info("MCP connected servers: {}", sorted(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: except asyncio.CancelledError:
if task_is_cancelling(): if task_is_cancelling():
raise raise
_set_runtime_status(state, missing_servers, "failed")
logger.warning("MCP connection cancelled (will retry next message)") logger.warning("MCP connection cancelled (will retry next message)")
except BaseException as e: except BaseException as e:
_set_runtime_status(state, missing_servers, "failed")
logger.warning("Failed to connect MCP servers (will retry next message): {}", e) logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
finally: finally:
state._mcp_connecting = False 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) tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, 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 state._mcp_servers = next_servers
retry_missing = sorted( retry_missing = sorted(
name 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} to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {} connected: dict[str, MCPConnection] = {}
if to_connect: if to_connect:
_set_runtime_status(state, to_connect, "connecting")
connected = await connect_mcp_servers(to_connect, registry) connected = await connect_mcp_servers(to_connect, registry)
if getattr(state, "_mcp_closing", False): if getattr(state, "_mcp_closing", False):
for connection in connected.values(): for connection in connected.values():
@@ -1404,6 +1492,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True, "requires_restart": True,
} }
state._mcp_stacks.update(connected) state._mcp_stacks.update(connected)
_record_connection_result(state, to_connect, connected)
_attach_reconnect_handlers(state, registry, connected) _attach_reconnect_handlers(state, registry, connected)
failed = sorted(set(to_connect) - set(connected)) failed = sorted(set(to_connect) - set(connected))
@@ -1562,12 +1651,14 @@ async def _refresh_terminated_server(
_unregister_server_tools(registry, server_name) _unregister_server_tools(registry, server_name)
await _close_server(state, server_name) await _close_server(state, server_name)
_set_runtime_status(state, {server_name}, "connecting")
connected = await connect_mcp_servers({server_name: cfg}, registry) connected = await connect_mcp_servers({server_name: cfg}, registry)
if getattr(state, "_mcp_closing", False): if getattr(state, "_mcp_closing", False):
for connection in connected.values(): for connection in connected.values():
await connection.aclose() await connection.aclose()
return None return None
state._mcp_stacks.update(connected) state._mcp_stacks.update(connected)
_record_connection_result(state, {server_name}, connected)
_attach_reconnect_handlers(state, registry, connected) _attach_reconnect_handlers(state, registry, connected)
if server_name not in connected: if server_name not in connected:
logger.warning("MCP server '{}' reconnect failed after session termination", server_name) 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): async with _reload_lock(state):
connections = list(state._mcp_stacks.items()) connections = list(state._mcp_stacks.items())
state._mcp_stacks.clear() state._mcp_stacks.clear()
statuses = _runtime_status_store(state)
if statuses is not None:
statuses.clear()
for name, connection in connections: for name, connection in connections:
try: try:
await connection.aclose() await connection.aclose()
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import inspect import inspect
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable, Mapping
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -100,6 +100,7 @@ class ChannelManager:
webui_static_dist: bool = True, webui_static_dist: bool = True,
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None, 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, webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None, config_path: Path | None = None,
): ):
@@ -119,6 +120,7 @@ class ChannelManager:
self._webui_static_dist = webui_static_dist self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) 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._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {} self._channel_owners: dict[str, str] = {}
@@ -187,6 +189,7 @@ class ChannelManager:
local_trigger_pending_ids=self._webui_local_trigger_pending_ids, local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action, channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status, channel_runtime_status=self.get_status,
mcp_runtime_status=self._webui_mcp_runtime_status,
skill_state_action=self._webui_skill_state_action, skill_state_action=self._webui_skill_state_action,
logger=logger, logger=logger,
) )
+1
View File
@@ -668,6 +668,7 @@ def _run_gateway(
webui_static_dist=webui_static_dist, webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface, webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities, webui_runtime_capabilities=webui_runtime_capabilities,
webui_mcp_runtime_status=agent.mcp_runtime_status,
webui_skill_state_action=_webui_skill_state_action, webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path), config_path=Path(config_path),
) )
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable 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, local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, 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, skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
@@ -116,6 +118,7 @@ def build_gateway_services(
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
skill_state_action=skill_state_action, skill_state_action=skill_state_action,
log=logger, log=logger,
) )
+75 -4
View File
@@ -60,7 +60,10 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20 _DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30 _DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"} _CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
McpReload = Callable[[], Awaitable[dict[str, Any]]] McpReload = Callable[[], Awaitable[dict[str, Any]]]
McpRuntimeStatus = Callable[[], Mapping[str, str]]
class McpPresetError(Exception): class McpPresetError(Exception):
@@ -943,6 +946,7 @@ def mcp_presets_payload(
*, *,
last_action: dict[str, Any] | None = None, last_action: dict[str, Any] | None = None,
tool_preview: Mapping[str, list[str]] | None = None, tool_preview: Mapping[str, list[str]] | None = None,
runtime_status: Mapping[str, str] | None = None,
config_path: Path | None = None, config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config() 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: if last_action is not None:
payload["last_action"] = last_action payload["last_action"] = last_action
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 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: 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", "import-cursor": "Imported",
"tools": "Updated tools for", "tools": "Updated tools for",
"remove": "Removed", "remove": "Removed",
"reconnect": "Retried connection for",
}.get(action, "Updated") }.get(action, "Updated")
payload: dict[str, Any] = { payload: dict[str, Any] = {
"ok": ok, "ok": ok,
@@ -1017,6 +1053,24 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
return payload 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: def _scrub_test_error(text: str) -> str:
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip()) scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed) scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
@@ -1571,12 +1625,16 @@ async def mcp_presets_settings_action(
query: QueryParams, query: QueryParams,
*, *,
reload_mcp: McpReload | None = None, reload_mcp: McpReload | None = None,
mcp_runtime_status: McpRuntimeStatus | None = None,
config: WebUISettingsConfig | None = None, config: WebUISettingsConfig | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run a WebUI MCP preset action and hot-reload the agent when config changes.""" """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 config_path = config.path if config is not None else None
if action is 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() name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"): if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config() 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()) payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload return payload
if action == "test": if action == "test":
return await mcp_presets_test_action(query, config_path=config_path) payload = await mcp_presets_test_action(query, config_path=config_path)
if config is not None: 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 operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
payload = await asyncio.to_thread( payload = await asyncio.to_thread(
config.run_serialized, config.run_serialized,
@@ -1614,4 +1682,7 @@ async def mcp_presets_settings_action(
payload = await asyncio.to_thread(mcp_presets_action, action, query) payload = await asyncio.to_thread(mcp_presets_action, action, query)
if reload_mcp is not None: if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp()) 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 asyncio
import html import html
import json import json
from collections.abc import Callable from collections.abc import Callable, Mapping
from typing import Any, cast from typing import Any, cast
from websockets.http11 import Request as WsRequest 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/disable": "disable",
"/api/settings/mcp-presets/remove": "remove", "/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test", "/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/reconnect": "reconnect",
"/api/settings/mcp-presets/custom": "custom", "/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import", "/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor", "/api/settings/mcp-presets/import-cursor": "import-cursor",
@@ -225,6 +226,7 @@ class WebUISettingsRouter:
runtime_capabilities: dict[str, Any], runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, 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, mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None: ) -> None:
self.settings = settings self.settings = settings
@@ -238,6 +240,7 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status 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_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager() self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set() self._restart_sections: set[str] = set()
@@ -470,6 +473,7 @@ class WebUISettingsRouter:
deny_code=deny_code, deny_code=deny_code,
mcp_presets_action=mcp_presets_settings_action, mcp_presets_action=mcp_presets_settings_action,
reload_mcp=lambda: request_mcp_reload(self.bus), reload_mcp=lambda: request_mcp_reload(self.bus),
mcp_runtime_status=self._mcp_runtime_status,
check_for_update=check_for_update, check_for_update=check_for_update,
channel_feature_action=self._channel_feature_action, channel_feature_action=self._channel_feature_action,
channel_runtime_status=self._channel_runtime_status, channel_runtime_status=self._channel_runtime_status,
+3 -1
View File
@@ -6,7 +6,7 @@ import asyncio
import inspect import inspect
import re import re
import time import time
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast from typing import TYPE_CHECKING, Any, TypedDict, cast
@@ -55,6 +55,7 @@ class SystemSettingsOperations:
deny_code: SettingsOperation deny_code: SettingsOperation
mcp_presets_action: SettingsOperation mcp_presets_action: SettingsOperation
reload_mcp: SettingsOperation reload_mcp: SettingsOperation
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
check_for_update: SettingsOperation check_for_update: SettingsOperation
channel_feature_action: SettingsOperation | None = None channel_feature_action: SettingsOperation | None = None
channel_runtime_status: Callable[[], dict[str, Any]] | None = None channel_runtime_status: Callable[[], dict[str, Any]] | None = None
@@ -928,6 +929,7 @@ class SystemSettingsHandler:
action, action,
request.query, request.query,
reload_mcp=operations.reload_mcp, reload_mcp=operations.reload_mcp,
mcp_runtime_status=operations.mcp_runtime_status,
config=self.settings.config, config=self.settings.config,
) )
except Exception as exc: except Exception as exc:
+4 -1
View File
@@ -14,7 +14,7 @@ import json
import mimetypes import mimetypes
import re import re
import time import time
from collections.abc import Callable from collections.abc import Callable, Mapping
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote, urlsplit, urlunsplit 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.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove", "settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test", "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.custom": "/api/settings/mcp-presets/custom",
"settings.mcp.import": "/api/settings/mcp-presets/import", "settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor", "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, local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None, channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, 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, skill_state_action: Callable[[set[str]], None] | None = None,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
@@ -348,6 +350,7 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities, runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri, mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
) )
+26 -1
View File
@@ -6,7 +6,7 @@ import asyncio
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import anyio import anyio
import pytest import pytest
@@ -155,6 +155,31 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
assert attempts == 2 assert attempts == 2
assert loop._mcp_stacks == {} 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 @pytest.mark.asyncio
+4
View File
@@ -84,6 +84,10 @@ class _GatewayAgentContractStub:
tools = ToolRegistry() tools = ToolRegistry()
@staticmethod
def mcp_runtime_status() -> dict[str, str]:
return {}
@staticmethod @staticmethod
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]: def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
return set() return set()
+66
View File
@@ -181,6 +181,72 @@ async def test_connect_missing_servers_propagates_external_cancellation(monkeypa
assert state._mcp_connecting is False 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: def test_wrapper_preserves_non_nullable_unions() -> None:
tool_def = SimpleNamespace( tool_def = SimpleNamespace(
name="demo", name="demo",
+62
View File
@@ -172,10 +172,72 @@ async def test_oauth_preset_is_one_click_configured_after_token_storage(
assert row["configured"] is True assert row["configured"] is True
assert row["status"] == "configured" 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"]}) mcp_presets_action("remove", {"name": ["xmind"]})
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None 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( def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+51 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Callable, Mapping
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock from unittest.mock import ANY, AsyncMock, MagicMock
from urllib.parse import parse_qs, urlsplit 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.config.loader import get_config_path
from nanobot.webui.http_utils import http_json_response 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_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices 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( return WebUISettingsRouter(
settings=WebUISettingsServices.create(get_config_path()), settings=WebUISettingsServices.create(config_path or get_config_path()),
bus=SimpleNamespace(), bus=SimpleNamespace(),
logger=SimpleNamespace(exception=lambda *_args: None), logger=SimpleNamespace(exception=lambda *_args: None),
check_api_token=lambda _request: authorized, check_api_token=lambda _request: authorized,
@@ -28,6 +36,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
), ),
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities={}, runtime_capabilities={},
mcp_runtime_status=mcp_runtime_status,
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback", 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 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 @pytest.mark.asyncio
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
config = SimpleNamespace( config = SimpleNamespace(
@@ -17,6 +17,7 @@ import {
Database, Database,
ExternalLink, ExternalLink,
Loader2, Loader2,
MoreHorizontal,
PauseCircle, PauseCircle,
PlayCircle, PlayCircle,
Plus, Plus,
@@ -24,6 +25,7 @@ import {
Search, Search,
Server, Server,
SlidersHorizontal, SlidersHorizontal,
TriangleAlert,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -158,8 +160,8 @@ export function AppsCatalogSettings({
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void; onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void; onMcpAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
onMcpOAuthConnect: (name: string) => void; onMcpOAuthConnect: (name: string, reset?: boolean) => void;
onMcpOAuthCancel: () => void; onMcpOAuthCancel: () => void;
onMcpOAuthOpen: () => void; onMcpOAuthOpen: () => void;
onMcpOAuthCallbackUrlChange: (value: string) => void; onMcpOAuthCallbackUrlChange: (value: string) => void;
@@ -324,6 +326,7 @@ export function AppsCatalogSettings({
oauthCompleting={mcpOAuthCompleting} oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError} oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos} showBrandLogos={showBrandLogos}
showTypeBadge={filter !== "mcp"}
onFieldChange={onMcpFieldChange} onFieldChange={onMcpFieldChange}
onAction={onMcpAction} onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect} onOAuthConnect={onMcpOAuthConnect}
@@ -487,6 +490,7 @@ function McpAppsCatalogRow({
oauthCompleting, oauthCompleting,
oauthCallbackError, oauthCallbackError,
showBrandLogos, showBrandLogos,
showTypeBadge,
onFieldChange, onFieldChange,
onAction, onAction,
onOAuthConnect, onOAuthConnect,
@@ -505,9 +509,10 @@ function McpAppsCatalogRow({
oauthCompleting: boolean; oauthCompleting: boolean;
oauthCallbackError: string | null; oauthCallbackError: string | null;
showBrandLogos: boolean; showBrandLogos: boolean;
showTypeBadge: boolean;
onFieldChange: (presetName: string, fieldName: string, value: string) => void; onFieldChange: (presetName: string, fieldName: string, value: string) => void;
onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void; onAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
onOAuthConnect: (name: string) => void; onOAuthConnect: (name: string, reset?: boolean) => void;
onOAuthCancel: () => void; onOAuthCancel: () => void;
onOAuthOpen: () => void; onOAuthOpen: () => void;
onOAuthCallbackUrlChange: (value: string) => void; onOAuthCallbackUrlChange: (value: string) => void;
@@ -522,17 +527,29 @@ function McpAppsCatalogRow({
const disableBusy = actionKey === `disable:${preset.name}`; const disableBusy = actionKey === `disable:${preset.name}`;
const removeBusy = actionKey === `remove:${preset.name}`; const removeBusy = actionKey === `remove:${preset.name}`;
const testBusy = actionKey === `test:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`;
const reconnectBusy = actionKey === `reconnect:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`;
const oauthBusy = actionKey === `oauth:${preset.name}`; const oauthBusy = actionKey === `oauth:${preset.name}`;
const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; 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 agentPlugin = preset.source === "agent-plugin";
const toggleable = preset.enabled !== undefined; const toggleable = preset.enabled !== undefined;
const isOAuth = preset.auth === "oauth"; const isOAuth = preset.auth === "oauth";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0; const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.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 = const canEnable =
preset.install_supported && preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -544,9 +561,6 @@ function McpAppsCatalogRow({
const detail = agentPlugin && preset.requires const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}` ? `${description} · ${preset.requires}`
: description || preset.requires; : description || preset.requires;
const statusLabel = toggleable
? tx("settings.nanobotFeatures.enabled", "Enabled")
: mcpPresetStatusLabel(preset.status, tx);
const manualCallback = const manualCallback =
oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url); oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
const callbackInputId = `mcp-oauth-callback-${preset.name}`; const callbackInputId = `mcp-oauth-callback-${preset.name}`;
@@ -583,33 +597,121 @@ function McpAppsCatalogRow({
return ( return (
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45"> <article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
<div <div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
className={cn(
"group min-w-0 px-3 py-3",
oauthFlow
? "grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
: "flex items-center gap-3",
)}
>
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} /> <McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1"> <div className="min-w-[8rem] flex-[1_1_8rem]">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3> <h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
{showTypeBadge ? (
<AppsTypeBadge> <AppsTypeBadge>
{agentPlugin {agentPlugin
? tx("settings.apps.filterPlugins", "Plugins") ? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")} : tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge> </AppsTypeBadge>
) : null}
</div> </div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p> <p
</div>
<div
className={cn( className={cn(
"flex shrink-0 items-center gap-1", "mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] leading-5 text-muted-foreground",
oauthFlow && "col-span-2 justify-self-end sm:col-span-1", runtimeFailed && configuredInstalled && "font-medium text-destructive",
)} )}
> >
{readyInstalled ? ( {runtimeFailed && configuredInstalled ? (
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden />
) : null}
<span className="truncate">
{runtimeFailed && configuredInstalled ? failureLabel : detail}
</span>
</p>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{oauthFlow ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.connectingAccount", {
name: preset.display_name,
defaultValue: "Connecting {{name}}",
})}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.actions.cancel", "Cancel")}
visibleLabel={tx("settings.actions.cancel", "Cancel")}
tone="danger"
onClick={onOAuthCancel}
/>
</>
) : runtimeConnecting && configuredInstalled ? (
<>
<AppsActionButton
ariaLabel={`${preset.display_name}: ${tx("settings.mcp.connectingLabel", "Connecting…")}`}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
disabled={busy && !removeBusy}
tone="danger"
onClick={() => onAction("remove", preset.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
</>
) : runtimeFailed && configuredInstalled ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.reconnectTitle", {
name: preset.display_name,
defaultValue: "Reconnect {{name}}",
})}
visibleLabel={reconnectLabel}
busy={isOAuth ? oauthBusy : reconnectBusy}
disabled={anotherOAuthBusy || (busy && !oauthBusy && !reconnectBusy)}
onClick={() => {
if (isOAuth) onOAuthConnect(preset.name, true);
else onAction("reconnect", preset.name);
}}
>
<RotateCcw className="h-4 w-4" aria-hidden />
</AppsActionButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppsActionButton
ariaLabel={t("settings.mcp.actionsTitle", {
name: preset.display_name,
defaultValue: "Actions for {{name}}",
})}
busy={testBusy || toolsBusy || removeBusy}
disabled={busy}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
{toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
tone="destructive"
disabled={busy}
onClick={() => onAction("remove", preset.name)}
>
<Trash2 aria-hidden />
{tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : readyInstalled ? (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -618,9 +720,13 @@ function McpAppsCatalogRow({
visibleLabel={statusLabel} visibleLabel={statusLabel}
busy={testBusy || toolsBusy || disableBusy} busy={testBusy || toolsBusy || disableBusy}
disabled={busy} disabled={busy}
tone="installed" tone={toggleable || runtimeConnected ? "installed" : "default"}
> >
{toggleable || runtimeConnected ? (
<Check className="h-4 w-4" aria-hidden /> <Check className="h-4 w-4" aria-hidden />
) : (
<Server className="h-4 w-4" aria-hidden />
)}
</AppsActionButton> </AppsActionButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -667,23 +773,6 @@ function McpAppsCatalogRow({
busy={enableBusy} busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)} onClick={() => onAction("enable", preset.name, values)}
/> />
) : oauthFlow ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.connectingAccount", {
name: preset.display_name,
defaultValue: "Connecting {{name}}",
})}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.actions.cancel", "Cancel")}
visibleLabel={tx("settings.actions.cancel", "Cancel")}
tone="danger"
onClick={onOAuthCancel}
/>
</>
) : isOAuth && preset.install_supported ? ( ) : isOAuth && preset.install_supported ? (
<AppsActionButton <AppsActionButton
ariaLabel={t("settings.mcp.connectTitle", { ariaLabel={t("settings.mcp.connectTitle", {
@@ -902,7 +991,7 @@ function McpAppsCatalogRow({
</div> </div>
) : null} ) : null}
{toolsOpen && readyInstalled && toolNames.length ? ( {toolsOpen && configuredInstalled && toolNames.length ? (
<div className="mx-3 mb-3 rounded-[14px] bg-background/55 p-3"> <div className="mx-3 mb-3 rounded-[14px] bg-background/55 p-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11.5px] font-medium text-muted-foreground"> <div className="text-[11.5px] font-medium text-muted-foreground">
@@ -966,24 +1055,32 @@ function AppsTypeBadge({ children }: { children: ReactNode }) {
); );
} }
export const AppsActionButton = forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<typeof Button> & { type AppsActionButtonProps = Omit<
ComponentPropsWithoutRef<typeof Button>,
"aria-label" | "children" | "disabled" | "size" | "variant"
> & {
ariaLabel: string; ariaLabel: string;
visibleLabel?: string; visibleLabel?: string;
busy?: boolean; busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger"; tone?: "default" | "installed" | "danger";
}>(function AppsActionButton({ children?: ReactNode;
};
export const AppsActionButton = forwardRef<HTMLButtonElement, AppsActionButtonProps>(
function AppsActionButton({
ariaLabel, ariaLabel,
visibleLabel, visibleLabel,
busy, busy,
disabled, disabled,
tone = "default", tone = "default",
className,
children, children,
...props className,
...buttonProps
}, ref) { }, ref) {
return ( return (
<Button <Button
{...props} {...buttonProps}
ref={ref} ref={ref}
type="button" type="button"
size={visibleLabel ? "sm" : "icon"} size={visibleLabel ? "sm" : "icon"}
@@ -1006,7 +1103,8 @@ export const AppsActionButton = forwardRef<HTMLButtonElement, ComponentPropsWith
{visibleLabel ? <span>{visibleLabel}</span> : null} {visibleLabel ? <span>{visibleLabel}</span> : null}
</Button> </Button>
); );
}); },
);
function appsTitle(item: AppsCatalogItem): string { function appsTitle(item: AppsCatalogItem): string {
return item.kind === "cli" ? item.app.display_name : item.preset.display_name; 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 { function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "cli") return item.app.installed; 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 { function appsSearchText(item: AppsCatalogItem): string {
@@ -423,7 +423,7 @@ export function createSystemSettingsActions({
} }
}; };
const handleMcpOAuthConnect = async (name: string) => { const handleMcpOAuthConnect = async (name: string, reset = false) => {
openMcpOAuthPopup(); openMcpOAuthPopup();
const key = `oauth:${name}`; const key = `oauth:${name}`;
setMcpPresetAction(key); setMcpPresetAction(key);
@@ -433,7 +433,7 @@ export function createSystemSettingsActions({
setMcpOAuthCompleting(false); setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null); setMcpOAuthCallbackError(null);
try { try {
const flow = await startMcpOAuth(client, name); const flow = await startMcpOAuth(client, name, reset);
mcpOAuthFlowRef.current = flow; mcpOAuthFlowRef.current = flow;
setMcpOAuthFlow(flow); setMcpOAuthFlow(flow);
navigateMcpOAuthPopup(flow); navigateMcpOAuthPopup(flow);
@@ -521,7 +521,7 @@ export function createSystemSettingsActions({
}; };
const handleMcpPresetAction = async ( const handleMcpPresetAction = async (
action: "enable" | "disable" | "remove" | "test", action: "enable" | "disable" | "remove" | "test" | "reconnect",
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
) => { ) => {
@@ -21,6 +21,8 @@ interface SystemSettingsEffectsOptions {
pageVisible: boolean; pageVisible: boolean;
} }
const MCP_RUNTIME_STATUS_REFRESH_MS = 1_000;
export function useSystemSettingsEffects({ export function useSystemSettingsEffects({
state, state,
activeSection, activeSection,
@@ -149,26 +151,36 @@ export function useSystemSettingsEffects({
}, [activeSection, getToken]); }, [activeSection, getToken]);
useEffect(() => { useEffect(() => {
if (activeSection !== "apps") return; if (activeSection !== "apps" || !pageVisible) return;
let cancelled = false; let cancelled = false;
setMcpPresetsLoading(true); let retry: number | null = null;
const loadMcpPresets = (showLoading: boolean) => {
if (showLoading) setMcpPresetsLoading(true);
fetchMcpPresets(getToken()) fetchMcpPresets(getToken())
.then((payload) => { .then((payload) => {
if (!cancelled) { if (cancelled) return;
setMcpPresets(payload); setMcpPresets(payload);
setMcpError(null); setMcpError(null);
if (payload.presets.some((preset) => preset.runtime_status === "connecting")) {
retry = window.setTimeout(() => {
retry = null;
loadMcpPresets(false);
}, MCP_RUNTIME_STATUS_REFRESH_MS);
} }
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setMcpError((err as Error).message); if (!cancelled) setMcpError((err as Error).message);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setMcpPresetsLoading(false); if (!cancelled && showLoading) setMcpPresetsLoading(false);
}); });
};
loadMcpPresets(true);
return () => { return () => {
cancelled = true; cancelled = true;
if (retry !== null) window.clearTimeout(retry);
}; };
}, [activeSection, getToken]); }, [activeSection, getToken, pageVisible]);
const refreshAutomations = useCallback( const refreshAutomations = useCallback(
async (showLoading = false) => { async (showLoading = false) => {
+3
View File
@@ -354,7 +354,10 @@
"enabled": "Enabled", "enabled": "Enabled",
"setup": "Connect", "setup": "Connect",
"configure": "Connect", "configure": "Connect",
"reconnect": "Reconnect",
"connectTitle": "Connect {{name}}", "connectTitle": "Connect {{name}}",
"reconnectTitle": "Reconnect {{name}}",
"actionsTitle": "Actions for {{name}}",
"connectHint": "Add the key from your account settings.", "connectHint": "Add the key from your account settings.",
"saveAndEnable": "Save and enable", "saveAndEnable": "Save and enable",
"updateSetup": "Update setup", "updateSetup": "Update setup",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Habilitado", "enabled": "Habilitado",
"setup": "Conectar", "setup": "Conectar",
"configure": "Conectar", "configure": "Conectar",
"reconnect": "Reconectar",
"connectTitle": "Conectar {{name}}", "connectTitle": "Conectar {{name}}",
"reconnectTitle": "Reconectar {{name}}",
"actionsTitle": "Acciones de {{name}}",
"connectHint": "Añade la clave desde la configuración de tu cuenta.", "connectHint": "Añade la clave desde la configuración de tu cuenta.",
"saveAndEnable": "Guardar y habilitar", "saveAndEnable": "Guardar y habilitar",
"updateSetup": "Actualizar configuración", "updateSetup": "Actualizar configuración",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Activé", "enabled": "Activé",
"setup": "Connecter", "setup": "Connecter",
"configure": "Connecter", "configure": "Connecter",
"reconnect": "Reconnecter",
"connectTitle": "Connecter {{name}}", "connectTitle": "Connecter {{name}}",
"reconnectTitle": "Reconnecter {{name}}",
"actionsTitle": "Actions pour {{name}}",
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.", "connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
"saveAndEnable": "Enregistrer et activer", "saveAndEnable": "Enregistrer et activer",
"updateSetup": "Mettre à jour la configuration", "updateSetup": "Mettre à jour la configuration",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Aktif", "enabled": "Aktif",
"setup": "Hubungkan", "setup": "Hubungkan",
"configure": "Hubungkan", "configure": "Hubungkan",
"reconnect": "Hubungkan kembali",
"connectTitle": "Hubungkan {{name}}", "connectTitle": "Hubungkan {{name}}",
"reconnectTitle": "Hubungkan kembali {{name}}",
"actionsTitle": "Tindakan untuk {{name}}",
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.", "connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
"saveAndEnable": "Simpan dan aktifkan", "saveAndEnable": "Simpan dan aktifkan",
"updateSetup": "Perbarui konfigurasi", "updateSetup": "Perbarui konfigurasi",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "有効", "enabled": "有効",
"setup": "接続", "setup": "接続",
"configure": "接続", "configure": "接続",
"reconnect": "再接続",
"connectTitle": "{{name}} に接続", "connectTitle": "{{name}} に接続",
"reconnectTitle": "{{name}} に再接続",
"actionsTitle": "{{name}} の操作",
"connectHint": "アカウント設定からキーを追加します。", "connectHint": "アカウント設定からキーを追加します。",
"saveAndEnable": "保存して有効化", "saveAndEnable": "保存して有効化",
"updateSetup": "設定を更新", "updateSetup": "設定を更新",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "활성화됨", "enabled": "활성화됨",
"setup": "연결", "setup": "연결",
"configure": "연결", "configure": "연결",
"reconnect": "다시 연결",
"connectTitle": "{{name}} 연결", "connectTitle": "{{name}} 연결",
"reconnectTitle": "{{name}} 다시 연결",
"actionsTitle": "{{name}} 작업",
"connectHint": "계정 설정에서 키를 추가하세요.", "connectHint": "계정 설정에서 키를 추가하세요.",
"saveAndEnable": "저장 후 활성화", "saveAndEnable": "저장 후 활성화",
"updateSetup": "설정 업데이트", "updateSetup": "설정 업데이트",
+3
View File
@@ -354,7 +354,10 @@
"enabled": "Habilitado", "enabled": "Habilitado",
"setup": "Conectar", "setup": "Conectar",
"configure": "Conectar", "configure": "Conectar",
"reconnect": "Reconectar",
"connectTitle": "Conectar {{name}}", "connectTitle": "Conectar {{name}}",
"reconnectTitle": "Reconectar {{name}}",
"actionsTitle": "Ações para {{name}}",
"connectHint": "Adicione a chave a partir das configurações da sua conta.", "connectHint": "Adicione a chave a partir das configurações da sua conta.",
"saveAndEnable": "Salvar e habilitar", "saveAndEnable": "Salvar e habilitar",
"updateSetup": "Atualizar configuração", "updateSetup": "Atualizar configuração",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Đã bật", "enabled": "Đã bật",
"setup": "Kết nối", "setup": "Kết nối",
"configure": "Kết nối", "configure": "Kết nối",
"reconnect": "Kết nối lại",
"connectTitle": "Kết nối {{name}}", "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.", "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", "saveAndEnable": "Lưu và bật",
"updateSetup": "Cập nhật thiết lập", "updateSetup": "Cập nhật thiết lập",
+3
View File
@@ -354,7 +354,10 @@
"enabled": "已启用", "enabled": "已启用",
"setup": "连接", "setup": "连接",
"configure": "连接", "configure": "连接",
"reconnect": "重新连接",
"connectTitle": "连接 {{name}}", "connectTitle": "连接 {{name}}",
"reconnectTitle": "重新连接 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "填入账户中的密钥。", "connectHint": "填入账户中的密钥。",
"saveAndEnable": "保存并启用", "saveAndEnable": "保存并启用",
"updateSetup": "更新配置", "updateSetup": "更新配置",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "已啟用", "enabled": "已啟用",
"setup": "連線", "setup": "連線",
"configure": "連線", "configure": "連線",
"reconnect": "重新連線",
"connectTitle": "連線 {{name}}", "connectTitle": "連線 {{name}}",
"reconnectTitle": "重新連線 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "請從帳號設定新增金鑰。", "connectHint": "請從帳號設定新增金鑰。",
"saveAndEnable": "儲存並啟用", "saveAndEnable": "儲存並啟用",
"updateSetup": "更新設定", "updateSetup": "更新設定",
+1 -1
View File
@@ -766,7 +766,7 @@ export async function fetchProviderModels(
export async function runMcpPresetAction( export async function runMcpPresetAction(
transport: WebUIMutationTransport, transport: WebUIMutationTransport,
action: "enable" | "disable" | "remove" | "test", action: "enable" | "disable" | "remove" | "test" | "reconnect",
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
+1
View File
@@ -965,6 +965,7 @@ export interface McpPresetInfo {
enabled?: boolean; enabled?: boolean;
available: boolean; available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string; status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
runtime_status?: "connecting" | "connected" | "failed" | string;
logo_url?: string | null; logo_url?: string | null;
brand_color?: string | null; brand_color?: string | null;
required_fields: McpPresetField[]; required_fields: McpPresetField[];
+176 -1
View File
@@ -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 { describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { import {
installSettingsViewTestHooks, installSettingsViewTestHooks,
jsonResponse, 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 () => { it("configures OAuth for a custom remote MCP without importing JSON", async () => {
const customPreset = { const customPreset = {
...xmindMcpPreset, ...xmindMcpPreset,