mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 15:19:16 +03:00
fix(webui): surface MCP runtime connection failures (#5331)
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 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:
|
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,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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
<AppsTypeBadge>
|
{showTypeBadge ? (
|
||||||
{agentPlugin
|
<AppsTypeBadge>
|
||||||
? tx("settings.apps.filterPlugins", "Plugins")
|
{agentPlugin
|
||||||
: tx("settings.apps.mcpLabel", "MCP")}
|
? tx("settings.apps.filterPlugins", "Plugins")
|
||||||
</AppsTypeBadge>
|
: tx("settings.apps.mcpLabel", "MCP")}
|
||||||
|
</AppsTypeBadge>
|
||||||
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
<div
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
className={cn(
|
{oauthFlow ? (
|
||||||
"flex shrink-0 items-center gap-1",
|
<>
|
||||||
oauthFlow && "col-span-2 justify-self-end sm:col-span-1",
|
<AppsActionButton
|
||||||
)}
|
ariaLabel={t("settings.mcp.connectingAccount", {
|
||||||
>
|
name: preset.display_name,
|
||||||
{readyInstalled ? (
|
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"}
|
||||||
>
|
>
|
||||||
<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>
|
</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,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;
|
ariaLabel: string;
|
||||||
visibleLabel?: string;
|
visibleLabel?: string;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
tone?: "default" | "installed" | "danger";
|
tone?: "default" | "installed" | "danger";
|
||||||
}>(function AppsActionButton({
|
children?: ReactNode;
|
||||||
ariaLabel,
|
};
|
||||||
visibleLabel,
|
|
||||||
busy,
|
export const AppsActionButton = forwardRef<HTMLButtonElement, AppsActionButtonProps>(
|
||||||
disabled,
|
function AppsActionButton({
|
||||||
tone = "default",
|
ariaLabel,
|
||||||
className,
|
visibleLabel,
|
||||||
children,
|
busy,
|
||||||
...props
|
disabled,
|
||||||
}, ref) {
|
tone = "default",
|
||||||
return (
|
children,
|
||||||
<Button
|
className,
|
||||||
{...props}
|
...buttonProps
|
||||||
ref={ref}
|
}, ref) {
|
||||||
type="button"
|
return (
|
||||||
size={visibleLabel ? "sm" : "icon"}
|
<Button
|
||||||
variant="ghost"
|
{...buttonProps}
|
||||||
aria-label={ariaLabel}
|
ref={ref}
|
||||||
title={ariaLabel}
|
type="button"
|
||||||
disabled={disabled || busy}
|
size={visibleLabel ? "sm" : "icon"}
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"rounded-full text-muted-foreground transition-colors",
|
aria-label={ariaLabel}
|
||||||
visibleLabel
|
title={ariaLabel}
|
||||||
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
|
disabled={disabled || busy}
|
||||||
: "h-9 w-9",
|
className={cn(
|
||||||
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
|
"rounded-full text-muted-foreground transition-colors",
|
||||||
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
|
visibleLabel
|
||||||
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
|
||||||
className,
|
: "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",
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
|
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
||||||
{visibleLabel ? <span>{visibleLabel}</span> : null}
|
className,
|
||||||
</Button>
|
)}
|
||||||
);
|
>
|
||||||
});
|
{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 {
|
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;
|
||||||
fetchMcpPresets(getToken())
|
const loadMcpPresets = (showLoading: boolean) => {
|
||||||
.then((payload) => {
|
if (showLoading) setMcpPresetsLoading(true);
|
||||||
if (!cancelled) {
|
fetchMcpPresets(getToken())
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) return;
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
}
|
if (payload.presets.some((preset) => preset.runtime_status === "connecting")) {
|
||||||
})
|
retry = window.setTimeout(() => {
|
||||||
.catch((err) => {
|
retry = null;
|
||||||
if (!cancelled) setMcpError((err as Error).message);
|
loadMcpPresets(false);
|
||||||
})
|
}, MCP_RUNTIME_STATUS_REFRESH_MS);
|
||||||
.finally(() => {
|
}
|
||||||
if (!cancelled) setMcpPresetsLoading(false);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
if (!cancelled) setMcpError((err as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "設定を更新",
|
||||||
|
|||||||
@@ -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": "설정 업데이트",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "更新配置",
|
||||||
|
|||||||
@@ -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": "更新設定",
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user