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

This commit is contained in:
chengyongru
2026-08-11 23:52:02 +08:00
committed by GitHub
parent 1edfd268db
commit d45c893f68
31 changed files with 838 additions and 123 deletions
+4
View File
@@ -46,6 +46,10 @@ async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools)
def mcp_runtime_status(state: Any) -> dict[str, mcp_tools.MCPRuntimeStatus]:
return mcp_tools.runtime_status(state)
async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)
+6 -1
View File
@@ -95,7 +95,7 @@ from nanobot.utils.runtime import (
)
if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection
from nanobot.agent.tools.mcp import MCPConnection, MCPRuntimeStatus
from nanobot.config.schema import (
ChannelsConfig,
Config,
@@ -401,6 +401,7 @@ class AgentLoop:
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_runtime_statuses: dict[str, MCPRuntimeStatus] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
@@ -646,6 +647,10 @@ class AgentLoop:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
def mcp_runtime_status(self) -> dict[str, MCPRuntimeStatus]:
"""Return connection state learned from real MCP runtime attempts."""
return agent_context.mcp_runtime_status(self)
def register_runtime_context_provider(
self,
provider: RuntimeContextProvider,
+96 -2
View File
@@ -9,7 +9,7 @@ import shutil
import urllib.parse
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from typing import TYPE_CHECKING, Any, Literal, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx
@@ -62,6 +62,10 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar
_SANITIZE_RE = re.compile(r"_+")
_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary()
_ReconnectCallback = Callable[[str, str, Tool], Awaitable[Tool | None]]
MCPRuntimeStatus = Literal["connecting", "connected", "failed"]
_MCP_RUNTIME_STATUSES: frozenset[MCPRuntimeStatus] = frozenset(
("connecting", "connected", "failed")
)
class MCPConnection(Protocol):
@@ -1297,17 +1301,92 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {}
def _runtime_status_store(
state: Any,
*,
create: bool = False,
) -> dict[str, MCPRuntimeStatus] | None:
raw_statuses: object = getattr(state, "_mcp_runtime_statuses", None)
if isinstance(raw_statuses, dict):
return cast(dict[str, MCPRuntimeStatus], raw_statuses)
if not create:
return None
statuses: dict[str, MCPRuntimeStatus] = {}
state._mcp_runtime_statuses = statuses
return statuses
def runtime_status(state: Any) -> dict[str, MCPRuntimeStatus]:
"""Return the latest connection-attempt result for configured MCP servers."""
statuses = _runtime_status_store(state)
raw_configured: object = getattr(state, "_mcp_servers", None)
if statuses is None or not isinstance(raw_configured, dict):
return {}
configured = cast(dict[str, Any], raw_configured)
return {
name: status
for name, status in statuses.items()
if name in configured and status in _MCP_RUNTIME_STATUSES
}
def _set_runtime_status(
state: Any,
server_names: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
status: MCPRuntimeStatus,
) -> None:
statuses = _runtime_status_store(state, create=True)
assert statuses is not None
for name in server_names:
statuses[name] = status
def _record_connection_result(
state: Any,
attempted: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
connected: Mapping[str, Any] | set[str] | list[str] | tuple[str, ...],
) -> None:
attempted_names = set(attempted)
connected_names = set(connected)
_set_runtime_status(state, connected_names, "connected")
_set_runtime_status(state, attempted_names - connected_names, "failed")
async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
"""Connect configured MCP servers that are not currently live."""
async with _reload_lock(state):
if getattr(state, "_mcp_closing", False):
return
missing_servers = {
configured_missing = {
name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks
}
oauth_servers = {
name: cfg
for name, cfg in configured_missing.items()
if getattr(cfg, "auth", None) == "oauth"
}
authorization_pending: set[str] = set()
if oauth_servers:
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
authorization_pending = {
name
for name, cfg in oauth_servers.items()
if not mcp_oauth_has_credentials(name, cfg.url)
}
statuses = _runtime_status_store(state)
if statuses is not None:
for name in authorization_pending:
statuses.pop(name, None)
missing_servers = {
name: cfg
for name, cfg in configured_missing.items()
if name not in authorization_pending
}
if state._mcp_connecting or not missing_servers:
return
state._mcp_connecting = True
_set_runtime_status(state, missing_servers, "connecting")
try:
connected = await connect_mcp_servers(missing_servers, registry)
if getattr(state, "_mcp_closing", False):
@@ -1315,6 +1394,7 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
await connection.aclose()
return
state._mcp_stacks.update(connected)
_record_connection_result(state, missing_servers, connected)
_attach_reconnect_handlers(state, registry, connected)
if connected:
logger.info("MCP connected servers: {}", sorted(connected))
@@ -1323,8 +1403,10 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
except asyncio.CancelledError:
if task_is_cancelling():
raise
_set_runtime_status(state, missing_servers, "failed")
logger.warning("MCP connection cancelled (will retry next message)")
except BaseException as e:
_set_runtime_status(state, missing_servers, "failed")
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
finally:
state._mcp_connecting = False
@@ -1380,6 +1462,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, name)
runtime_statuses = _runtime_status_store(state)
if runtime_statuses is not None:
for name in [*removed, *authorization_pending]:
runtime_statuses.pop(name, None)
state._mcp_servers = next_servers
retry_missing = sorted(
name
@@ -1394,6 +1481,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
_set_runtime_status(state, to_connect, "connecting")
connected = await connect_mcp_servers(to_connect, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
@@ -1404,6 +1492,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
state._mcp_stacks.update(connected)
_record_connection_result(state, to_connect, connected)
_attach_reconnect_handlers(state, registry, connected)
failed = sorted(set(to_connect) - set(connected))
@@ -1562,12 +1651,14 @@ async def _refresh_terminated_server(
_unregister_server_tools(registry, server_name)
await _close_server(state, server_name)
_set_runtime_status(state, {server_name}, "connecting")
connected = await connect_mcp_servers({server_name: cfg}, registry)
if getattr(state, "_mcp_closing", False):
for connection in connected.values():
await connection.aclose()
return None
state._mcp_stacks.update(connected)
_record_connection_result(state, {server_name}, connected)
_attach_reconnect_handlers(state, registry, connected)
if server_name not in connected:
logger.warning("MCP server '{}' reconnect failed after session termination", server_name)
@@ -1621,6 +1712,9 @@ async def close_mcp_servers(state: Any) -> None:
async with _reload_lock(state):
connections = list(state._mcp_stacks.items())
state._mcp_stacks.clear()
statuses = _runtime_status_store(state)
if statuses is not None:
statuses.clear()
for name, connection in connections:
try:
await connection.aclose()
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -100,6 +100,7 @@ class ChannelManager:
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None,
):
@@ -119,6 +120,7 @@ class ChannelManager:
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {}
@@ -187,6 +189,7 @@ class ChannelManager:
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status,
mcp_runtime_status=self._webui_mcp_runtime_status,
skill_state_action=self._webui_skill_state_action,
logger=logger,
)
+1
View File
@@ -668,6 +668,7 @@ def _run_gateway(
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_mcp_runtime_status=agent.mcp_runtime_status,
webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path),
)
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
@@ -64,6 +65,7 @@ def build_gateway_services(
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
@@ -116,6 +118,7 @@ def build_gateway_services(
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
skill_state_action=skill_state_action,
log=logger,
)
+76 -5
View File
@@ -60,7 +60,10 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
_MCP_RUNTIME_STATUSES = {"connecting", "connected", "failed"}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
McpRuntimeStatus = Callable[[], Mapping[str, str]]
class McpPresetError(Exception):
@@ -943,6 +946,7 @@ def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
tool_preview: Mapping[str, list[str]] | None = None,
runtime_status: Mapping[str, str] | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config()
@@ -970,7 +974,38 @@ def mcp_presets_payload(
}
if last_action is not None:
payload["last_action"] = last_action
return payload
return attach_mcp_runtime_status(payload, runtime_status)
def attach_mcp_runtime_status(
payload: dict[str, Any],
runtime_status: Mapping[str, str] | None,
) -> dict[str, Any]:
"""Project safe, connection-attempt state onto configured MCP rows."""
if runtime_status is None:
return payload
projected = dict(payload)
raw_rows: object = payload.get("presets", [])
preset_rows = cast(list[object], raw_rows) if isinstance(raw_rows, list) else []
rows: list[Any] = []
for raw_row in preset_rows:
if not isinstance(raw_row, dict):
rows.append(raw_row)
continue
row = dict(cast(dict[str, Any], raw_row))
name = row.get("name")
status = runtime_status.get(name) if isinstance(name, str) else None
if (
status in _MCP_RUNTIME_STATUSES
and row.get("installed") is True
and row.get("configured") is True
):
row["runtime_status"] = status
else:
row.pop("runtime_status", None)
rows.append(row)
projected["presets"] = rows
return projected
def _display_name_for(name: str, preset: McpPreset | None = None) -> str:
@@ -1003,6 +1038,7 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
"import-cursor": "Imported",
"tools": "Updated tools for",
"remove": "Removed",
"reconnect": "Retried connection for",
}.get(action, "Updated")
payload: dict[str, Any] = {
"ok": ok,
@@ -1017,6 +1053,24 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
return payload
def mcp_reconnect_action(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Validate a configured server before asking the live runtime to retry it."""
name = _validated_server_name((_query_first(query, "name") or "").strip())
config = load_config(config_path) if config_path is not None else load_config()
if name not in config.tools.mcp_servers:
raise McpPresetError("unknown MCP server", status=404)
payload = mcp_presets_payload(
last_action=_server_action_message("reconnect", name),
config_path=config_path,
)
payload["requires_restart"] = True
return payload
def _scrub_test_error(text: str) -> str:
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", scrubbed)
@@ -1571,12 +1625,16 @@ async def mcp_presets_settings_action(
query: QueryParams,
*,
reload_mcp: McpReload | None = None,
mcp_runtime_status: McpRuntimeStatus | None = None,
config: WebUISettingsConfig | None = None,
) -> dict[str, Any]:
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
config_path = config.path if config is not None else None
if action is None:
return mcp_presets_payload(config_path=config_path)
return mcp_presets_payload(
runtime_status=mcp_runtime_status() if mcp_runtime_status is not None else None,
config_path=config_path,
)
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config()
@@ -1601,8 +1659,18 @@ async def mcp_presets_settings_action(
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
if action == "test":
return await mcp_presets_test_action(query, config_path=config_path)
if config is not None:
payload = await mcp_presets_test_action(query, config_path=config_path)
return attach_mcp_runtime_status(
payload,
mcp_runtime_status() if mcp_runtime_status is not None else None,
)
if action == "reconnect":
payload = await asyncio.to_thread(
mcp_reconnect_action,
query,
config_path=config_path,
)
elif config is not None:
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
payload = await asyncio.to_thread(
config.run_serialized,
@@ -1614,4 +1682,7 @@ async def mcp_presets_settings_action(
payload = await asyncio.to_thread(mcp_presets_action, action, query)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
return attach_mcp_runtime_status(
payload,
mcp_runtime_status() if mcp_runtime_status is not None else None,
)
+5 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import html
import json
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any, cast
from websockets.http11 import Request as WsRequest
@@ -94,6 +94,7 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/reconnect": "reconnect",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
@@ -225,6 +226,7 @@ class WebUISettingsRouter:
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None:
self.settings = settings
@@ -238,6 +240,7 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status
self._mcp_runtime_status = mcp_runtime_status
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set()
@@ -470,6 +473,7 @@ class WebUISettingsRouter:
deny_code=deny_code,
mcp_presets_action=mcp_presets_settings_action,
reload_mcp=lambda: request_mcp_reload(self.bus),
mcp_runtime_status=self._mcp_runtime_status,
check_for_update=check_for_update,
channel_feature_action=self._channel_feature_action,
channel_runtime_status=self._channel_runtime_status,
+3 -1
View File
@@ -6,7 +6,7 @@ import asyncio
import inspect
import re
import time
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast
@@ -55,6 +55,7 @@ class SystemSettingsOperations:
deny_code: SettingsOperation
mcp_presets_action: SettingsOperation
reload_mcp: SettingsOperation
mcp_runtime_status: Callable[[], Mapping[str, str]] | None
check_for_update: SettingsOperation
channel_feature_action: SettingsOperation | None = None
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
@@ -928,6 +929,7 @@ class SystemSettingsHandler:
action,
request.query,
reload_mcp=operations.reload_mcp,
mcp_runtime_status=operations.mcp_runtime_status,
config=self.settings.config,
)
except Exception as exc:
+4 -1
View File
@@ -14,7 +14,7 @@ import json
import mimetypes
import re
import time
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote, urlsplit, urlunsplit
@@ -163,6 +163,7 @@ _WEBUI_MUTATION_PATHS = {
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.reconnect": "/api/settings/mcp-presets/reconnect",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
"settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
@@ -306,6 +307,7 @@ class GatewayHTTPHandler:
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
log: Any = logger,
) -> None:
@@ -348,6 +350,7 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_runtime_status=mcp_runtime_status,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
)
+26 -1
View File
@@ -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
+4
View File
@@ -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()
+66
View File
@@ -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",
+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["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,
+51 -2
View File
@@ -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(
@@ -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<string, string>) => void;
onMcpOAuthConnect: (name: string) => void;
onMcpAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => 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<string, string>) => void;
onOAuthConnect: (name: string) => void;
onAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => 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 (
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
<div
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",
)}
>
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
<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">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge>
{showTypeBadge ? (
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge>
) : null}
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
<p
className={cn(
"mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] leading-5 text-muted-foreground",
runtimeFailed && configuredInstalled && "font-medium text-destructive",
)}
>
{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={cn(
"flex shrink-0 items-center gap-1",
oauthFlow && "col-span-2 justify-self-end sm:col-span-1",
)}
>
{readyInstalled ? (
<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>
<DropdownMenuTrigger asChild>
@@ -618,9 +720,13 @@ function McpAppsCatalogRow({
visibleLabel={statusLabel}
busy={testBusy || toolsBusy || disableBusy}
disabled={busy}
tone="installed"
tone={toggleable || runtimeConnected ? "installed" : "default"}
>
<Check className="h-4 w-4" aria-hidden />
{toggleable || runtimeConnected ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Server className="h-4 w-4" aria-hidden />
)}
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -667,23 +773,6 @@ function McpAppsCatalogRow({
busy={enableBusy}
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 ? (
<AppsActionButton
ariaLabel={t("settings.mcp.connectTitle", {
@@ -902,7 +991,7 @@ function McpAppsCatalogRow({
</div>
) : null}
{toolsOpen && readyInstalled && toolNames.length ? (
{toolsOpen && configuredInstalled && toolNames.length ? (
<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="text-[11.5px] font-medium text-muted-foreground">
@@ -966,47 +1055,56 @@ 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;
visibleLabel?: string;
busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger";
}>(function AppsActionButton({
ariaLabel,
visibleLabel,
busy,
disabled,
tone = "default",
className,
children,
...props
}, ref) {
return (
<Button
{...props}
ref={ref}
type="button"
size={visibleLabel ? "sm" : "icon"}
variant="ghost"
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
className={cn(
"rounded-full text-muted-foreground transition-colors",
visibleLabel
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
: "h-9 w-9",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
{visibleLabel ? <span>{visibleLabel}</span> : null}
</Button>
);
});
children?: ReactNode;
};
export const AppsActionButton = forwardRef<HTMLButtonElement, AppsActionButtonProps>(
function AppsActionButton({
ariaLabel,
visibleLabel,
busy,
disabled,
tone = "default",
children,
className,
...buttonProps
}, ref) {
return (
<Button
{...buttonProps}
ref={ref}
type="button"
size={visibleLabel ? "sm" : "icon"}
variant="ghost"
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
className={cn(
"rounded-full text-muted-foreground transition-colors",
visibleLabel
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
: "h-9 w-9",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
{visibleLabel ? <span>{visibleLabel}</span> : null}
</Button>
);
},
);
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 {
@@ -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<string, string> = {},
) => {
@@ -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) => {
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "有効",
"setup": "接続",
"configure": "接続",
"reconnect": "再接続",
"connectTitle": "{{name}} に接続",
"reconnectTitle": "{{name}} に再接続",
"actionsTitle": "{{name}} の操作",
"connectHint": "アカウント設定からキーを追加します。",
"saveAndEnable": "保存して有効化",
"updateSetup": "設定を更新",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "활성화됨",
"setup": "연결",
"configure": "연결",
"reconnect": "다시 연결",
"connectTitle": "{{name}} 연결",
"reconnectTitle": "{{name}} 다시 연결",
"actionsTitle": "{{name}} 작업",
"connectHint": "계정 설정에서 키를 추가하세요.",
"saveAndEnable": "저장 후 활성화",
"updateSetup": "설정 업데이트",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -354,7 +354,10 @@
"enabled": "已启用",
"setup": "连接",
"configure": "连接",
"reconnect": "重新连接",
"connectTitle": "连接 {{name}}",
"reconnectTitle": "重新连接 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "填入账户中的密钥。",
"saveAndEnable": "保存并启用",
"updateSetup": "更新配置",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "已啟用",
"setup": "連線",
"configure": "連線",
"reconnect": "重新連線",
"connectTitle": "連線 {{name}}",
"reconnectTitle": "重新連線 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "請從帳號設定新增金鑰。",
"saveAndEnable": "儲存並啟用",
"updateSetup": "更新設定",
+1 -1
View File
@@ -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<string, string> = {},
): Promise<McpPresetsPayload> {
+1
View File
@@ -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[];
+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 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,