From 504a5361ee5432907a6b9a6f4320cdfd8df919a7 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 10 Aug 2026 14:03:52 +0800 Subject: [PATCH 1/9] feat(mcp): add browser OAuth for remote servers --- docs/configuration.md | 40 +- docs/guides/configure-mcp-tools.md | 12 +- docs/webui.md | 10 +- nanobot/agent/tools/mcp.py | 53 ++- nanobot/agent/tools/mcp_oauth.py | 303 +++++++++++++ nanobot/config/schema.py | 1 + nanobot/webui/mcp_oauth_api.py | 330 ++++++++++++++ nanobot/webui/mcp_presets_api.py | 166 ++++++- nanobot/webui/settings_routes.py | 140 +++++- nanobot/webui/ws_http.py | 13 +- tests/tools/test_mcp_oauth.py | 267 ++++++++++++ tests/tools/test_mcp_tool.py | 123 ++++++ tests/webui/test_mcp_oauth_api.py | 182 ++++++++ tests/webui/test_mcp_presets_api.py | 106 ++++- tests/webui/test_settings_routes.py | 79 +++- tests/webui/test_ws_http_oauth.py | 38 ++ .../src/components/settings/SettingsView.tsx | 411 ++++++++++++++++-- webui/src/i18n/locales/en/common.json | 34 +- webui/src/i18n/locales/es/common.json | 34 +- webui/src/i18n/locales/fr/common.json | 34 +- webui/src/i18n/locales/id/common.json | 34 +- webui/src/i18n/locales/ja/common.json | 34 +- webui/src/i18n/locales/ko/common.json | 34 +- webui/src/i18n/locales/pt-BR/common.json | 34 +- webui/src/i18n/locales/vi/common.json | 34 +- webui/src/i18n/locales/zh-CN/common.json | 34 +- webui/src/i18n/locales/zh-TW/common.json | 34 +- webui/src/lib/api.ts | 39 ++ webui/src/lib/types.ts | 24 + webui/src/tests/api.test.ts | 23 + webui/src/tests/i18n.test.tsx | 13 + webui/src/tests/settings-view.test.tsx | 286 +++++++++++- 32 files changed, 2834 insertions(+), 165 deletions(-) create mode 100644 nanobot/agent/tools/mcp_oauth.py create mode 100644 nanobot/webui/mcp_oauth_api.py create mode 100644 tests/tools/test_mcp_oauth.py create mode 100644 tests/webui/test_mcp_oauth_api.py create mode 100644 tests/webui/test_ws_http_oauth.py diff --git a/docs/configuration.md b/docs/configuration.md index 6b3bcb5915e..9d1c427e81c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1971,15 +1971,47 @@ Add MCP servers to your `config.json`: } ``` -Two transport modes are supported: +MCP servers can run locally over stdio or connect remotely over HTTP: -| Mode | Config | Example | +| Connection | Config | Example | |------|--------|---------| | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | -| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) | +| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) | + +Remote HTTP servers may use browser OAuth instead of static headers. Add +`auth: "oauth"`, then open **Apps → MCP** in the WebUI and choose **Connect**. +Known presets such as Xmind, Notion, and Linear add the config automatically on +first click. + +```json +{ + "tools": { + "mcpServers": { + "notion": { + "type": "streamableHttp", + "url": "https://mcp.notion.com/mcp", + "auth": "oauth" + } + } + } +} +``` + +nanobot opens the server's authorization page and handles the callback through +the gateway. The tools become available immediately when hot reload succeeds; +otherwise the WebUI asks for a restart. OAuth tokens and dynamic client +registration data are stored in the nanobot data directory under +`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server +from Apps also removes its saved OAuth credentials. Normal gateway startup never +opens a browser or registers a new OAuth client when credentials are +missing—interactive authorization starts only after a user clicks **Connect**. + +For a remotely accessed WebUI, use HTTPS and configure +`channels.websocket.publicWsUrl` with the public `wss://` endpoint so nanobot can +register the matching HTTPS callback. A loopback WebUI may use HTTP. > [!IMPORTANT] -> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected. +> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected. Use `toolTimeout` to override the default 30s per-call timeout for slow servers: diff --git a/docs/guides/configure-mcp-tools.md b/docs/guides/configure-mcp-tools.md index 4227dd76294..0ccf7604b73 100644 --- a/docs/guides/configure-mcp-tools.md +++ b/docs/guides/configure-mcp-tools.md @@ -30,10 +30,12 @@ remote HTTP endpoint. For local interactive setup: 1. Run `nanobot webui` and open **Apps**. -2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server. +2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server. + For Xmind, Notion, Linear, or another OAuth-enabled entry, click **Connect** + and approve access in the browser window; there is no callback URL or code to copy. 3. Limit the enabled tools when the server exposes more than the task needs. 4. Save and restart when prompted. -5. Mention the integration with `@` in the next message and ask for a small test action. +5. Mention the connected MCP server with `@` in the next message and ask for a small test action. For manual or deployment-managed config, add this to `~/.nanobot/config.json`: @@ -58,12 +60,16 @@ Restart nanobot and ask a question that requires the MCP tool. - Prefer `enabledTools` over exposing every tool by default. - Use `toolTimeout` for slow MCP operations. - Use HTTP MCP only for endpoints you trust. +- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the + browser connection from **Apps → MCP**. - Keep MCP server commands stable and versioned in deployment docs or scripts. ## Security notes - Stdio MCP starts a local process; review the command before enabling it. -- HTTP/SSE MCP uses nanobot's SSRF guard. +- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration, + token exchange, and redirects. +- OAuth credentials live in the nanobot data directory, not in `config.json`. - Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs. - Do not place secrets in command arguments when environment variables or headers can be used. diff --git a/docs/webui.md b/docs/webui.md index 7885b61c9f0..f68b3a8a368 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -204,8 +204,10 @@ turn. The default **Ready** view shows only tools that can be used immediately: - **Apps** are local command-line adapters that nanobot runs on your machine. Installing an adapter does not modify the native desktop or web app it connects to. -- **Integrations** are MCP servers. Presets provide known configurations, and - the custom integration panel accepts stdio, HTTP, and SSE servers. +- **MCP** lists Model Context Protocol servers. Presets provide known + configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE + servers. OAuth presets such as Xmind, Notion, and Linear open their sign-in + page in the browser and return to nanobot automatically. Apps intentionally does not list nanobot runtime support packages such as `api` or `bedrock`. Those packages enable providers, servers, or channels; they @@ -226,8 +228,8 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key. It is an optional integration and does not replace nanobot's built-in web search provider; mention `@parallel-search` when a turn should use it. -After an App or integration is available, mention it from the composer with -`@` to attach that tool to the next message. +After an App or MCP server is available, mention it from the composer with `@` +to attach that tool to the next message. ## Skills diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index a4d3aad5d46..9afbdb22eec 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -38,6 +38,7 @@ from mcp.types import Prompt, Resource from mcp.types import Tool as MCPToolDefinition + from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers from nanobot.config.schema import MCPServerConfig # Transient connection errors that warrant a single retry. @@ -961,7 +962,10 @@ async def execute(self, **kwargs: Any) -> str: async def connect_mcp_servers( - mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry + mcp_servers: "dict[str, MCPServerConfig]", + registry: ToolRegistry, + *, + oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None, ) -> dict[str, MCPConnection]: """Connect to configured MCP servers and register their tools, resources, prompts. @@ -1001,6 +1005,29 @@ async def open_single_server( ) return False + oauth_auth: httpx.Auth | None = None + if cfg.auth == "oauth": + if transport_type not in {"sse", "streamableHttp"}: + logger.warning( + "MCP server '{}': OAuth requires an SSE or Streamable HTTP transport", + name, + ) + return False + from nanobot.agent.tools.mcp_oauth import ( + MCPAuthorizationRequiredError, + create_mcp_oauth_auth, + ) + + try: + oauth_auth = await create_mcp_oauth_auth( + name, + cfg.url, + (oauth_handlers or {}).get(name), + ) + except MCPAuthorizationRequiredError: + logger.info("MCP server '{}': waiting for browser authorization", name) + return False + if transport_type == "stdio": command, args, env = _normalize_windows_stdio_command( cfg.command, @@ -1038,22 +1065,30 @@ def httpx_client_factory( **_pinned_transport_kwargs(), ) + sse_kwargs: dict[str, Any] = { + "httpx_client_factory": httpx_client_factory, + } + if oauth_auth is not None: + sse_kwargs["auth"] = oauth_auth read, write = await server_stack.enter_async_context( - sse_client(cfg.url, httpx_client_factory=httpx_client_factory) + sse_client(cfg.url, **sse_kwargs) ) elif transport_type == "streamableHttp": if not await _probe_http_url(cfg.url): logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url)) return False + http_client_kwargs: dict[str, Any] = { + "headers": cfg.headers or None, + "event_hooks": {"request": [_validate_mcp_request_url]}, + "follow_redirects": True, + "timeout": httpx.Timeout(30.0, connect=10.0), + **_pinned_transport_kwargs(), + } + if oauth_auth is not None: + http_client_kwargs["auth"] = oauth_auth http_client = await server_stack.enter_async_context( - httpx.AsyncClient( - headers=cfg.headers or None, - event_hooks={"request": [_validate_mcp_request_url]}, - follow_redirects=True, - timeout=httpx.Timeout(30.0, connect=10.0), - **_pinned_transport_kwargs(), - ) + httpx.AsyncClient(**http_client_kwargs) ) read, write, _ = await server_stack.enter_async_context( streamable_http_client(cfg.url, http_client=http_client) diff --git a/nanobot/agent/tools/mcp_oauth.py b/nanobot/agent/tools/mcp_oauth.py new file mode 100644 index 00000000000..9846c801fb1 --- /dev/null +++ b/nanobot/agent/tools/mcp_oauth.py @@ -0,0 +1,303 @@ +"""OAuth support for remote MCP servers. + +This module intentionally owns MCP OAuth end to end. Provider OAuth has a +different lifecycle and storage contract, so sharing a higher-level workflow +would couple unrelated extension boundaries. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypedDict, cast + +from filelock import FileLock +from loguru import logger +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from pydantic import AnyUrl + +from nanobot.config.paths import get_data_dir +from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage] + +MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback" +_STORE_VERSION = 1 +_STORE_LOCK_TIMEOUT_S = 15 +_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}" + + +class _StoredServer(TypedDict, total=False): + server_fingerprint: str + tokens: dict[str, Any] + client_info: dict[str, Any] + redirect_uri: str + + +class _CredentialStore(TypedDict): + version: int + servers: dict[str, _StoredServer] + + +class MCPAuthorizationRequiredError(RuntimeError): + """Raised when a background MCP connection needs interactive authorization.""" + + +@dataclass(frozen=True) +class MCPOAuthHandlers: + """Browser callbacks supplied only for a user-initiated OAuth attempt.""" + + redirect_uri: str + redirect_handler: Callable[[str], Awaitable[None]] + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] + reset_credentials: bool = False + + +def _store_path() -> Path: + return get_data_dir() / "auth" / "mcp.json" + + +def _server_fingerprint(server_url: str) -> str: + return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest() + + +def _empty_store() -> _CredentialStore: + return {"version": _STORE_VERSION, "servers": {}} + + +def _stored_server(value: object) -> _StoredServer | None: + if not isinstance(value, dict): + return None + raw = cast(dict[object, object], value) + entry: _StoredServer = {} + fingerprint = raw.get("server_fingerprint") + if isinstance(fingerprint, str): + entry["server_fingerprint"] = fingerprint + redirect_uri = raw.get("redirect_uri") + if isinstance(redirect_uri, str): + entry["redirect_uri"] = redirect_uri + tokens = raw.get("tokens") + if isinstance(tokens, dict): + token_values = cast(dict[object, object], tokens) + if all(isinstance(key, str) for key in token_values): + entry["tokens"] = cast(dict[str, Any], token_values) + client_info = raw.get("client_info") + if isinstance(client_info, dict): + client_values = cast(dict[object, object], client_info) + if all(isinstance(key, str) for key in client_values): + entry["client_info"] = cast(dict[str, Any], client_values) + return entry + + +def _read_store_unlocked(path: Path) -> _CredentialStore: + try: + raw = cast(object, json.loads(path.read_text(encoding="utf-8"))) + except FileNotFoundError: + return _empty_store() + except (OSError, ValueError, TypeError) as exc: + logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__) + return _empty_store() + if not isinstance(raw, dict): + return _empty_store() + payload = cast(dict[object, object], raw) + raw_servers = payload.get("servers") + if not isinstance(raw_servers, dict): + return _empty_store() + servers: dict[str, _StoredServer] = {} + for name, value in cast(dict[object, object], raw_servers).items(): + entry = _stored_server(value) + if isinstance(name, str) and entry is not None: + servers[name] = entry + return {"version": _STORE_VERSION, "servers": servers} + + +def _with_store_lock(path: Path) -> FileLock: + path.parent.mkdir(parents=True, exist_ok=True) + return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S) + + +def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(path.parent, 0o700) + _write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False)) + with suppress(OSError): + os.chmod(path, 0o600) + + +class MCPOAuthStorage: + """Persistent MCP SDK token storage, isolated by config name and server URL.""" + + def __init__(self, server_name: str, server_url: str) -> None: + self.server_name = server_name + self.server_fingerprint = _server_fingerprint(server_url) + + def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None: + servers = payload["servers"] + entry = servers.get(self.server_name) + if entry is None or entry.get("server_fingerprint") != self.server_fingerprint: + return None + return entry + + def _read_entry_sync(self) -> _StoredServer | None: + path = _store_path() + with _with_store_lock(path): + return self._entry_unlocked(_read_store_unlocked(path)) + + def _update_entry_sync(self, update: Callable[[_StoredServer], None]) -> None: + path = _store_path() + with _with_store_lock(path): + payload = _read_store_unlocked(path) + servers = payload["servers"] + entry = self._entry_unlocked(payload) + if entry is None: + entry = _StoredServer(server_fingerprint=self.server_fingerprint) + servers[self.server_name] = entry + update(entry) + payload["version"] = _STORE_VERSION + _write_store_unlocked(path, payload) + + async def get_tokens(self) -> OAuthToken | None: + entry = await asyncio.to_thread(self._read_entry_sync) + raw = entry.get("tokens") if entry is not None else None + if not isinstance(raw, dict): + return None + try: + return OAuthToken.model_validate(raw) + except (ValueError, TypeError): + logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name) + return None + + async def set_tokens(self, tokens: OAuthToken) -> None: + raw = tokens.model_dump(mode="json", exclude_none=True) + + def update(entry: _StoredServer) -> None: + entry["tokens"] = raw + + await asyncio.to_thread(self._update_entry_sync, update) + + async def clear_tokens(self) -> None: + def update(entry: _StoredServer) -> None: + entry.pop("tokens", None) + + await asyncio.to_thread(self._update_entry_sync, update) + + async def get_client_info(self) -> OAuthClientInformationFull | None: + entry = await asyncio.to_thread(self._read_entry_sync) + raw = entry.get("client_info") if entry is not None else None + if not isinstance(raw, dict): + return None + try: + return OAuthClientInformationFull.model_validate(raw) + except (ValueError, TypeError): + logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name) + return None + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + raw = client_info.model_dump(mode="json", exclude_none=True) + + def update(entry: _StoredServer) -> None: + entry["client_info"] = raw + + await asyncio.to_thread(self._update_entry_sync, update) + + async def redirect_uri(self) -> str | None: + entry = await asyncio.to_thread(self._read_entry_sync) + value = entry.get("redirect_uri") if entry is not None else None + return value if isinstance(value, str) and value else None + + async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None: + def update(entry: _StoredServer) -> None: + changed = entry.get("redirect_uri") != redirect_uri + if reset: + entry.pop("tokens", None) + entry.pop("client_info", None) + elif changed: + # Dynamic registrations bind a client to its redirect URI. + entry.pop("client_info", None) + entry["redirect_uri"] = redirect_uri + + await asyncio.to_thread(self._update_entry_sync, update) + + def has_credentials(self) -> bool: + entry = self._read_entry_sync() + raw_tokens = entry.get("tokens") if entry is not None else None + if not isinstance(raw_tokens, dict): + return False + tokens = cast(dict[str, object], raw_tokens) + access_token = tokens.get("access_token") + return isinstance(access_token, str) and bool(access_token) + + +async def _missing_callback() -> tuple[str, str | None]: + raise MCPAuthorizationRequiredError("MCP server requires browser authorization") + + +async def create_mcp_oauth_auth( + server_name: str, + server_url: str, + handlers: MCPOAuthHandlers | None = None, +) -> OAuthClientProvider: + """Build the official MCP SDK OAuth provider for one configured server.""" + storage = MCPOAuthStorage(server_name, server_url) + if handlers is not None: + await storage.prepare_redirect_uri( + handlers.redirect_uri, + reset=handlers.reset_credentials, + ) + redirect_uri = handlers.redirect_uri + redirect_handler = handlers.redirect_handler + callback_handler = handlers.callback_handler + else: + if not await asyncio.to_thread(storage.has_credentials): + # Do not perform discovery or dynamic registration from a background + # startup. Interactive OAuth begins only after an explicit user action. + raise MCPAuthorizationRequiredError("MCP server requires browser authorization") + redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI + + async def authorization_required(_authorization_url: str) -> None: + await storage.clear_tokens() + raise MCPAuthorizationRequiredError("MCP server requires browser authorization") + + redirect_handler = authorization_required + callback_handler = _missing_callback + + metadata = OAuthClientMetadata( + redirect_uris=[AnyUrl(redirect_uri)], + token_endpoint_auth_method="none", + client_name="nanobot", + software_id="https://github.com/HKUDS/nanobot", + ) + return OAuthClientProvider( + server_url, + metadata, + storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + timeout=300, + ) + + +def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool: + """Return whether this exact configured MCP instance has an access token.""" + return MCPOAuthStorage(server_name, server_url).has_credentials() + + +def delete_mcp_oauth_credentials(server_name: str) -> bool: + """Delete credentials for one config name without touching other MCP instances.""" + path = _store_path() + if not path.exists(): + return False + with _with_store_lock(path): + payload = _read_store_unlocked(path) + servers = payload["servers"] + if server_name not in servers: + return False + del servers[server_name] + _write_store_unlocked(path, payload) + return True diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e997fea30fa..4d3d2e79cd2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -373,6 +373,7 @@ class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted + auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars diff --git a/nanobot/webui/mcp_oauth_api.py b/nanobot/webui/mcp_oauth_api.py new file mode 100644 index 00000000000..36765578015 --- /dev/null +++ b/nanobot/webui/mcp_oauth_api.py @@ -0,0 +1,330 @@ +"""Gateway-owned browser authorization flows for remote MCP servers.""" + +from __future__ import annotations + +import asyncio +import re +import secrets +import time +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers +from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.config.schema import MCPServerConfig +from nanobot.security.network import validate_url_target +from nanobot.webui.http_utils import is_loopback_host + +McpReload = Callable[[], Awaitable[dict[str, Any]]] +_FLOW_TTL_S = 300 +_START_WAIT_S = 20 +_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$") + + +class McpOAuthError(Exception): + """Safe WebUI error for an MCP OAuth request.""" + + def __init__(self, message: str, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status + + +class _OAuthCallbackError(RuntimeError): + pass + + +@dataclass +class _McpOAuthFlow: + flow_id: str + name: str + cfg: MCPServerConfig + redirect_uri: str + expires_at: float + authorization_ready: asyncio.Event = field(default_factory=asyncio.Event) + callback_result: asyncio.Future[tuple[str, str | None]] | None = None + task: asyncio.Task[bool] | None = None + authorization_url: str | None = None + state: str | None = None + callback_received: bool = False + error: str | None = None + reload_result: dict[str, Any] | None = None + + +def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str: + """Allow HTTPS callbacks, plus loopback HTTP for a local gateway.""" + parsed = urlsplit(redirect_uri.strip()) + if ( + not parsed.netloc + or parsed.path != MCP_OAUTH_CALLBACK_PATH + or parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None + ): + raise McpOAuthError("Invalid MCP OAuth callback URL") + if parsed.scheme == "https": + return redirect_uri.strip() + if parsed.scheme == "http" and is_loopback_host(parsed.netloc): + return redirect_uri.strip() + raise McpOAuthError( + "Remote MCP OAuth requires HTTPS. Configure a public wss:// URL or use localhost." + ) + + +class McpOAuthManager: + """Own short-lived browser flows while the gateway process is running.""" + + def __init__(self) -> None: + self._flows: dict[str, _McpOAuthFlow] = {} + self._states: dict[str, str] = {} + + async def start( + self, + name: str, + cfg: MCPServerConfig, + redirect_uri: str, + *, + reload_mcp: McpReload, + reset_credentials: bool = False, + ) -> dict[str, Any]: + self._prune() + redirect_uri = validate_mcp_oauth_redirect_uri(redirect_uri) + await self._cancel_name(name) + + loop = asyncio.get_running_loop() + now = time.monotonic() + flow = _McpOAuthFlow( + flow_id=secrets.token_urlsafe(24), + name=name, + cfg=cfg, + redirect_uri=redirect_uri, + expires_at=now + _FLOW_TTL_S, + callback_result=loop.create_future(), + ) + self._flows[flow.flow_id] = flow + handlers = MCPOAuthHandlers( + redirect_uri=redirect_uri, + redirect_handler=lambda url: self._receive_authorization_url(flow, url), + callback_handler=lambda: self._wait_for_callback(flow), + reset_credentials=reset_credentials, + ) + flow.task = asyncio.create_task( + self._connect_and_reload(flow, handlers, reload_mcp), + name=f"mcp-oauth:{name}", + ) + + ready_waiter = asyncio.create_task(flow.authorization_ready.wait()) + try: + await asyncio.wait( + {ready_waiter, flow.task}, + timeout=_START_WAIT_S, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + ready_waiter.cancel() + with suppress(asyncio.CancelledError): + await ready_waiter + return self._payload(flow) + + async def status(self, flow_id: str) -> dict[str, Any]: + self._prune() + flow = self._flow(flow_id) + return self._payload(flow) + + def submit_callback( + self, + *, + state: str, + code: str | None, + error: str | None, + ) -> str: + self._prune() + flow_id = self._states.pop(state, None) + if flow_id is None: + raise McpOAuthError("This MCP authorization request has expired", status=410) + flow = self._flow(flow_id) + callback_result = flow.callback_result + if callback_result is None or callback_result.done(): + raise McpOAuthError("This MCP authorization callback was already used", status=409) + + flow.callback_received = True + if error: + safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed" + flow.error = f"Authorization was not completed ({safe_error})." + callback_result.set_exception(_OAuthCallbackError(flow.error)) + raise McpOAuthError(flow.error) + elif not code or len(code) > 8192: + flow.error = "The MCP server did not return an authorization code." + callback_result.set_exception(_OAuthCallbackError(flow.error)) + raise McpOAuthError(flow.error) + else: + callback_result.set_result((code, state)) + return flow.name + + async def cancel(self, flow_id: str) -> dict[str, Any]: + self._prune() + flow = self._flow(flow_id) + await self._cancel_flow(flow) + return self._payload(flow) + + async def _receive_authorization_url( + self, + flow: _McpOAuthFlow, + authorization_url: str, + ) -> None: + parsed = urlsplit(authorization_url) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + flow.error = "The MCP server returned an unsafe authorization URL." + raise McpOAuthError(flow.error) + ok, _error = validate_url_target(authorization_url) + if not ok: + flow.error = "The MCP server returned an unsafe authorization URL." + raise McpOAuthError(flow.error) + states = parse_qs(parsed.query).get("state", []) + state = states[0] if len(states) == 1 else "" + if not state or len(state) > 512: + flow.error = "The MCP server returned an invalid authorization URL." + raise McpOAuthError(flow.error) + if state in self._states: + flow.error = "The MCP server reused an OAuth state value." + raise McpOAuthError(flow.error) + flow.authorization_url = authorization_url + flow.state = state + self._states[state] = flow.flow_id + flow.authorization_ready.set() + + async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]: + callback_result = flow.callback_result + if callback_result is None: + raise _OAuthCallbackError("MCP OAuth callback is unavailable") + remaining = max(0.1, flow.expires_at - time.monotonic()) + try: + return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining) + except asyncio.TimeoutError as exc: + flow.error = "MCP authorization timed out." + raise _OAuthCallbackError(flow.error) from exc + + async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool: + connections: dict[str, MCPConnection] = {} + try: + connections = await connect_mcp_servers( + {flow.name: flow.cfg}, + ToolRegistry(), + oauth_handlers={flow.name: handlers}, + ) + succeeded = flow.name in connections + if not succeeded and flow.error is None: + flow.error = "Could not complete the MCP OAuth connection." + return succeeded + except asyncio.CancelledError: + raise + except Exception: + if flow.error is None: + flow.error = "Could not complete the MCP OAuth connection." + return False + finally: + for connection in connections.values(): + with suppress(Exception): + await connection.aclose() + + async def _connect_and_reload( + self, + flow: _McpOAuthFlow, + handlers: MCPOAuthHandlers, + reload_mcp: McpReload, + ) -> bool: + succeeded = await self._connect(flow, handlers) + if not succeeded: + return False + try: + flow.reload_result = await reload_mcp() + except Exception: + flow.reload_result = { + "ok": False, + "message": "Signed in, but nanobot could not activate the MCP tools.", + "requires_restart": True, + } + return True + + def _flow(self, flow_id: str) -> _McpOAuthFlow: + flow = self._flows.get(flow_id) + if flow is None: + raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404) + return flow + + def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]: + task = flow.task + if task is not None and task.cancelled(): + status = "cancelled" + elif task is not None and task.done(): + try: + succeeded = task.result() + except Exception: + succeeded = False + if not succeeded: + status = "failed" + elif flow.reload_result is None: + status = "authorized" + elif flow.reload_result.get("ok"): + status = "connected" + else: + status = "authorized" + elif flow.callback_received: + status = "connecting" + elif flow.authorization_url: + status = "authorization_required" + else: + status = "starting" + + payload: dict[str, Any] = { + "flow_id": flow.flow_id, + "name": flow.name, + "status": status, + "expires_in": max(0, int(flow.expires_at - time.monotonic())), + } + if flow.authorization_url and status == "authorization_required": + payload["authorization_url"] = flow.authorization_url + if flow.error: + payload["error"] = flow.error + if flow.reload_result is not None: + payload["hot_reload"] = flow.reload_result + return payload + + async def _cancel_name(self, name: str) -> None: + for flow in list(self._flows.values()): + if flow.name == name and flow.task is not None and not flow.task.done(): + await self._cancel_flow(flow) + + async def _cancel_flow(self, flow: _McpOAuthFlow) -> None: + if flow.state: + self._states.pop(flow.state, None) + task = flow.task + if task is not None and not task.done(): + task.cancel() + with suppress(BaseException): + await task + + def _prune(self) -> None: + now = time.monotonic() + for flow_id, flow in list(self._flows.items()): + if flow.expires_at > now: + continue + if flow.state: + self._states.pop(flow.state, None) + if flow.task is not None and not flow.task.done(): + flow.task.cancel() + callback_result = flow.callback_result + if callback_result is not None and not callback_result.done(): + callback_result.cancel() + self._flows.pop(flow_id, None) diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 162c793fac7..86413f3a65f 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -16,6 +16,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Mapping, cast +from nanobot.agent.tools.mcp_oauth import ( + delete_mcp_oauth_credentials, + mcp_oauth_has_credentials, +) from nanobot.agent.tools.registry import ToolRegistry from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.config.loader import load_config, resolve_config_env_vars, save_config @@ -337,6 +341,63 @@ def _favicon_url(domain: str) -> str: ), note="Requires Figma Desktop Dev Mode MCP to be running locally.", ), + McpPreset( + name="xmind", + display_name="Xmind", + category="productivity", + description="Create, read, and edit cloud mind maps through Xmind.", + docs_url="https://xmind.com/user-guide/xmind-mcp", + transport="streamableHttp", + install_supported=True, + brand_domain="xmind.com", + brand_color="#F4B41A", + requires="Xmind account", + server=MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://app.xmind.com/api/mcp", + tool_timeout=60, + ), + note="Connects securely in your browser with Xmind OAuth.", + ), + McpPreset( + name="notion", + display_name="Notion", + category="productivity", + description="Read and update your Notion workspace through Notion MCP.", + docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp", + transport="streamableHttp", + install_supported=True, + brand_domain="notion.so", + brand_color="#111111", + requires="Notion account", + server=MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.notion.com/mcp", + tool_timeout=60, + ), + note="Connects securely in your browser with Notion OAuth.", + ), + McpPreset( + name="linear", + display_name="Linear", + category="productivity", + description="Find and manage Linear issues, projects, and comments.", + docs_url="https://linear.app/docs/mcp", + transport="streamableHttp", + install_supported=True, + brand_domain="linear.app", + brand_color="#5E6AD2", + requires="Linear account", + server=MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.linear.app/mcp", + tool_timeout=60, + ), + note="Connects securely in your browser with Linear OAuth.", + ), McpPreset( name="github", display_name="GitHub", @@ -657,6 +718,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str: return "not_installed" if preset.install_supported else "coming_soon" if any(field.required and not _field_configured(field, cfg) for field in preset.fields): return "missing_credentials" + if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url): + return "authorization_required" if cfg.command and not _command_available(cfg.command): return "missing_dependency" return "configured" @@ -702,6 +765,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]: compact_dict({ "type": "mcp", "transport": preset.transport, + "auth": server.auth if server and server.auth else None, "command": server.command if server and server.command else None, "args": list(server.args) if server and server.command else None, "url": _connection_summary(server) if server and server.url else None, @@ -752,6 +816,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]: compact_dict({ "type": "mcp", "transport": transport, + "auth": cfg.auth, "command": cfg.command or None, "url": _connection_summary(cfg) if cfg.url else None, }) @@ -779,7 +844,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]: def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]: cfg = configured_servers.get(preset.name) status = _status_for(preset, cfg) - configured = cfg is not None and status not in {"missing_credentials"} + configured = cfg is not None and status not in {"missing_credentials", "authorization_required"} logo_url = _favicon_url(preset.brand_domain) return { "name": preset.name, @@ -788,6 +853,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo "description": preset.description, "docs_url": preset.docs_url, "transport": preset.transport, + "auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)), "requires": preset.requires, "note": preset.note, "install_supported": preset.install_supported, @@ -814,7 +880,11 @@ def _custom_payload( transport = cfg.type if not transport: transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp") - status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured" + if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url): + status = "authorization_required" + else: + status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured" + configured = status != "authorization_required" return { "name": name, "display_name": name, @@ -822,12 +892,13 @@ def _custom_payload( "description": "Custom MCP server from nanobot config.", "docs_url": "", "transport": transport, + "auth": cfg.auth, "requires": "", "note": "", "install_supported": True, "installed": True, - "configured": True, - "available": _config_available(cfg), + "configured": configured, + "available": configured and _config_available(cfg), "status": status, "logo_url": None, "brand_color": "#64748B", @@ -1127,6 +1198,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "") return normalized # type: ignore[return-value] +def _normalize_auth( + value: object, + *, + transport: Literal["stdio", "sse", "streamableHttp"], + url: str, + headers: Mapping[str, str], +) -> Literal["oauth"] | None: + raw = str(value or "").strip().lower() + if not raw and url and not headers: + normalized_url = url.rstrip("/") + if any( + preset.server is not None + and preset.server.auth == "oauth" + and preset.server.url.rstrip("/") == normalized_url + for preset in MCP_PRESETS + ): + raw = "oauth" + if not raw: + return None + if raw != "oauth": + raise McpPresetError("unsupported MCP auth type") + if transport == "stdio": + raise McpPresetError("MCP OAuth requires a remote HTTP transport") + return "oauth" + + def _validated_server_name(name: str) -> str: if not name or _MCP_PRESET_NAME_RE.match(name) is None: raise McpPresetError("invalid MCP server name") @@ -1142,6 +1239,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig] raise McpPresetError("stdio MCP servers require a command") if transport in {"sse", "streamableHttp"} and not url: raise McpPresetError("remote MCP servers require a URL") + headers = _parse_string_map(_query_first(query, "headers")) + auth = _normalize_auth( + _query_first(query, "auth"), + transport=transport, + url=url, + headers=headers, + ) raw_timeout = (_query_first(query, "tool_timeout") or "").strip() tool_timeout = _DEFAULT_CUSTOM_TIMEOUT if raw_timeout: @@ -1151,12 +1255,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig] raise McpPresetError("tool_timeout must be an integer") from exc cfg = MCPServerConfig( type=transport, + auth=auth, command=command if transport == "stdio" else "", args=_parse_string_list(_query_first(query, "args")), env=_parse_string_map(_query_first(query, "env")), cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "", url=url if transport in {"sse", "streamableHttp"} else "", - headers=_parse_string_map(_query_first(query, "headers")), + headers=headers, tool_timeout=tool_timeout, enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")), ) @@ -1201,6 +1306,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]: headers = cast(dict[object, object], headers_value) if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()): raise McpPresetError(f"MCP server '{server_name}' headers must be a string object") + typed_headers = cast(dict[str, str], headers) + auth = _normalize_auth( + server.get("auth"), + transport=transport, + url=url, + headers=typed_headers, + ) if not isinstance(enabled_tools_value, list): enabled_tools_value = ["*"] else: @@ -1209,12 +1321,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]: enabled_tools_value = ["*"] return server_name, MCPServerConfig( type=transport, + auth=auth, command=command if transport == "stdio" else "", args=cast(list[str], args), env=cast(dict[str, str], env), cwd=cwd if transport == "stdio" else "", url=url if transport in {"sse", "streamableHttp"} else "", - headers=cast(dict[str, str], headers), + headers=typed_headers, tool_timeout=timeout_int, enabled_tools=cast(list[str], enabled_tools_value), ) @@ -1239,6 +1352,15 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]: return out +def _oauth_credentials_replaced( + previous: MCPServerConfig | None, + replacement: MCPServerConfig, +) -> bool: + if previous is None or previous.auth != "oauth": + return False + return replacement.auth != "oauth" or replacement.url != previous.url + + def custom_mcp_action( action: str, query: QueryParams, @@ -1248,8 +1370,11 @@ def custom_mcp_action( config = load_config(config_path) if config_path is not None else load_config() if action == "custom": name, cfg = _custom_server_from_query(query) + delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg) config.tools.mcp_servers[name] = cfg save_config(config, config_path) + if delete_credentials: + delete_mcp_oauth_credentials(name) payload = mcp_presets_payload( last_action=_server_action_message(action, name), config_path=config_path, @@ -1259,8 +1384,15 @@ def custom_mcp_action( if action in {"import", "import-cursor"}: servers = _import_mcp_servers(_query_first(query, "config")) + delete_credentials = [ + name + for name, cfg in servers.items() + if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg) + ] config.tools.mcp_servers.update(servers) save_config(config, config_path) + for name in delete_credentials: + delete_mcp_oauth_credentials(name) payload = mcp_presets_payload( last_action={ "ok": True, @@ -1289,6 +1421,27 @@ def custom_mcp_action( raise McpPresetError(f"unknown MCP action '{action}'", status=404) +def ensure_mcp_oauth_server( + query: QueryParams, + *, + config_path: Path | None = None, +) -> tuple[str, MCPServerConfig]: + """Materialize an OAuth preset on first click and return its saved config.""" + name = _validated_server_name((_query_first(query, "name") or "").strip()) + config = load_config(config_path) if config_path is not None else load_config() + cfg = config.tools.mcp_servers.get(name) + if cfg is None: + preset = _preset_by_name(name) + if preset.server is None or preset.server.auth != "oauth": + raise McpPresetError("MCP server does not support browser authorization", status=409) + cfg = _materialize_server(preset, query, None) + config.tools.mcp_servers[name] = cfg + save_config(config, config_path) + if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url: + raise McpPresetError("MCP server is not configured for OAuth", status=409) + return name, cfg + + def mcp_presets_action( action: str, query: QueryParams, @@ -1328,6 +1481,7 @@ def mcp_presets_action( cleanup_error = str(exc) del config.tools.mcp_servers[name] save_config(config, config_path) + delete_mcp_oauth_credentials(name) last_action = ( _action_message(action, preset) if preset is not None diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index bd50124dcfc..377dc9fe4ae 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import html import inspect import json import time @@ -19,6 +20,7 @@ from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.mcp import request_mcp_reload +from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths from nanobot.bus.queue import MessageBus from nanobot.channels._setup import channel_setup_spec @@ -39,9 +41,12 @@ ) from nanobot.pairing import approve_code, deny_code, list_pending from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload +from nanobot.webui.http_utils import case_insensitive_header +from nanobot.webui.http_utils import http_response as _http_response from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request from nanobot.webui.http_utils import query_first as _query_first -from nanobot.webui.mcp_presets_api import mcp_presets_settings_action +from nanobot.webui.mcp_oauth_api import McpOAuthManager +from nanobot.webui.mcp_presets_api import ensure_mcp_oauth_server, mcp_presets_settings_action from nanobot.webui.nanobot_features_api import ( nanobot_feature_instance_target, nanobot_features_action, @@ -130,6 +135,8 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None: "/api/settings/channels/configure", "/api/settings/pairing/approve", "/api/settings/pairing/deny", + "/api/settings/mcp-oauth/start", + "/api/settings/mcp-oauth/cancel", *_MCP_PRESET_ACTIONS_BY_PATH, }) @@ -177,6 +184,7 @@ def __init__( runtime_capabilities: dict[str, Any], channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None, ) -> None: self.settings = settings self.bus = bus @@ -189,6 +197,8 @@ def __init__( self._runtime_capabilities = runtime_capabilities self._channel_feature_action = channel_feature_action self._channel_runtime_status = channel_runtime_status + self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri + self._mcp_oauth = McpOAuthManager() self._restart_sections: set[str] = set() self._channel_connectors: dict[str, Any] = {} @@ -202,6 +212,8 @@ async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Resp 405, "WebUI mutations require an authenticated WebSocket", ) + if path == MCP_OAUTH_CALLBACK_PATH: + return self._handle_mcp_oauth_callback(request) if path == "/api/settings": return self._handle_settings(request) if path == "/api/settings/usage": @@ -281,6 +293,12 @@ async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Resp return self._handle_settings_pairing_action(request, "deny") if path == "/api/settings/mcp-presets": return await self._handle_settings_mcp_presets(request) + if path == "/api/settings/mcp-oauth/start": + return await self._handle_mcp_oauth_start(request) + if path == "/api/settings/mcp-oauth/status": + return await self._handle_mcp_oauth_status(request) + if path == "/api/settings/mcp-oauth/cancel": + return await self._handle_mcp_oauth_cancel(request) if path == "/api/settings/version-check": return await self._handle_settings_version_check(request) mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path) @@ -1230,6 +1248,126 @@ async def _handle_settings_mcp_presets( return self._json_response(payload) return self._json_response(self._with_restart_state(payload, section="runtime")) + async def _handle_mcp_oauth_start(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + if self._mcp_oauth_redirect_uri is None: + return self._error_response(500, "MCP OAuth callback is not configured") + query = self._parse_mcp_settings_query(request) + try: + name, cfg = await asyncio.to_thread( + self.settings.mutate, + ensure_mcp_oauth_server, + query, + ) + redirect_uri = self._mcp_oauth_redirect_uri(request) + reset = (_query_first(query, "reset") or "").lower() in {"1", "true", "yes"} + payload = await self._mcp_oauth.start( + name, + cfg, + redirect_uri, + reload_mcp=lambda: request_mcp_reload(self.bus), + reset_credentials=reset, + ) + except Exception as exc: + return self._mcp_oauth_error_response(exc, action="start") + return self._json_response(payload) + + async def _handle_mcp_oauth_status(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + flow_id = (_query_first(self._query(request), "flow_id") or "").strip() + if not flow_id: + return self._error_response(400, "missing MCP OAuth flow ID") + try: + payload = await self._mcp_oauth.status(flow_id) + except Exception as exc: + return self._mcp_oauth_error_response(exc, action="status") + return self._json_response(payload) + + async def _handle_mcp_oauth_cancel(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + flow_id = (_query_first(self._query(request), "flow_id") or "").strip() + if not flow_id: + return self._error_response(400, "missing MCP OAuth flow ID") + try: + payload = await self._mcp_oauth.cancel(flow_id) + except Exception as exc: + return self._mcp_oauth_error_response(exc, action="cancel") + return self._json_response(payload) + + def _handle_mcp_oauth_callback(self, request: WsRequest) -> Response: + query = self._query(request) + state = (_query_first(query, "state") or "").strip() + if not state: + return self._mcp_oauth_callback_page( + ok=False, + message="This authorization request is missing its security state.", + status=400, + ) + try: + name = self._mcp_oauth.submit_callback( + state=state, + code=_query_first(query, "code"), + error=_query_first(query, "error"), + ) + except Exception as exc: + status = int(getattr(exc, "status", 400)) + message = str(getattr(exc, "message", "Could not complete MCP authorization")) + return self._mcp_oauth_callback_page(ok=False, message=message, status=status) + return self._mcp_oauth_callback_page( + ok=True, + message=f"Authorization received for {name}. Return to nanobot to finish connecting.", + ) + + def _mcp_oauth_error_response(self, exc: Exception, *, action: str) -> Response: + raw_status = getattr(exc, "status", 500) + status = raw_status if isinstance(raw_status, int) and 400 <= raw_status <= 599 else 500 + if status >= 500: + self.logger.exception("MCP OAuth '{}' failed", action) + message = f"MCP OAuth {action} failed" + else: + raw_message = getattr(exc, "message", None) + message = raw_message if isinstance(raw_message, str) else "MCP OAuth request failed" + return self._error_response(status, message) + + @staticmethod + def _mcp_oauth_callback_page( + *, + ok: bool, + message: str, + status: int = 200, + ) -> Response: + title = "Authorization received" if ok else "Connection failed" + safe_title = html.escape(title) + safe_message = html.escape(message) + close_script = "" if ok else "" + body = ( + "" + "" + f"{safe_title}
" + f"

{safe_title}

{safe_message}

{close_script}" + ).encode("utf-8") + return _http_response( + body, + status=status, + content_type="text/html; charset=utf-8", + extra_headers=[ + ("Cache-Control", "no-store"), + ("Referrer-Policy", "no-referrer"), + ( + "Content-Security-Policy", + "default-src 'none'; base-uri 'none'; form-action 'none'; " + "frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", + ), + ], + ) + async def _handle_settings_version_check(self, request: WsRequest) -> Response: if not self._authorized(request): return self._unauthorized() diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index a298a979bbc..681b342f57a 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -17,7 +17,7 @@ from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from urllib.parse import quote, unquote +from urllib.parse import quote, unquote, urlsplit, urlunsplit from loguru import logger from websockets.datastructures import Headers @@ -166,6 +166,8 @@ "settings.mcp.import": "/api/settings/mcp-presets/import", "settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor", "settings.mcp.tools": "/api/settings/mcp-presets/tools", + "settings.mcp.oauth_start": "/api/settings/mcp-oauth/start", + "settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel", } _WEBUI_CHANNEL_CONNECT_ACTIONS = { @@ -344,6 +346,7 @@ def __init__( runtime_capabilities=self._capabilities, channel_feature_action=channel_feature_action, channel_runtime_status=channel_runtime_status, + mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri, ) def workspace_controls_available(self, connection: Any) -> bool: @@ -617,6 +620,14 @@ def _bootstrap_ws_url(self, request: Any) -> str: expected_path = _normalize_config_path(self.config.path) return f"{scheme}://{host}{expected_path}" + def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str: + """Derive the browser callback from the same public origin as WebSocket bootstrap.""" + from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH + + public_ws_url = urlsplit(self._bootstrap_ws_url(request)) + scheme = "https" if public_ws_url.scheme == "wss" else "http" + return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", "")) + # -- Session routes ----------------------------------------------------- async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None: diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py new file mode 100644 index 00000000000..3a48b9a3f13 --- /dev/null +++ b/tests/tools/test_mcp_oauth.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from nanobot.agent.tools.mcp_oauth import ( + MCPAuthorizationRequiredError, + MCPOAuthHandlers, + MCPOAuthStorage, + create_mcp_oauth_auth, + delete_mcp_oauth_credentials, + mcp_oauth_has_credentials, +) +from nanobot.config.schema import MCPServerConfig + + +def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path) + + +def test_mcp_server_config_accepts_explicit_oauth() -> None: + config = MCPServerConfig.model_validate({ + "type": "streamableHttp", + "url": "https://mcp.example.com/mcp", + "auth": "oauth", + }) + + assert config.auth == "oauth" + assert config.model_dump(by_alias=True)["auth"] == "oauth" + + +@pytest.mark.asyncio +async def test_mcp_oauth_storage_isolates_name_and_server_url( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp") + tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret") + client_info = OAuthClientInformationFull( + redirect_uris=["https://agent.example/auth/mcp/callback"], + client_id="client-id", + client_secret="client-secret", + ) + + await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback") + await storage.set_tokens(tokens) + await storage.set_client_info(client_info) + + assert await storage.get_tokens() == tokens + assert await storage.get_client_info() == client_info + assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback" + assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp") + assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp") + assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp") + + payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8")) + assert "https://mcp.example.com/mcp" not in str(payload) + assert "access-secret" in str(payload) + + +@pytest.mark.asyncio +async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp") + await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback") + await storage.set_tokens(OAuthToken(access_token="access-secret")) + await storage.set_client_info(OAuthClientInformationFull( + redirect_uris=["https://old.example/auth/mcp/callback"], + client_id="old-client", + )) + + await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback") + + assert await storage.get_tokens() is not None + assert await storage.get_client_info() is None + + +@pytest.mark.asyncio +async def test_reset_and_delete_credentials_are_scoped_to_one_server( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + first = MCPOAuthStorage("first", "https://mcp.example.com/mcp") + second = MCPOAuthStorage("second", "https://mcp.example.com/mcp") + await first.set_tokens(OAuthToken(access_token="first-token")) + await second.set_tokens(OAuthToken(access_token="second-token")) + + await first.prepare_redirect_uri( + "https://agent.example/auth/mcp/callback", + reset=True, + ) + + assert await first.get_tokens() is None + assert await second.get_tokens() is not None + assert delete_mcp_oauth_credentials("first") + assert not delete_mcp_oauth_credentials("first") + assert await second.get_tokens() is not None + + +@pytest.mark.asyncio +async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + + async def redirect(_url: str) -> None: + return None + + async def callback() -> tuple[str, str | None]: + return "code", "state" + + handlers = MCPOAuthHandlers( + redirect_uri="https://agent.example/auth/mcp/callback", + redirect_handler=redirect, + callback_handler=callback, + ) + + auth = await create_mcp_oauth_auth( + "xmind", + "https://app.xmind.example/api/mcp", + handlers, + ) + + assert str(auth.context.client_metadata.redirect_uris[0]) == ( + "https://agent.example/auth/mcp/callback" + ) + assert auth.context.redirect_handler is redirect + assert auth.context.callback_handler is callback + storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp") + assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback" + + +@pytest.mark.asyncio +async def test_background_authorization_without_tokens_stops_locally( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + + with pytest.raises(MCPAuthorizationRequiredError): + await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp") + + assert not (tmp_path / "auth" / "mcp.json").exists() + + +@pytest.mark.asyncio +async def test_background_authorization_request_clears_rejected_token( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + server_url = "https://mcp.example.com/mcp" + storage = MCPOAuthStorage("notion", server_url) + client_info = OAuthClientInformationFull( + redirect_uris=["https://agent.example/auth/mcp/callback"], + client_id="registered-client", + ) + await storage.set_tokens(OAuthToken(access_token="rejected-token")) + await storage.set_client_info(client_info) + auth = await create_mcp_oauth_auth("notion", server_url) + + redirect_handler = auth.context.redirect_handler + assert redirect_handler is not None + with pytest.raises(MCPAuthorizationRequiredError): + await redirect_handler("https://accounts.example.com/authorize?state=state") + + assert await storage.get_tokens() is None + assert await storage.get_client_info() == client_info + + +@pytest.mark.asyncio +async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + server_url = "https://mcp.example.com/mcp" + authorization_url = "" + requests: list[tuple[str, str]] = [] + + async def redirect(url: str) -> None: + nonlocal authorization_url + authorization_url = url + + async def callback() -> tuple[str, str | None]: + state = parse_qs(urlsplit(authorization_url).query)["state"][0] + return "authorization-code", state + + auth = await create_mcp_oauth_auth( + "company-mcp", + server_url, + MCPOAuthHandlers( + redirect_uri="https://agent.example/auth/mcp/callback", + redirect_handler=redirect, + callback_handler=callback, + ), + ) + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append((request.method, str(request.url))) + if str(request.url) == server_url: + if request.headers.get("Authorization") == "Bearer access-token": + return httpx.Response(200, json={"ok": True}) + return httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer resource_metadata="https://mcp.example.com/' + '.well-known/oauth-protected-resource"' + ) + }, + ) + if request.url.path == "/.well-known/oauth-protected-resource": + return httpx.Response(200, json={ + "resource": server_url, + "authorization_servers": ["https://auth.example.com"], + }) + if request.url.path == "/.well-known/oauth-authorization-server": + return httpx.Response(200, json={ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }) + if request.url.path == "/register": + return httpx.Response(201, json={ + "client_id": "nanobot-client", + "redirect_uris": ["https://agent.example/auth/mcp/callback"], + "token_endpoint_auth_method": "none", + }) + if request.url.path == "/token": + return httpx.Response(200, json={ + "access_token": "access-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + return httpx.Response(404) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(respond), + auth=auth, + ) as client: + response = await client.get(server_url) + + assert response.status_code == 200 + assert urlsplit(authorization_url)._replace(query="").geturl() == ( + "https://auth.example.com/authorize" + ) + assert ("POST", "https://auth.example.com/register") in requests + assert ("POST", "https://auth.example.com/token") in requests + stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens() + assert stored is not None + assert stored.access_token == "access-token" + assert stored.refresh_token == "refresh-token" diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index e2c282ebd70..512f1a12bac 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -1210,6 +1210,129 @@ async def _capturing_streamable_http_client(_url: str, http_client=None): assert timeout.pool == 30.0 +@pytest.mark.parametrize("transport", ["sse", "streamableHttp"]) +@pytest.mark.asyncio +async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client( + transport: str, + fake_mcp_runtime: dict[str, object | None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_mcp_runtime["session"] = _make_fake_session(["demo"]) + oauth_auth = object() + oauth_handlers = object() + captured: dict[str, object] = {} + + async def _reachable(_url: str) -> bool: + return True + + def _validate(_url: str) -> tuple[bool, str]: + return True, "" + + async def _create_auth(name: str, url: str, handlers: object) -> object: + captured.update(name=name, url=url, handlers=handlers) + return oauth_auth + + oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth") + oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined] + oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod) + + class FakeAsyncClient: + def __init__(self, *args: object, **kwargs: object) -> None: + captured["client_kwargs"] = kwargs + + async def __aenter__(self) -> object: + return self + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: + return False + + @asynccontextmanager + async def _capturing_sse_client( + _url: str, + httpx_client_factory=None, + auth=None, + ): + captured["transport_auth"] = auth + yield object(), object() + + @asynccontextmanager + async def _capturing_streamable_http_client(_url: str, http_client=None): + assert http_client is not None + yield object(), object(), object() + + monkeypatch.setattr(mcp_mod, "validate_url_target", _validate) + monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable) + monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client) + monkeypatch.setattr( + sys.modules["mcp.client.streamable_http"], + "streamable_http_client", + _capturing_streamable_http_client, + ) + + url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp" + registry = ToolRegistry() + stacks = await connect_mcp_servers( + {"remote": MCPServerConfig(type=transport, url=url, auth="oauth")}, + registry, + oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type] + ) + for stack in stacks.values(): + await stack.aclose() + + assert captured["name"] == "remote" + assert captured["url"] == url + assert captured["handlers"] is oauth_handlers + if transport == "sse": + assert captured["transport_auth"] is oauth_auth + else: + client_kwargs = captured["client_kwargs"] + assert isinstance(client_kwargs, dict) + assert client_kwargs["auth"] is oauth_auth + assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]} + + +@pytest.mark.asyncio +async def test_connect_mcp_servers_skips_background_oauth_without_credentials( + fake_mcp_runtime: dict[str, object | None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + class AuthorizationRequiredError(RuntimeError): + pass + + async def _create_auth(*_args: object) -> object: + raise AuthorizationRequiredError + + probe_called = False + + async def _probe(_url: str) -> bool: + nonlocal probe_called + probe_called = True + 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] + 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", _probe) + + stacks = await connect_mcp_servers( + { + "remote": MCPServerConfig( + type="streamableHttp", + url="https://mcp.example.com/mcp", + auth="oauth", + ) + }, + ToolRegistry(), + ) + + assert stacks == {} + assert not probe_called + + @pytest.mark.asyncio async def test_connect_mcp_servers_wraps_windows_stdio_launchers( fake_mcp_runtime: dict[str, object | None], diff --git a/tests/webui/test_mcp_oauth_api.py b/tests/webui/test_mcp_oauth_api.py new file mode 100644 index 00000000000..20cc55b69fb --- /dev/null +++ b/tests/webui/test_mcp_oauth_api.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from nanobot.config.schema import MCPServerConfig +from nanobot.webui.mcp_oauth_api import ( + McpOAuthError, + McpOAuthManager, + validate_mcp_oauth_redirect_uri, +) + + +class _Connection: + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + +def _config() -> MCPServerConfig: + return MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.example.com/mcp", + ) + + +@pytest.mark.asyncio +async def test_browser_flow_forwards_callback_and_hot_reloads_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = McpOAuthManager() + connection = _Connection() + received: dict[str, object] = {} + reload_calls = 0 + + monkeypatch.setattr( + "nanobot.webui.mcp_oauth_api.validate_url_target", + lambda _url: (True, ""), + ) + + async def connect(servers, _registry, *, oauth_handlers): + assert set(servers) == {"xmind"} + handlers = oauth_handlers["xmind"] + await handlers.redirect_handler( + "https://accounts.example.com/authorize?client_id=test&state=state-123" + ) + received["callback"] = await handlers.callback_handler() + return {"xmind": connection} + + async def reload_mcp() -> dict[str, object]: + nonlocal reload_calls + reload_calls += 1 + return {"ok": True, "requires_restart": False} + + monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect) + + started = await manager.start( + "xmind", + _config(), + "https://agent.example.com/auth/mcp/callback", + reload_mcp=reload_mcp, + ) + + assert started["status"] == "authorization_required" + assert started["authorization_url"].startswith("https://accounts.example.com/authorize?") + manager.submit_callback(state="state-123", code="oauth-code", error=None) + with pytest.raises(McpOAuthError, match="expired"): + manager.submit_callback(state="state-123", code="replayed-code", error=None) + + for _ in range(10): + await asyncio.sleep(0) + if reload_calls == 1: + break + assert reload_calls == 1 + + for _ in range(10): + first, second = await asyncio.gather( + manager.status(started["flow_id"]), + manager.status(started["flow_id"]), + ) + if first["status"] == "connected": + break + await asyncio.sleep(0) + + assert first["status"] == "connected" + assert second["status"] == "connected" + assert received["callback"] == ("oauth-code", "state-123") + assert reload_calls == 1 + assert connection.closed is True + + +@pytest.mark.asyncio +async def test_browser_flow_surfaces_provider_denial_without_callback_description( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = McpOAuthManager() + monkeypatch.setattr( + "nanobot.webui.mcp_oauth_api.validate_url_target", + lambda _url: (True, ""), + ) + + async def connect(_servers, _registry, *, oauth_handlers): + handlers = oauth_handlers["notion"] + await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state") + await handlers.callback_handler() + return {} + + monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect) + started = await manager.start( + "notion", + _config(), + "https://agent.example.com/auth/mcp/callback", + reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}), + ) + + with pytest.raises(McpOAuthError, match="access_denied"): + manager.submit_callback(state="deny-state", code=None, error="access_denied") + for _ in range(10): + await asyncio.sleep(0) + result = await manager.status(started["flow_id"]) + if result["status"] == "failed": + break + + assert result["status"] == "failed" + assert result["error"] == "Authorization was not completed (access_denied)." + + +@pytest.mark.parametrize( + ("authorization_url", "url_is_safe", "state"), + [ + ("https://127.0.0.1/authorize?state=private-state", False, "private-state"), + ("http://accounts.example.com/authorize?state=http-state", True, "http-state"), + ], +) +@pytest.mark.asyncio +async def test_browser_flow_blocks_unsafe_authorization_url( + monkeypatch: pytest.MonkeyPatch, + authorization_url: str, + url_is_safe: bool, + state: str, +) -> None: + manager = McpOAuthManager() + monkeypatch.setattr( + "nanobot.webui.mcp_oauth_api.validate_url_target", + lambda _url: (url_is_safe, "private address"), + ) + + async def connect(_servers, _registry, *, oauth_handlers): + await oauth_handlers["linear"].redirect_handler(authorization_url) + return {} + + monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect) + + result = await manager.start( + "linear", + _config(), + "https://agent.example.com/auth/mcp/callback", + reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}), + ) + + assert result["status"] == "failed" + assert result["error"] == "The MCP server returned an unsafe authorization URL." + with pytest.raises(McpOAuthError, match="expired"): + manager.submit_callback(state=state, code="code", error=None) + + +def test_redirect_uri_requires_https_except_for_loopback() -> None: + assert validate_mcp_oauth_redirect_uri( + "https://agent.example.com/auth/mcp/callback" + ) == "https://agent.example.com/auth/mcp/callback" + assert validate_mcp_oauth_redirect_uri( + "http://127.0.0.1:8765/auth/mcp/callback" + ) == "http://127.0.0.1:8765/auth/mcp/callback" + + with pytest.raises(McpOAuthError, match="requires HTTPS"): + validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback") + with pytest.raises(McpOAuthError, match="Invalid"): + validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong") diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index e2a2a66830e..7f74d00f4e9 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -3,7 +3,9 @@ import asyncio import pytest +from mcp.shared.auth import OAuthToken +from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials from nanobot.config.loader import load_config from nanobot.webui.mcp_presets_api import ( McpPresetError, @@ -38,6 +40,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest "aws-docs", "brave-search", "postman", + "xmind", + "notion", + "linear", }.issubset(names) browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase") assert browserbase["installed"] is False @@ -55,6 +60,37 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest assert manifest["trust"]["review_status"] == "builtin_preset" +@pytest.mark.asyncio +async def test_oauth_preset_is_one_click_configured_after_token_storage( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + + payload = mcp_presets_action("enable", {"name": ["xmind"]}) + + row = next(item for item in payload["presets"] if item["name"] == "xmind") + assert row["installed"] is True + assert row["configured"] is False + assert row["status"] == "authorization_required" + assert row["transport"] == "streamableHttp" + assert row["auth"] == "oauth" + config = load_config() + cfg = config.tools.mcp_servers["xmind"] + assert cfg.type == "streamableHttp" + assert cfg.auth == "oauth" + assert cfg.url == "https://app.xmind.com/api/mcp" + + await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret")) + connected = mcp_presets_payload() + row = next(item for item in connected["presets"] if item["name"] == "xmind") + assert row["configured"] is True + assert row["status"] == "configured" + + mcp_presets_action("remove", {"name": ["xmind"]}) + assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None + + def test_enable_browserbase_writes_scrubbed_config_payload( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -296,11 +332,11 @@ async def fake_connect(_servers, _registry): assert "" in payload["last_action"]["error"] -def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _use_config(tmp_path, monkeypatch) with pytest.raises(McpPresetError) as exc: - mcp_presets_action("enable", {"name": ["linear"]}) + mcp_presets_action("enable", {"name": ["asana"]}) assert exc.value.status == 404 @@ -414,6 +450,72 @@ def test_import_mcp_config_and_tool_allowlist( assert load_config().tools.mcp_servers["docs"].enabled_tools == [] +def test_import_recognizes_known_and_explicit_oauth_servers( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + + payload = custom_mcp_action( + "import", + { + "config": [ + ( + '{"mcpServers":{' + '"notion-work":{"url":"https://mcp.notion.com/mcp"},' + '"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},' + '"notion-pat":{"url":"https://mcp.notion.com/mcp",' + '"headers":{"Authorization":"Bearer secret"}}' + '}}' + ) + ], + }, + ) + + config = load_config() + assert config.tools.mcp_servers["notion-work"].auth == "oauth" + assert config.tools.mcp_servers["company-mcp"].auth == "oauth" + assert config.tools.mcp_servers["notion-pat"].auth is None + rows = {row["name"]: row for row in payload["presets"]} + assert rows["notion-work"]["status"] == "authorization_required" + assert rows["company-mcp"]["status"] == "authorization_required" + assert rows["notion-pat"]["status"] == "configured" + assert "Bearer secret" not in str(payload) + + +@pytest.mark.asyncio +async def test_replacing_oauth_config_removes_its_stored_credentials( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + server_url = "https://mcp.example.com/mcp" + custom_mcp_action( + "custom", + { + "name": ["company-mcp"], + "transport": ["streamableHttp"], + "url": [server_url], + "auth": ["oauth"], + }, + ) + await MCPOAuthStorage("company-mcp", server_url).set_tokens( + OAuthToken(access_token="secret") + ) + assert mcp_oauth_has_credentials("company-mcp", server_url) + + custom_mcp_action( + "custom", + { + "name": ["company-mcp"], + "transport": ["streamableHttp"], + "url": [server_url], + }, + ) + + assert not mcp_oauth_has_credentials("company-mcp", server_url) + + def test_normalize_mcp_preset_mentions_accepts_configured_custom_server( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index f0a469f143e..effeec53bbe 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -2,7 +2,7 @@ import json from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock from urllib.parse import parse_qs, urlsplit import pytest @@ -28,6 +28,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter: ), runtime_surface="browser", runtime_capabilities={}, + mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback", ) @@ -39,6 +40,82 @@ def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace: return request +@pytest.mark.asyncio +async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: + config = SimpleNamespace( + type="streamableHttp", + auth="oauth", + url="https://app.xmind.com/api/mcp", + ) + monkeypatch.setattr( + "nanobot.webui.settings_routes.ensure_mcp_oauth_server", + lambda _query, *, config_path=None: ("xmind", config), + ) + router = _router() + start = AsyncMock(return_value={ + "status": "authorization_required", + "flow_id": "flow-123", + "name": "xmind", + "authorization_url": "https://xmind.example/authorize?state=state-123", + }) + router._mcp_oauth = SimpleNamespace(start=start) + request = _mutation_request( + "/api/settings/mcp-oauth/start", + {"name": "xmind"}, + ) + + response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start") + + assert response is not None + assert response.status_code == 200 + assert json.loads(response.body)["flow_id"] == "flow-123" + start.assert_awaited_once_with( + "xmind", + config, + "https://gateway.example/auth/mcp/callback", + reload_mcp=ANY, + reset_credentials=False, + ) + + denied = _router(authorized=False) + denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start") + assert denied_response is not None + assert denied_response.status_code == 401 + + failed = _router() + failed._mcp_oauth = SimpleNamespace( + start=AsyncMock(side_effect=RuntimeError("upstream secret response")) + ) + failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start") + assert failed_response is not None + assert failed_response.status_code == 500 + assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"} + assert b"upstream secret response" not in failed_response.body + + +@pytest.mark.asyncio +async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None: + router = _router(authorized=False) + submit = MagicMock(return_value="xmind") + router._mcp_oauth = SimpleNamespace(submit_callback=submit) + request = SimpleNamespace( + path="/auth/mcp/callback?code=oauth-code&state=state-123", + headers=Headers(), + ) + + response = await router.dispatch(None, request, "/auth/mcp/callback") + + assert response is not None + assert response.status_code == 200 + assert response.headers["Content-Type"] == "text/html; charset=utf-8" + assert response.headers["Cache-Control"] == "no-store" + assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] + assert b"window.close" in response.body + assert b"Authorization received" in response.body + assert b"oauth-code" not in response.body + submit.assert_called_once_with(state="state-123", code="oauth-code", error=None) + + @pytest.mark.parametrize( ("provider", "authorization_response"), [ diff --git a/tests/webui/test_ws_http_oauth.py b/tests/webui/test_ws_http_oauth.py new file mode 100644 index 00000000000..df0a2b27cc0 --- /dev/null +++ b/tests/webui/test_ws_http_oauth.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast + +from websockets.datastructures import Headers +from websockets.http11 import Request as WsRequest + +from nanobot.channels.websocket.runtime import WebSocketConfig +from nanobot.webui.ws_http import GatewayHTTPHandler + + +def _handler(config: WebSocketConfig) -> GatewayHTTPHandler: + handler = object.__new__(GatewayHTTPHandler) + handler.config = config + return handler + + +def _request(**headers: str) -> WsRequest: + return cast(WsRequest, SimpleNamespace(headers=Headers(headers))) + + +def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None: + handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws")) + + redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example")) + + assert redirect_uri == "https://agent.example/auth/mcp/callback" + + +def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None: + handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765)) + + redirect_uri = handler._mcp_oauth_redirect_uri( + _request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"}) + ) + + assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback" diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 793bfec9b48..9213baafa15 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -113,6 +113,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { Textarea } from "@/components/ui/textarea"; import { isLoopbackHost } from "@/lib/network"; import { + cancelMcpOAuth, checkVersion, completeProviderOAuth, createModelConfiguration, @@ -126,6 +127,7 @@ import { fetchSettingsUsage, fetchCliApps, fetchMcpPresets, + fetchMcpOAuthStatus, fetchNanobotFeatures, fetchProviderModels, importMcpConfig, @@ -136,6 +138,7 @@ import { runCliAppAction, runMcpPresetAction, saveCustomMcpServer, + startMcpOAuth, startApiService, stopApiService, updateAutomation, @@ -181,6 +184,7 @@ import type { CliAppsPayload, ImageGenerationSettingsUpdate, McpPresetInfo, + McpOAuthFlowPayload, McpPresetsPayload, NanobotFeatureInfo, NanobotFeaturesPayload, @@ -751,6 +755,11 @@ export function SettingsView({ const [nanobotFeatureAction, setNanobotFeatureAction] = useState(null); const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState(null); const [mcpPresetAction, setMcpPresetAction] = useState(null); + const [mcpOAuthFlow, setMcpOAuthFlow] = useState(null); + const mcpOAuthFlowRef = useRef(null); + const mcpOAuthPopupRef = useRef(null); + const mcpOAuthNavigatedUrlRef = useRef(null); + const [mcpOAuthPopupBlocked, setMcpOAuthPopupBlocked] = useState(false); const [providerSaving, setProviderSaving] = useState(null); const [providerOAuthFlow, setProviderOAuthFlow] = useState(null); @@ -1986,6 +1995,210 @@ export function SettingsView({ } }; + const closeMcpOAuthPopup = () => { + const popup = mcpOAuthPopupRef.current; + mcpOAuthPopupRef.current = null; + mcpOAuthNavigatedUrlRef.current = null; + if (!popup) return; + try { + if (!popup.closed) popup.close(); + } catch { + // The authorization page may have navigated cross-origin before it closed itself. + } + }; + + const openMcpOAuthPopup = (authorizationUrl?: string): Window | null => { + let popup: Window | null = null; + try { + popup = window.open( + authorizationUrl ?? "about:blank", + "nanobot-mcp-oauth", + "popup,width=560,height=720,resizable=yes,scrollbars=yes", + ); + if (popup) { + mcpOAuthPopupRef.current = popup; + mcpOAuthNavigatedUrlRef.current = authorizationUrl ?? null; + if (!authorizationUrl) { + try { + popup.document.title = t("settings.oauth.signingIn", { defaultValue: "Preparing sign-in…" }); + popup.document.body.textContent = t("settings.mcp.preparingSignIn", { + defaultValue: "Preparing secure sign-in…", + }); + } catch { + // about:blank can become unavailable if the window is reused mid-navigation. + } + } + try { + popup.opener = null; + popup.focus(); + } catch { + // A cross-origin authorization page can restrict window access. + } + } + } catch { + // Browsers can reject popup creation before returning a window handle. + } + setMcpOAuthPopupBlocked(!popup); + return popup; + }; + + const navigateMcpOAuthPopup = (flow: McpOAuthFlowPayload) => { + const authorizationUrl = flow.authorization_url; + if (!authorizationUrl) return; + const popup = mcpOAuthPopupRef.current; + // OAuth pages can use Cross-Origin-Opener-Policy, which severs the + // WindowProxy and makes an open tab appear closed. Once navigation was + // requested, do not mistake that browser isolation for a blocked popup. + if (popup && mcpOAuthNavigatedUrlRef.current === authorizationUrl) return; + try { + if (popup && !popup.closed) { + popup.location.replace(authorizationUrl); + mcpOAuthNavigatedUrlRef.current = authorizationUrl; + popup.focus(); + setMcpOAuthPopupBlocked(false); + return; + } + if (popup) return; + } catch { + // Fall through to the explicit Continue in browser action. + } + setMcpOAuthPopupBlocked(true); + }; + + const finishMcpOAuthFlow = async (flow: McpOAuthFlowPayload) => { + if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; + closeMcpOAuthPopup(); + mcpOAuthFlowRef.current = null; + setMcpOAuthFlow(null); + setMcpPresetAction(null); + + if (flow.status === "connected") { + try { + const payload = await fetchMcpPresets(getToken()); + setMcpPresets(payload); + notifyMcpPresetsChanged(payload); + setMcpMessage(null); + setMcpError(null); + } catch (err) { + setMcpError((err as Error).message); + } + return; + } + + if (flow.status === "authorized" && flow.hot_reload) { + if (flow.hot_reload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + setMcpError( + flow.hot_reload.message + || t("settings.mcp.reloadFailed", { + defaultValue: "Signed in, but nanobot could not connect the tools. Try restarting nanobot.", + }), + ); + return; + } + + if (flow.status === "failed") { + setMcpError( + flow.error + || t("settings.mcp.oauthFailed", { + defaultValue: "Unable to connect. Try signing in again.", + }), + ); + } + }; + + const monitorMcpOAuthFlow = async (initial: McpOAuthFlowPayload) => { + let current = initial; + while (mcpOAuthFlowRef.current?.flow_id === current.flow_id) { + navigateMcpOAuthPopup(current); + const terminal = + current.status === "connected" + || current.status === "failed" + || current.status === "cancelled" + || (current.status === "authorized" && Boolean(current.hot_reload)); + if (terminal) { + await finishMcpOAuthFlow(current); + return; + } + + await new Promise((resolve) => window.setTimeout(resolve, 800)); + if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; + try { + current = await fetchMcpOAuthStatus(getToken(), current.flow_id); + if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; + mcpOAuthFlowRef.current = current; + setMcpOAuthFlow(current); + } catch (err) { + if (mcpOAuthFlowRef.current?.flow_id !== current.flow_id) return; + closeMcpOAuthPopup(); + mcpOAuthFlowRef.current = null; + setMcpOAuthFlow(null); + setMcpPresetAction(null); + setMcpError((err as Error).message); + return; + } + } + }; + + const handleMcpOAuthConnect = async (name: string) => { + openMcpOAuthPopup(); + const key = `oauth:${name}`; + setMcpPresetAction(key); + setMcpMessage(null); + setMcpError(null); + try { + const flow = await startMcpOAuth(client, name); + mcpOAuthFlowRef.current = flow; + setMcpOAuthFlow(flow); + navigateMcpOAuthPopup(flow); + void monitorMcpOAuthFlow(flow); + } catch (err) { + closeMcpOAuthPopup(); + mcpOAuthFlowRef.current = null; + setMcpOAuthFlow(null); + setMcpPresetAction(null); + setMcpError((err as Error).message); + } + }; + + const handleMcpOAuthCancel = async () => { + const flow = mcpOAuthFlowRef.current; + if (!flow) return; + mcpOAuthFlowRef.current = null; + setMcpOAuthFlow(null); + setMcpPresetAction(null); + closeMcpOAuthPopup(); + try { + await cancelMcpOAuth(client, flow.flow_id); + } catch (err) { + setMcpError((err as Error).message); + } + }; + + const handleMcpOAuthOpen = () => { + const authorizationUrl = mcpOAuthFlowRef.current?.authorization_url; + if (!authorizationUrl) return; + openMcpOAuthPopup(authorizationUrl); + }; + + const applyMcpActionFeedback = ( + payload: McpPresetsPayload, + announceSuccess = false, + ) => { + const actionError = payload.last_action?.ok === false + ? payload.last_action.error || payload.last_action.message + : payload.hot_reload?.ok === false + ? payload.hot_reload.message + : null; + setMcpError(actionError || null); + setMcpMessage( + actionError || !announceSuccess + ? null + : payload.last_action?.message ?? null, + ); + }; + const handleMcpPresetAction = async ( action: "enable" | "remove" | "test", name: string, @@ -1998,7 +2211,7 @@ export function SettingsView({ try { const payload = await runMcpPresetAction(client, action, name, values); setMcpPresets(payload); - setMcpMessage(payload.last_action?.message ?? null); + applyMcpActionFeedback(payload, action === "test"); if (action !== "test") { notifyMcpPresetsChanged(payload); } @@ -2034,7 +2247,7 @@ export function SettingsView({ tool_timeout: customMcpForm.toolTimeout, }); setMcpPresets(payload); - setMcpMessage(payload.last_action?.message ?? null); + applyMcpActionFeedback(payload); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -2055,7 +2268,7 @@ export function SettingsView({ try { const payload = await importMcpConfig(client, mcpConfigImport); setMcpPresets(payload); - setMcpMessage(payload.last_action?.message ?? null); + applyMcpActionFeedback(payload); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -2076,7 +2289,7 @@ export function SettingsView({ try { const payload = await updateMcpServerTools(client, name, enabledTools); setMcpPresets(payload); - setMcpMessage(payload.last_action?.message ?? null); + applyMcpActionFeedback(payload); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -2272,6 +2485,8 @@ export function SettingsView({ filter={appsKindFilter} cliActionKey={cliAppsAction} mcpActionKey={mcpPresetAction} + mcpOAuthFlow={mcpOAuthFlow} + mcpOAuthPopupBlocked={mcpOAuthPopupBlocked} cliMessage={cliAppsMessage} cliError={cliAppsError} cliFocusName={cliAppsFocusName} @@ -2286,6 +2501,9 @@ export function SettingsView({ onFilterChange={setAppsKindFilter} onCliAction={handleCliAppAction} onMcpAction={handleMcpPresetAction} + onMcpOAuthConnect={handleMcpOAuthConnect} + onMcpOAuthCancel={() => void handleMcpOAuthCancel()} + onMcpOAuthOpen={handleMcpOAuthOpen} onDismissStatus={() => { setCliAppsMessage(null); setCliAppsError(null); @@ -7335,6 +7553,8 @@ function AppsCatalogSettings({ filter, cliActionKey, mcpActionKey, + mcpOAuthFlow, + mcpOAuthPopupBlocked, cliMessage, cliError, cliFocusName, @@ -7349,6 +7569,9 @@ function AppsCatalogSettings({ onFilterChange, onCliAction, onMcpAction, + onMcpOAuthConnect, + onMcpOAuthCancel, + onMcpOAuthOpen, onDismissStatus, onBackToChat, onMcpFieldChange, @@ -7368,6 +7591,8 @@ function AppsCatalogSettings({ filter: AppsKindFilter; cliActionKey: string | null; mcpActionKey: string | null; + mcpOAuthFlow: McpOAuthFlowPayload | null; + mcpOAuthPopupBlocked: boolean; cliMessage: string | null; cliError: string | null; cliFocusName: string | null; @@ -7382,6 +7607,9 @@ function AppsCatalogSettings({ onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onMcpOAuthConnect: (name: string) => void; + onMcpOAuthCancel: () => void; + onMcpOAuthOpen: () => void; onDismissStatus: () => void; onBackToChat: () => void; onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void; @@ -7398,7 +7626,7 @@ function AppsCatalogSettings({ const filterOptions = [ { value: "ready", label: tx("settings.apps.filterAll", "Ready") }, { value: "cli", label: tx("settings.apps.filterCli", "Apps") }, - { value: "mcp", label: tx("settings.apps.filterMcp", "Integrations") }, + { value: "mcp", label: tx("settings.apps.filterMcp", "MCP") }, ]; const normalizedQuery = query.trim().toLowerCase(); const items: AppsCatalogItem[] = [ @@ -7430,7 +7658,7 @@ function AppsCatalogSettings({ : filter === "cli" ? tx("settings.apps.emptyApps", "No apps available.") : filter === "mcp" - ? tx("settings.apps.emptyIntegrations", "No integrations available.") + ? tx("settings.apps.emptyIntegrations", "No MCP tools available.") : tx("settings.apps.emptyReady", "No tools are ready yet."); const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery ? null @@ -7446,8 +7674,12 @@ function AppsCatalogSettings({ mcpError || (!focusedApp ? cliMessage || mcpMessage : null); const statusIsError = Boolean(cliError || mcpError); + const oauthStatusAnnouncement = mcpOAuthFlow + ? mcpOAuthStatusText(mcpOAuthFlow.status, mcpOAuthPopupBlocked, tx) + : ""; return (
+
{oauthStatusAnnouncement}
@@ -7484,7 +7716,7 @@ function AppsCatalogSettings({ {requiresRestartPending ? ( @@ -7492,7 +7724,11 @@ function AppsCatalogSettings({
- {tx("settings.apps.featured", "Tools")} + + {filter === "mcp" + ? tx("settings.apps.mcpTools", "MCP tools") + : tx("settings.apps.featured", "Tools")} + {items.length} @@ -7519,9 +7755,14 @@ function AppsCatalogSettings({ preset={item.preset} values={mcpFieldValues[item.preset.name] ?? {}} actionKey={mcpActionKey} + oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null} + oauthPopupBlocked={mcpOAuthPopupBlocked} showBrandLogos={showBrandLogos} onFieldChange={onMcpFieldChange} onAction={onMcpAction} + onOAuthConnect={onMcpOAuthConnect} + onOAuthCancel={onMcpOAuthCancel} + onOAuthOpen={onMcpOAuthOpen} onToolsChange={onMcpToolsChange} /> ), @@ -7548,13 +7789,13 @@ function AppsCatalogSettings({ > {emptyBrowseTarget === "cli" ? tx("settings.apps.browseApps", "Browse apps") - : tx("settings.apps.browseIntegrations", "Browse integrations")} + : tx("settings.apps.browseIntegrations", "Browse MCP tools")} ) : (

{tx( "settings.apps.emptyIntegrationsHint", - "Add a custom integration below.", + "Add a custom MCP server below.", )}

)} @@ -7672,17 +7913,27 @@ function McpAppsCatalogRow({ preset, values, actionKey, + oauthFlow, + oauthPopupBlocked, showBrandLogos, onFieldChange, onAction, + onOAuthConnect, + onOAuthCancel, + onOAuthOpen, onToolsChange, }: { preset: McpPresetInfo; values: Record; actionKey: string | null; + oauthFlow: McpOAuthFlowPayload | null; + oauthPopupBlocked: boolean; showBrandLogos: boolean; onFieldChange: (presetName: string, fieldName: string, value: string) => void; onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onOAuthConnect: (name: string) => void; + onOAuthCancel: () => void; + onOAuthOpen: () => void; onToolsChange: (name: string, enabledTools: string[]) => void; }) { const { t } = useTranslation(); @@ -7693,7 +7944,10 @@ function McpAppsCatalogRow({ const removeBusy = actionKey === `remove:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; - const busy = enableBusy || removeBusy || testBusy || toolsBusy; + const oauthBusy = actionKey === `oauth:${preset.name}`; + const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; + const busy = enableBusy || removeBusy || testBusy || toolsBusy || oauthBusy; + 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; @@ -7706,13 +7960,16 @@ function McpAppsCatalogRow({ const allowAllTools = enabledTools.includes("*"); const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); const description = preset.description || preset.note || preset.requires || preset.name; - const statusLabel = mcpPresetStatusLabel(preset.status, tx); useEffect(() => { if (preset.configured || !preset.install_supported) setSetupOpen(false); }, [preset.configured, preset.install_supported]); const enableOrOpenSetup = () => { + if (isOAuth) { + onOAuthConnect(preset.name); + return; + } if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) { setSetupOpen(true); return; @@ -7739,7 +7996,7 @@ function McpAppsCatalogRow({

{preset.display_name}

- {tx("settings.apps.mcpLabel", "Integration")} + {tx("settings.apps.mcpLabel", "MCP")}

{description}

@@ -7749,7 +8006,11 @@ function McpAppsCatalogRow({ + ) : oauthFlow ? ( + + ) : isOAuth && preset.install_supported ? ( + onOAuthConnect(preset.name)} + /> ) : preset.installed && !preset.configured ? ( { if (hasFields) setSetupOpen(true); else onAction("enable", preset.name, values); }} - > - - + /> ) : preset.install_supported ? ( - - + /> ) : ( - - - + )}
+ {oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? ( +
+
+ {mcpOAuthStatusText(oauthFlow.status, oauthPopupBlocked, tx)} +
+
+ + +
+
+ ) : null} + {setupOpen && preset.install_supported && hasFields ? (
@@ -7952,13 +8265,15 @@ function AppsTypeBadge({ children }: { children: ReactNode }) { const AppsActionButton = forwardRef void; - children: ReactNode; + children?: ReactNode; }>(function AppsActionButton({ ariaLabel, + visibleLabel, busy, disabled, tone = "default", @@ -7969,20 +8284,24 @@ const AppsActionButton = forwardRef - {busy ? : children} + {busy ? : children} + {visibleLabel ? {visibleLabel} : null} ); }); @@ -8068,12 +8387,12 @@ function McpCustomServerPanel({

- {tx("settings.mcp.moreOptions", "Add integration")} + {tx("settings.mcp.moreOptions", "Add MCP server")}

{tx( "settings.mcp.moreOptionsSubtitle", - "Connect a custom tool server or import an existing configuration.", + "Connect a custom MCP server or import an existing configuration.", )}

@@ -8264,18 +8583,28 @@ function McpCustomServerPanel({ ); } -function mcpPresetStatusLabel(status: string, tx: (key: string, fallback: string) => string): string { +function mcpOAuthStatusText( + status: McpOAuthFlowPayload["status"], + popupBlocked: boolean, + tx: (key: string, fallback: string) => string, +): string { switch (status) { - case "configured": - return tx("settings.mcp.statusConfigured", "Configured"); - case "missing_credentials": - return tx("settings.mcp.statusMissingCredentials", "Needs key"); - case "missing_dependency": - return tx("settings.mcp.statusMissingDependency", "Needs dependency"); - case "coming_soon": - return tx("settings.mcp.statusComingSoon", "Coming soon"); - default: - return tx("settings.mcp.statusNotInstalled", "Not enabled"); + case "starting": + return tx("settings.mcp.preparingSignIn", "Preparing secure sign-in..."); + case "authorization_required": + return popupBlocked + ? tx("settings.mcp.openSignInToContinue", "Open the sign-in page to continue.") + : tx("settings.mcp.finishSignInInBrowser", "Finish signing in in the browser window."); + case "connecting": + return tx("settings.mcp.finishingConnection", "Finishing connection..."); + case "authorized": + return tx("settings.mcp.activatingTools", "Activating tools..."); + case "connected": + return tx("settings.mcp.connected", "Connected."); + case "failed": + return tx("settings.mcp.connectionFailed", "Connection failed."); + case "cancelled": + return tx("settings.mcp.connectionCancelled", "Connection cancelled."); } } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index b0b5637a0b1..01f44d35863 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -319,8 +319,8 @@ "filterInstalled": "Enabled", "filterNotInstalled": "Not enabled", "searchPlaceholder": "Search MCP presets", - "moreOptions": "Add integration", - "moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.", + "moreOptions": "Add MCP server", + "moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.", "customTitle": "Custom MCP", "customSubtitle": "Add any stdio, HTTP, or SSE MCP server.", "customAction": "Custom", @@ -357,6 +357,21 @@ "keepExisting": "Leave blank to keep existing", "statusConfigured": "Configured", "statusMissingCredentials": "Needs key", + "connectingAccount": "Connecting {{name}}", + "connectingLabel": "Connecting…", + "continueSignIn": "Continue sign-in", + "preparingSignIn": "Preparing secure sign-in…", + "openSignInToContinue": "Open the sign-in page to continue.", + "finishSignInInBrowser": "Finish signing in in the browser window.", + "finishingConnection": "Finishing connection…", + "activatingTools": "Activating tools…", + "connected": "Connected.", + "connectedLabel": "Connected", + "connectedAccount": "{{name}} connected.", + "connectionFailed": "Connection failed.", + "connectionCancelled": "Connection cancelled.", + "reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.", + "oauthFailed": "Unable to connect. Try signing in again.", "statusMissingDependency": "Needs dependency", "statusComingSoon": "Coming soon", "comingSoon": "Coming soon", @@ -584,27 +599,28 @@ "apps": { "description": "Add tools to nanobot, then @ them in chat.", "cliLabel": "App", - "mcpLabel": "Integration", + "mcpLabel": "MCP", "channelLabel": "Channel", "featureLabel": "Feature", "filterAll": "Ready", "filterPlugins": "Plugins", "filterCli": "Apps", - "filterMcp": "Integrations", + "filterMcp": "MCP", "enabledSummary": "{{count}} ready", - "caption": "{{cli}} apps · {{mcp}} integrations", + "caption": "{{cli}} apps · {{mcp}} MCP tools", "searchPlaceholder": "Search tools", "featured": "Tools", + "mcpTools": "MCP tools", "loading": "Loading Apps...", "empty": "No tools match your search.", "emptyApps": "No apps available.", - "emptyIntegrations": "No integrations available.", + "emptyIntegrations": "No MCP tools available.", "emptyReady": "No tools are ready yet.", "clearSearch": "Clear search", "browseApps": "Browse apps", - "browseIntegrations": "Browse integrations", - "emptyIntegrationsHint": "Add a custom integration below.", - "restartRequired": "Restart nanobot to apply updated apps and integrations." + "browseIntegrations": "Browse MCP tools", + "emptyIntegrationsHint": "Add a custom MCP server below.", + "restartRequired": "Restart nanobot to apply updated apps and MCP tools." }, "channels": { "description": "Connect chat apps, email, and WebUI to nanobot.", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index ae2e9fb3ebd..9fe96308632 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -504,8 +504,8 @@ "filterInstalled": "Habilitados", "filterNotInstalled": "No habilitados", "searchPlaceholder": "Buscar preajustes MCP", - "moreOptions": "Más opciones de MCP", - "moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.", + "moreOptions": "Añadir servidor MCP", + "moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.", "customTitle": "MCP personalizado", "customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.", "customAction": "Personalizado", @@ -542,6 +542,21 @@ "keepExisting": "Déjalo en blanco para conservar el valor actual", "statusConfigured": "Configurado", "statusMissingCredentials": "Necesita clave", + "connectingAccount": "Conectando {{name}}", + "connectingLabel": "Conectando…", + "continueSignIn": "Continuar inicio de sesión", + "preparingSignIn": "Preparando un inicio de sesión seguro…", + "openSignInToContinue": "Abre la página de inicio de sesión para continuar.", + "finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.", + "finishingConnection": "Finalizando la conexión…", + "activatingTools": "Activando herramientas…", + "connected": "Conectado.", + "connectedLabel": "Conectado", + "connectedAccount": "{{name}} está conectado.", + "connectionFailed": "Error de conexión.", + "connectionCancelled": "Conexión cancelada.", + "reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.", + "oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.", "statusMissingDependency": "Necesita dependencia", "statusComingSoon": "Próximamente", "comingSoon": "Próximamente", @@ -571,27 +586,28 @@ "apps": { "description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.", "cliLabel": "Aplicación", - "mcpLabel": "Integración", + "mcpLabel": "MCP", "channelLabel": "Canal", "featureLabel": "Función", "filterAll": "Listo", "filterPlugins": "Complementos", "filterCli": "Aplicaciones", - "filterMcp": "Integraciones", + "filterMcp": "MCP", "enabledSummary": "{{count}} listos", - "caption": "{{cli}} aplicaciones · {{mcp}} integraciones", + "caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP", "searchPlaceholder": "Buscar aplicaciones", "featured": "Herramientas", + "mcpTools": "Herramientas MCP", "loading": "Cargando aplicaciones...", "empty": "Ninguna herramienta coincide con tu búsqueda.", "emptyApps": "No hay aplicaciones disponibles.", - "emptyIntegrations": "No hay integraciones disponibles.", + "emptyIntegrations": "No hay herramientas MCP disponibles.", "emptyReady": "Todavía no hay herramientas listas.", "clearSearch": "Borrar búsqueda", "browseApps": "Explorar aplicaciones", - "browseIntegrations": "Explorar integraciones", - "emptyIntegrationsHint": "Añade una integración personalizada abajo.", - "restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas." + "browseIntegrations": "Explorar herramientas MCP", + "emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.", + "restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas." }, "channels": { "description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index aa32089c664..c841eebc5b4 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -504,8 +504,8 @@ "filterInstalled": "Activés", "filterNotInstalled": "Non activés", "searchPlaceholder": "Rechercher des préréglages MCP", - "moreOptions": "Plus d'options MCP", - "moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.", + "moreOptions": "Ajouter un serveur MCP", + "moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.", "customTitle": "MCP personnalisé", "customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.", "customAction": "Personnalisé", @@ -542,6 +542,21 @@ "keepExisting": "Laissez vide pour conserver la valeur actuelle", "statusConfigured": "Configuré", "statusMissingCredentials": "Clé requise", + "connectingAccount": "Connexion à {{name}}", + "connectingLabel": "Connexion…", + "continueSignIn": "Continuer la connexion", + "preparingSignIn": "Préparation d’une connexion sécurisée…", + "openSignInToContinue": "Ouvrez la page de connexion pour continuer.", + "finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.", + "finishingConnection": "Finalisation de la connexion…", + "activatingTools": "Activation des outils…", + "connected": "Connecté.", + "connectedLabel": "Connecté", + "connectedAccount": "{{name}} est connecté.", + "connectionFailed": "Échec de la connexion.", + "connectionCancelled": "Connexion annulée.", + "reloadFailed": "Connexion réussie, mais nanobot n’a pas pu activer les outils. Essayez de redémarrer nanobot.", + "oauthFailed": "Connexion impossible. Essayez de vous reconnecter.", "statusMissingDependency": "Dépendance requise", "statusComingSoon": "Bientôt disponible", "comingSoon": "Bientôt disponible", @@ -570,27 +585,28 @@ "apps": { "description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.", "cliLabel": "Application", - "mcpLabel": "Intégration", + "mcpLabel": "MCP", "channelLabel": "Canal", "featureLabel": "Fonction", "filterAll": "Prêts", "filterPlugins": "Extensions", "filterCli": "Applications", - "filterMcp": "Intégrations", + "filterMcp": "MCP", "enabledSummary": "{{count}} prêts", - "caption": "{{cli}} applications · {{mcp}} intégrations", + "caption": "{{cli}} applications · {{mcp}} outils MCP", "searchPlaceholder": "Rechercher des applications", "featured": "Outils", + "mcpTools": "Outils MCP", "loading": "Chargement des applications...", "empty": "Aucun outil ne correspond à votre recherche.", "emptyApps": "Aucune application disponible.", - "emptyIntegrations": "Aucune intégration disponible.", + "emptyIntegrations": "Aucun outil MCP disponible.", "emptyReady": "Aucun outil n’est encore prêt.", "clearSearch": "Effacer la recherche", "browseApps": "Parcourir les applications", - "browseIntegrations": "Parcourir les intégrations", - "emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.", - "restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour." + "browseIntegrations": "Parcourir les outils MCP", + "emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.", + "restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour." }, "channels": { "description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 81326edf907..99568a96d9d 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -504,8 +504,8 @@ "filterInstalled": "Aktif", "filterNotInstalled": "Tidak aktif", "searchPlaceholder": "Cari prasetel MCP", - "moreOptions": "Opsi MCP lainnya", - "moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.", + "moreOptions": "Tambahkan server MCP", + "moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.", "customTitle": "MCP khusus", "customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.", "customAction": "Khusus", @@ -542,6 +542,21 @@ "keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini", "statusConfigured": "Terkonfigurasi", "statusMissingCredentials": "Butuh kunci", + "connectingAccount": "Menghubungkan {{name}}", + "connectingLabel": "Menghubungkan…", + "continueSignIn": "Lanjutkan masuk", + "preparingSignIn": "Menyiapkan proses masuk yang aman…", + "openSignInToContinue": "Buka halaman masuk untuk melanjutkan.", + "finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.", + "finishingConnection": "Menyelesaikan koneksi…", + "activatingTools": "Mengaktifkan alat…", + "connected": "Terhubung.", + "connectedLabel": "Terhubung", + "connectedAccount": "{{name}} terhubung.", + "connectionFailed": "Koneksi gagal.", + "connectionCancelled": "Koneksi dibatalkan.", + "reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.", + "oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.", "statusMissingDependency": "Butuh dependensi", "statusComingSoon": "Segera hadir", "comingSoon": "Segera hadir", @@ -570,27 +585,28 @@ "apps": { "description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.", "cliLabel": "Aplikasi", - "mcpLabel": "Integrasi", + "mcpLabel": "MCP", "channelLabel": "Kanal", "featureLabel": "Fitur", "filterAll": "Siap", "filterPlugins": "Plugin", "filterCli": "Aplikasi", - "filterMcp": "Integrasi", + "filterMcp": "MCP", "enabledSummary": "{{count}} siap", - "caption": "{{cli}} aplikasi · {{mcp}} integrasi", + "caption": "{{cli}} aplikasi · {{mcp}} alat MCP", "searchPlaceholder": "Cari aplikasi", "featured": "Alat", + "mcpTools": "Alat MCP", "loading": "Memuat aplikasi...", "empty": "Tidak ada alat yang cocok dengan pencarian Anda.", "emptyApps": "Tidak ada aplikasi yang tersedia.", - "emptyIntegrations": "Tidak ada integrasi yang tersedia.", + "emptyIntegrations": "Tidak ada alat MCP yang tersedia.", "emptyReady": "Belum ada alat yang siap.", "clearSearch": "Hapus pencarian", "browseApps": "Jelajahi aplikasi", - "browseIntegrations": "Jelajahi integrasi", - "emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.", - "restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui." + "browseIntegrations": "Jelajahi alat MCP", + "emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.", + "restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui." }, "channels": { "description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 08fe99bd71c..d7f9283bc2b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -504,8 +504,8 @@ "filterInstalled": "有効", "filterNotInstalled": "未有効", "searchPlaceholder": "MCP プリセットを検索", - "moreOptions": "その他の MCP オプション", - "moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。", + "moreOptions": "MCP サーバーを追加", + "moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。", "customTitle": "カスタム MCP", "customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。", "customAction": "カスタム", @@ -542,6 +542,21 @@ "keepExisting": "既存の値を維持するには空欄のままにします", "statusConfigured": "設定済み", "statusMissingCredentials": "キーが必要", + "connectingAccount": "{{name}} に接続しています", + "connectingLabel": "接続中…", + "continueSignIn": "サインインを続ける", + "preparingSignIn": "安全なサインインを準備しています…", + "openSignInToContinue": "サインインページを開いて続行してください。", + "finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。", + "finishingConnection": "接続を完了しています…", + "activatingTools": "ツールを有効にしています…", + "connected": "接続しました。", + "connectedLabel": "接続済み", + "connectedAccount": "{{name}} に接続しました。", + "connectionFailed": "接続に失敗しました。", + "connectionCancelled": "接続をキャンセルしました。", + "reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。", + "oauthFailed": "接続できません。もう一度サインインしてください。", "statusMissingDependency": "依存関係が必要", "statusComingSoon": "近日公開", "comingSoon": "近日公開", @@ -570,27 +585,28 @@ "apps": { "description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。", "cliLabel": "アプリ", - "mcpLabel": "連携", + "mcpLabel": "MCP", "channelLabel": "チャンネル", "featureLabel": "機能", "filterAll": "使用可能", "filterPlugins": "プラグイン", "filterCli": "アプリ", - "filterMcp": "連携", + "filterMcp": "MCP", "enabledSummary": "{{count}} 件使用可能", - "caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件", + "caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件", "searchPlaceholder": "アプリを検索", "featured": "ツール", + "mcpTools": "MCP ツール", "loading": "アプリを読み込み中...", "empty": "検索条件に一致するツールはありません。", "emptyApps": "利用できるアプリはありません。", - "emptyIntegrations": "利用できる連携はありません。", + "emptyIntegrations": "利用できる MCP ツールはありません。", "emptyReady": "使用可能なツールはまだありません。", "clearSearch": "検索をクリア", "browseApps": "アプリを見る", - "browseIntegrations": "連携を見る", - "emptyIntegrationsHint": "下からカスタム連携を追加できます。", - "restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。" + "browseIntegrations": "MCP ツールを見る", + "emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。", + "restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。" }, "channels": { "description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index dd3bfdf60aa..e8739e52165 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -504,8 +504,8 @@ "filterInstalled": "활성화됨", "filterNotInstalled": "비활성", "searchPlaceholder": "MCP 프리셋 검색", - "moreOptions": "추가 MCP 옵션", - "moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.", + "moreOptions": "MCP 서버 추가", + "moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.", "customTitle": "사용자 지정 MCP", "customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.", "customAction": "사용자 지정", @@ -542,6 +542,21 @@ "keepExisting": "기존 값을 유지하려면 비워 두세요", "statusConfigured": "구성됨", "statusMissingCredentials": "키 필요", + "connectingAccount": "{{name}} 연결 중", + "connectingLabel": "연결 중…", + "continueSignIn": "로그인 계속", + "preparingSignIn": "안전한 로그인을 준비하는 중…", + "openSignInToContinue": "계속하려면 로그인 페이지를 여세요.", + "finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.", + "finishingConnection": "연결을 마무리하는 중…", + "activatingTools": "도구를 활성화하는 중…", + "connected": "연결됨.", + "connectedLabel": "연결됨", + "connectedAccount": "{{name}} 연결됨.", + "connectionFailed": "연결에 실패했습니다.", + "connectionCancelled": "연결을 취소했습니다.", + "reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.", + "oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.", "statusMissingDependency": "의존성 필요", "statusComingSoon": "곧 제공", "comingSoon": "곧 제공", @@ -570,27 +585,28 @@ "apps": { "description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.", "cliLabel": "앱", - "mcpLabel": "연동", + "mcpLabel": "MCP", "channelLabel": "채널", "featureLabel": "기능", "filterAll": "사용 가능", "filterPlugins": "플러그인", "filterCli": "앱", - "filterMcp": "연동", + "filterMcp": "MCP", "enabledSummary": "{{count}}개 사용 가능", - "caption": "앱 {{cli}}개 · 연동 {{mcp}}개", + "caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개", "searchPlaceholder": "앱 검색", "featured": "도구", + "mcpTools": "MCP 도구", "loading": "앱을 불러오는 중...", "empty": "검색과 일치하는 도구가 없습니다.", "emptyApps": "사용 가능한 앱이 없습니다.", - "emptyIntegrations": "사용 가능한 연동이 없습니다.", + "emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.", "emptyReady": "아직 준비된 도구가 없습니다.", "clearSearch": "검색 지우기", "browseApps": "앱 둘러보기", - "browseIntegrations": "연동 둘러보기", - "emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.", - "restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요." + "browseIntegrations": "MCP 도구 둘러보기", + "emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.", + "restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요." }, "channels": { "description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index eef0bea72c8..1599fcd0192 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -319,8 +319,8 @@ "filterInstalled": "Habilitadas", "filterNotInstalled": "Não habilitadas", "searchPlaceholder": "Buscar predefinições MCP", - "moreOptions": "Adicionar integração", - "moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.", + "moreOptions": "Adicionar servidor MCP", + "moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.", "customTitle": "MCP personalizado", "customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.", "customAction": "Personalizado", @@ -357,6 +357,21 @@ "keepExisting": "Deixe em branco para manter o valor atual", "statusConfigured": "Configurado", "statusMissingCredentials": "Precisa de chave", + "connectingAccount": "Conectando {{name}}", + "connectingLabel": "Conectando…", + "continueSignIn": "Continuar login", + "preparingSignIn": "Preparando login seguro…", + "openSignInToContinue": "Abra a página de login para continuar.", + "finishSignInInBrowser": "Conclua o login na janela do navegador.", + "finishingConnection": "Finalizando conexão…", + "activatingTools": "Ativando ferramentas…", + "connected": "Conectado.", + "connectedLabel": "Conectado", + "connectedAccount": "{{name}} está conectado.", + "connectionFailed": "Falha na conexão.", + "connectionCancelled": "Conexão cancelada.", + "reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.", + "oauthFailed": "Não foi possível conectar. Tente fazer login novamente.", "statusMissingDependency": "Precisa de dependência", "statusComingSoon": "Em breve", "comingSoon": "Em breve", @@ -584,27 +599,28 @@ "apps": { "description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.", "cliLabel": "Aplicativo", - "mcpLabel": "Integração", + "mcpLabel": "MCP", "channelLabel": "Canal", "featureLabel": "Recurso", "filterAll": "Prontos", "filterPlugins": "Complementos", "filterCli": "Aplicativos", - "filterMcp": "Integrações", + "filterMcp": "MCP", "enabledSummary": "{{count}} prontos", - "caption": "{{cli}} aplicativos · {{mcp}} integrações", + "caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP", "searchPlaceholder": "Buscar ferramentas", "featured": "Ferramentas", + "mcpTools": "Ferramentas MCP", "loading": "Carregando aplicativos...", "empty": "Nenhuma ferramenta corresponde à sua busca.", "emptyApps": "Nenhum aplicativo disponível.", - "emptyIntegrations": "Nenhuma integração disponível.", + "emptyIntegrations": "Nenhuma ferramenta MCP disponível.", "emptyReady": "Ainda não há ferramentas prontas.", "clearSearch": "Limpar busca", "browseApps": "Explorar aplicativos", - "browseIntegrations": "Explorar integrações", - "emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.", - "restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados." + "browseIntegrations": "Explorar ferramentas MCP", + "emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.", + "restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados." }, "channels": { "description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 98ca6c1b3bd..4d0a210ea23 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -504,8 +504,8 @@ "filterInstalled": "Đã bật", "filterNotInstalled": "Chưa bật", "searchPlaceholder": "Tìm cấu hình đặt trước MCP", - "moreOptions": "Tùy chọn MCP khác", - "moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.", + "moreOptions": "Thêm máy chủ MCP", + "moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.", "customTitle": "MCP tùy chỉnh", "customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.", "customAction": "Tùy chỉnh", @@ -542,6 +542,21 @@ "keepExisting": "Để trống để giữ giá trị hiện tại", "statusConfigured": "Đã cấu hình", "statusMissingCredentials": "Cần khóa", + "connectingAccount": "Đang kết nối {{name}}", + "connectingLabel": "Đang kết nối…", + "continueSignIn": "Tiếp tục đăng nhập", + "preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…", + "openSignInToContinue": "Mở trang đăng nhập để tiếp tục.", + "finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.", + "finishingConnection": "Đang hoàn tất kết nối…", + "activatingTools": "Đang kích hoạt công cụ…", + "connected": "Đã kết nối.", + "connectedLabel": "Đã kết nối", + "connectedAccount": "Đã kết nối {{name}}.", + "connectionFailed": "Kết nối thất bại.", + "connectionCancelled": "Đã hủy kết nối.", + "reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.", + "oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.", "statusMissingDependency": "Cần phụ thuộc", "statusComingSoon": "Sắp ra mắt", "comingSoon": "Sắp ra mắt", @@ -570,27 +585,28 @@ "apps": { "description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.", "cliLabel": "Ứng dụng", - "mcpLabel": "Tích hợp", + "mcpLabel": "MCP", "channelLabel": "Kênh", "featureLabel": "Tính năng", "filterAll": "Sẵn sàng", "filterPlugins": "Plugin", "filterCli": "Ứng dụng", - "filterMcp": "Tích hợp", + "filterMcp": "MCP", "enabledSummary": "{{count}} sẵn sàng", - "caption": "{{cli}} ứng dụng · {{mcp}} tích hợp", + "caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP", "searchPlaceholder": "Tìm ứng dụng", "featured": "Công cụ", + "mcpTools": "Công cụ MCP", "loading": "Đang tải ứng dụng...", "empty": "Không có công cụ phù hợp với tìm kiếm của bạn.", "emptyApps": "Không có ứng dụng nào.", - "emptyIntegrations": "Không có tích hợp nào.", + "emptyIntegrations": "Không có công cụ MCP nào.", "emptyReady": "Chưa có công cụ nào sẵn sàng.", "clearSearch": "Xóa tìm kiếm", "browseApps": "Xem ứng dụng", - "browseIntegrations": "Xem tích hợp", - "emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.", - "restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật." + "browseIntegrations": "Xem công cụ MCP", + "emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.", + "restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật." }, "channels": { "description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 265f8179742..e3a63e5093f 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -319,8 +319,8 @@ "filterInstalled": "已启用", "filterNotInstalled": "未启用", "searchPlaceholder": "搜索 MCP 预设", - "moreOptions": "添加集成", - "moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。", + "moreOptions": "添加 MCP 服务", + "moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。", "customTitle": "自定义 MCP", "customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。", "customAction": "自定义", @@ -357,6 +357,21 @@ "keepExisting": "留空则保留当前值", "statusConfigured": "已配置", "statusMissingCredentials": "需要密钥", + "connectingAccount": "正在连接 {{name}}", + "connectingLabel": "正在连接…", + "continueSignIn": "继续登录", + "preparingSignIn": "正在准备安全登录…", + "openSignInToContinue": "打开登录页面以继续。", + "finishSignInInBrowser": "请在浏览器窗口中完成登录。", + "finishingConnection": "正在完成连接…", + "activatingTools": "正在启用工具…", + "connected": "已连接。", + "connectedLabel": "已连接", + "connectedAccount": "{{name}} 已连接。", + "connectionFailed": "连接失败。", + "connectionCancelled": "已取消连接。", + "reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。", + "oauthFailed": "无法连接,请重新登录。", "statusMissingDependency": "缺少依赖", "statusComingSoon": "暂不支持", "comingSoon": "即将推出", @@ -584,27 +599,28 @@ "apps": { "description": "将工具接入 nanobot,然后在对话中通过 @ 调用。", "cliLabel": "应用", - "mcpLabel": "集成", + "mcpLabel": "MCP", "channelLabel": "渠道", "featureLabel": "能力", "filterAll": "可用", "filterPlugins": "插件", "filterCli": "应用", - "filterMcp": "集成", + "filterMcp": "MCP", "enabledSummary": "{{count}} 个可用", - "caption": "{{cli}} 个应用 · {{mcp}} 个集成", + "caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具", "searchPlaceholder": "搜索工具", "featured": "工具", + "mcpTools": "MCP 工具", "loading": "正在加载应用...", "empty": "没有与搜索条件匹配的工具。", "emptyApps": "暂无可用应用。", - "emptyIntegrations": "暂无可用集成。", + "emptyIntegrations": "暂无可用 MCP 工具。", "emptyReady": "还没有就绪的工具。", "clearSearch": "清除搜索", "browseApps": "浏览应用", - "browseIntegrations": "浏览集成", - "emptyIntegrationsHint": "可在下方添加自定义集成。", - "restartRequired": "重启 nanobot 以应用更新后的应用和集成。" + "browseIntegrations": "浏览 MCP 工具", + "emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。", + "restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。" }, "channels": { "description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index a2eef59f532..f93f6cdff43 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -504,8 +504,8 @@ "filterInstalled": "已啟用", "filterNotInstalled": "未啟用", "searchPlaceholder": "搜尋 MCP 預設", - "moreOptions": "新增整合", - "moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。", + "moreOptions": "新增 MCP 服務", + "moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。", "customTitle": "自訂 MCP", "customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。", "customAction": "自訂", @@ -542,6 +542,21 @@ "keepExisting": "留空以保留目前值", "statusConfigured": "已設定", "statusMissingCredentials": "需要金鑰", + "connectingAccount": "正在連接 {{name}}", + "connectingLabel": "正在連線…", + "continueSignIn": "繼續登入", + "preparingSignIn": "正在準備安全登入…", + "openSignInToContinue": "開啟登入頁面以繼續。", + "finishSignInInBrowser": "請在瀏覽器視窗中完成登入。", + "finishingConnection": "正在完成連線…", + "activatingTools": "正在啟用工具…", + "connected": "已連線。", + "connectedLabel": "已連線", + "connectedAccount": "{{name}} 已連線。", + "connectionFailed": "連線失敗。", + "connectionCancelled": "已取消連線。", + "reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。", + "oauthFailed": "無法連線,請重新登入。", "statusMissingDependency": "需要相依項", "statusComingSoon": "即將推出", "comingSoon": "即將推出", @@ -570,27 +585,28 @@ "apps": { "description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。", "cliLabel": "應用程式", - "mcpLabel": "整合", + "mcpLabel": "MCP", "channelLabel": "通訊管道", "featureLabel": "功能", "filterAll": "就緒", "filterPlugins": "外掛程式", "filterCli": "應用程式", - "filterMcp": "整合", + "filterMcp": "MCP", "enabledSummary": "{{count}} 個就緒", - "caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務", + "caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具", "searchPlaceholder": "搜尋工具", "featured": "工具", + "mcpTools": "MCP 工具", "loading": "正在載入應用程式…", "empty": "沒有符合搜尋條件的工具。", "emptyApps": "沒有可用的應用程式。", - "emptyIntegrations": "沒有可用的整合服務。", + "emptyIntegrations": "沒有可用的 MCP 工具。", "emptyReady": "尚無就緒的工具。", "clearSearch": "清除搜尋", "browseApps": "瀏覽應用程式", - "browseIntegrations": "瀏覽整合服務", - "emptyIntegrationsHint": "可在下方新增自訂整合服務。", - "restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。" + "browseIntegrations": "瀏覽 MCP 工具", + "emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。", + "restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。" }, "channels": { "description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 2647eb115d3..d6c9776e3e6 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -10,6 +10,7 @@ import type { FilePreviewPayload, ImageGenerationSettingsUpdate, McpPresetsPayload, + McpOAuthFlowPayload, MarketplaceProvider, NanobotFeaturesPayload, ModelConfigurationCreate, @@ -686,6 +687,44 @@ export async function fetchMcpPresets( ); } +export async function startMcpOAuth( + transport: WebUIMutationTransport, + name: string, + reset: boolean = false, +): Promise { + return mutation( + transport, + "settings.mcp.oauth_start", + { name, ...(reset ? { reset: true } : {}) }, + 30_000, + ); +} + +export async function fetchMcpOAuthStatus( + token: string, + flowId: string, + base: string = "", +): Promise { + const query = new URLSearchParams({ flow_id: flowId }); + return request( + `${base}/api/settings/mcp-oauth/status?${query}`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function cancelMcpOAuth( + transport: WebUIMutationTransport, + flowId: string, +): Promise { + return mutation( + transport, + "settings.mcp.oauth_cancel", + { flow_id: flowId }, + ); +} + export async function fetchProviderModels( token: string, provider: string, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 528e122c5ba..9323efce086 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -956,6 +956,7 @@ export interface McpPresetInfo { description: string; docs_url: string; transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string; + auth?: "oauth" | null; requires: string; note: string; install_supported: boolean; @@ -976,6 +977,29 @@ export interface McpPresetInfo { manifest?: AppManifest; } +export type McpOAuthFlowStatus = + | "starting" + | "authorization_required" + | "connecting" + | "authorized" + | "connected" + | "failed" + | "cancelled"; + +export interface McpOAuthFlowPayload { + flow_id: string; + name: string; + status: McpOAuthFlowStatus; + expires_in: number; + authorization_url?: string; + error?: string; + hot_reload?: { + ok: boolean; + message?: string; + requires_restart?: boolean; + }; +} + export interface McpPresetsPayload { presets: McpPresetInfo[]; installed_count: number; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 51494d27c4d..1b284ac2ae6 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + cancelMcpOAuth, configureChannel, completeProviderOAuth, createModelConfiguration, @@ -14,6 +15,7 @@ import { fetchApiService, fetchCliApps, fetchInstalledCliApps, + fetchMcpOAuthStatus, fetchMcpPresets, fetchMarketplaceSkillTrends, fetchNanobotFeatures, @@ -41,6 +43,7 @@ import { saveCustomMcpServer, searchMarketplaceSkills, startApiService, + startMcpOAuth, stopApiService, cancelChannelConnect, pollChannelConnect, @@ -882,6 +885,26 @@ describe("webui API helpers", () => { { name: "browserbase", browserbase_api_key: "bb_live_test" }, 20_000, ); + + await startMcpOAuth(mutationTransport, "notion", true); + expect(requestMutation).toHaveBeenCalledWith( + "settings.mcp.oauth_start", + { name: "notion", reset: true }, + 30_000, + ); + + await fetchMcpOAuthStatus("tok", "flow-123"); + expect(fetch).toHaveBeenCalledWith( + "/api/settings/mcp-oauth/status?flow_id=flow-123", + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + + await cancelMcpOAuth(mutationTransport, "flow-123"); + expect(requestMutation).toHaveBeenCalledWith( + "settings.mcp.oauth_cancel", + { flow_id: "flow-123" }, + 20_000, + ); }); it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => { diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 2c0bcba47d4..5edb271103b 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -75,6 +75,19 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.apps.description", "settings.apps.caption", "settings.apps.restartRequired", + "settings.mcp.connectingAccount", + "settings.mcp.continueSignIn", + "settings.mcp.preparingSignIn", + "settings.mcp.openSignInToContinue", + "settings.mcp.finishSignInInBrowser", + "settings.mcp.finishingConnection", + "settings.mcp.activatingTools", + "settings.mcp.connected", + "settings.mcp.connectedAccount", + "settings.mcp.connectionFailed", + "settings.mcp.connectionCancelled", + "settings.mcp.reloadFailed", + "settings.mcp.oauthFailed", "settings.skills.views", "settings.skills.installedTab", "settings.skills.discoverTab", diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 0e391f33571..d6ca18e0353 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -334,6 +334,29 @@ const installedAnyGen = { skill_installed: true, }; +const xmindMcpPreset = { + name: "xmind", + display_name: "Xmind", + category: "productivity", + description: "Create, read, and edit cloud mind maps through Xmind.", + docs_url: "https://xmind.com/user-guide/xmind-mcp", + transport: "streamableHttp", + auth: "oauth" as const, + requires: "Xmind account", + note: "Connects securely in your browser with Xmind OAuth.", + install_supported: true, + installed: false, + configured: false, + available: false, + status: "not_installed", + logo_url: null, + brand_color: "#F4B41A", + required_fields: [], + connection_summary: "", + enabled_tools: ["*"], + source: "preset", +}; + function renderSettingsView( options: { initialSection?: @@ -652,6 +675,263 @@ describe("SettingsView Apps catalog", () => { expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); }); + it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => { + let connected = false; + 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: [connected + ? { + ...xmindMcpPreset, + installed: true, + configured: true, + available: true, + status: "configured", + connection_summary: "https://app.xmind.com/api/mcp", + } + : xmindMcpPreset], + installed_count: connected ? 1 : 0, + }); + } + if (url === "/api/settings/mcp-oauth/start?name=xmind") { + return jsonResponse({ + flow_id: "flow-123", + name: "xmind", + status: "authorization_required", + expires_in: 300, + authorization_url: "https://accounts.xmind.test/authorize?state=state-123", + }); + } + if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") { + connected = true; + return jsonResponse({ + flow_id: "flow-123", + name: "xmind", + status: "connected", + expires_in: 295, + hot_reload: { ok: true, requires_restart: false }, + }); + } + return { ok: false, status: 404, text: async () => "Not found" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + const replace = vi.fn(); + const popup = { + opener: window, + closed: false, + location: { replace }, + document: { title: "", body: { textContent: "" } }, + focus: vi.fn(), + close: vi.fn(), + }; + const open = vi.fn(() => popup); + vi.stubGlobal("open", open); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + expect(screen.getByText("MCP tools")).toBeInTheDocument(); + const connectButton = await screen.findByRole("button", { name: "Connect Xmind" }); + expect(connectButton).toHaveTextContent("Connect"); + fireEvent.click(connectButton); + + expect(open).toHaveBeenCalledWith( + "about:blank", + "nanobot-mcp-oauth", + "popup,width=560,height=720,resizable=yes,scrollbars=yes", + ); + await waitFor(() => expect(replace).toHaveBeenCalledWith( + "https://accounts.xmind.test/authorize?state=state-123", + )); + expect(popup.opener).toBeNull(); + expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + "Finish signing in in the browser window.", + ); + expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent( + "Connecting…", + ); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + + expect(await screen.findByRole("button", { name: "Xmind connected." }, { timeout: 2500 })) + .toHaveTextContent("Connected"); + expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument(); + expect(popup.close).toHaveBeenCalledTimes(1); + expect(replace).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/mcp-oauth/status?flow_id=flow-123", + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + }); + + it("silently removes an MCP when the card already shows the result", async () => { + 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: [{ + ...xmindMcpPreset, + installed: true, + configured: true, + available: true, + status: "configured", + connection_summary: "https://app.xmind.com/api/mcp", + }], + installed_count: 1, + }); + } + if (url === "/api/settings/mcp-presets/remove?name=xmind") { + return jsonResponse({ + presets: [xmindMcpPreset], + installed_count: 0, + requires_restart: false, + hot_reload: { + ok: true, + message: "MCP config reloaded without restarting nanobot.", + }, + last_action: { + ok: true, + message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.", + removed: true, + }, + }); + } + 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" })); + fireEvent.click(await screen.findByRole("button", { name: "Remove" })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/mcp-presets/remove?name=xmind", + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + )); + expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument(); + expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument(); + }); + + it("offers a one-click recovery when the OAuth popup is blocked", async () => { + 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: [xmindMcpPreset], installed_count: 0 }); + } + if (url === "/api/settings/mcp-oauth/start?name=xmind") { + return jsonResponse({ + flow_id: "flow-blocked", + name: "xmind", + status: "authorization_required", + expires_in: 300, + authorization_url: "https://accounts.xmind.test/authorize?state=blocked", + }); + } + return new Promise(() => {}); + }); + vi.stubGlobal("fetch", fetchMock); + const popup = { + opener: window, + closed: false, + location: { replace: vi.fn() }, + focus: vi.fn(), + close: vi.fn(), + }; + const open = vi.fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce(popup); + vi.stubGlobal("open", open); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); + + const continueButton = await screen.findByRole("button", { name: "Continue sign-in" }); + expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue."); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + fireEvent.click(continueButton); + expect(open).toHaveBeenLastCalledWith( + "https://accounts.xmind.test/authorize?state=blocked", + "nanobot-mcp-oauth", + "popup,width=560,height=720,resizable=yes,scrollbars=yes", + ); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + }); + + it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => { + let popupIsolated = false; + let statusCalls = 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") { + return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 }); + } + if (url === "/api/settings/mcp-oauth/start?name=xmind") { + return jsonResponse({ + flow_id: "flow-coop", + name: "xmind", + status: "authorization_required", + expires_in: 300, + authorization_url: "https://accounts.xmind.test/authorize?state=coop", + }); + } + if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") { + statusCalls += 1; + return jsonResponse({ + flow_id: "flow-coop", + name: "xmind", + status: statusCalls === 1 ? "authorization_required" : "failed", + expires_in: 299, + error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.", + authorization_url: statusCalls === 1 + ? "https://accounts.xmind.test/authorize?state=coop" + : undefined, + }); + } + return { ok: false, status: 404, text: async () => "Not found" } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + const popup = { + opener: window, + get closed() { + return popupIsolated; + }, + location: { + replace: vi.fn(() => { + popupIsolated = true; + }), + }, + document: { title: "", body: { textContent: "" } }, + focus: vi.fn(), + close: vi.fn(), + }; + vi.stubGlobal("open", vi.fn(() => popup)); + + renderSettingsView({ initialSection: "apps" }); + fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); + + await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 }); + expect(screen.getByRole("status")).toHaveTextContent( + "Finish signing in in the browser window.", + ); + expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + }); + it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); @@ -694,7 +974,7 @@ describe("SettingsView Apps catalog", () => { ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument(); expect(screen.queryByText("Api")).not.toBeInTheDocument(); expect(screen.queryByText("0 ready")).not.toBeInTheDocument(); @@ -2023,8 +2303,8 @@ describe("SettingsView Apps catalog", () => { expect(await screen.findByText("No apps available.")).toBeInTheDocument(); expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Browse integrations" })); - expect(await screen.findByText("Add integration")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" })); + expect(await screen.findByText("Add MCP server")).toBeInTheDocument(); }); it("shows token activity on the overview", async () => { From 8b47f9ca32e6e59f0360966078f470020f03e44a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 10 Aug 2026 14:38:18 +0800 Subject: [PATCH 2/9] fix(mcp): polish OAuth connection UX --- nanobot/agent/tools/mcp_oauth.py | 9 ++- tests/tools/test_mcp_oauth.py | 10 +++ .../src/components/settings/SettingsView.tsx | 59 +++++++++------- webui/src/tests/settings-view.test.tsx | 67 ++++++++++++++++++- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/nanobot/agent/tools/mcp_oauth.py b/nanobot/agent/tools/mcp_oauth.py index 9846c801fb1..64cd652c596 100644 --- a/nanobot/agent/tools/mcp_oauth.py +++ b/nanobot/agent/tools/mcp_oauth.py @@ -21,7 +21,7 @@ from loguru import logger from mcp.client.auth import OAuthClientProvider from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from pydantic import AnyUrl +from pydantic import AnyHttpUrl, AnyUrl from nanobot.config.paths import get_data_dir from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage] @@ -30,6 +30,11 @@ _STORE_VERSION = 1 _STORE_LOCK_TIMEOUT_S = 15 _DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}" +_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot") +_LOGO_URI = AnyHttpUrl( + "https://raw.githubusercontent.com/HKUDS/nanobot/main/" + "webui/public/brand/nanobot_apple_touch.png" +) class _StoredServer(TypedDict, total=False): @@ -271,6 +276,8 @@ async def authorization_required(_authorization_url: str) -> None: redirect_uris=[AnyUrl(redirect_uri)], token_endpoint_auth_method="none", client_name="nanobot", + client_uri=_CLIENT_URI, + logo_uri=_LOGO_URI, software_id="https://github.com/HKUDS/nanobot", ) return OAuthClientProvider( diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 3a48b9a3f13..60222f6fc69 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -134,6 +134,11 @@ async def callback() -> tuple[str, str | None]: assert str(auth.context.client_metadata.redirect_uris[0]) == ( "https://agent.example/auth/mcp/callback" ) + assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot" + assert str(auth.context.client_metadata.logo_uri) == ( + "https://raw.githubusercontent.com/HKUDS/nanobot/main/" + "webui/public/brand/nanobot_apple_touch.png" + ) assert auth.context.redirect_handler is redirect assert auth.context.callback_handler is callback storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp") @@ -235,6 +240,11 @@ async def respond(request: httpx.Request) -> httpx.Response: "code_challenge_methods_supported": ["S256"], }) if request.url.path == "/register": + registration = json.loads(request.content) + assert registration["client_uri"] == "https://github.com/HKUDS/nanobot" + assert registration["logo_uri"].endswith( + "/webui/public/brand/nanobot_apple_touch.png" + ) return httpx.Response(201, json={ "client_id": "nanobot-client", "redirect_uris": ["https://agent.example/auth/mcp/callback"], diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 9213baafa15..d43c4192b40 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -7739,7 +7739,7 @@ function AppsCatalogSettings({ {tx("settings.apps.loading", "Loading Apps...")}
) : items.length ? ( -
+
{items.map((item) => item.kind === "cli" ? ( -
+
+
@@ -8000,7 +8007,12 @@ function McpAppsCatalogRow({

{description}

-
+
{readyInstalled ? ( <> @@ -8050,14 +8062,22 @@ function McpAppsCatalogRow({ ) : oauthFlow ? ( - + <> + + + ) : isOAuth && preset.install_supported ? ( -
) : null} @@ -8423,7 +8434,7 @@ function McpCustomServerPanel({ {activeMode === "custom" ? (
-
+