diff --git a/docs/configuration.md b/docs/configuration.md index b9d0aae69..a617f2be8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1971,15 +1971,52 @@ 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. In the +WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or +SSE, and choose **OAuth** under **Authentication**. Save the server, then choose +**Connect**. For manual configuration, add `auth: "oauth"` and open +**Apps → MCP** to 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, HTTPS is recommended. Configure +`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so +nanobot can register the matching HTTPS callback and finish automatically. A +loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP, +nanobot instead registers a localhost callback and asks you to paste the complete +callback URL from the browser address bar after authorization. > [!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 4227dd762..ca0fe1a98 100644 --- a/docs/guides/configure-mcp-tools.md +++ b/docs/guides/configure-mcp-tools.md @@ -30,10 +30,15 @@ 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 a custom OAuth server, choose **OAuth** under **Authentication**, save it, + and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to + **Connect**. Approve access in the browser window. HTTPS and localhost WebUIs + return automatically. From a remote plain-HTTP WebUI, copy the complete + localhost callback URL from the browser address bar and paste it into nanobot. 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 +63,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 7885b61c9..d89db9a63 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -204,8 +204,13 @@ 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. Custom HTTP/SSE servers can use no authentication, OAuth, or request + headers. After saving an OAuth server, choose **Connect** to open its sign-in + page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and + localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one + field for pasting the complete localhost callback URL. Apps intentionally does not list nanobot runtime support packages such as `api` or `bedrock`. Those packages enable providers, servers, or channels; they @@ -226,8 +231,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/filesystem.py b/nanobot/agent/tools/filesystem.py index ac52ca868..ea84fedb1 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -827,7 +827,8 @@ class EditFileTool(_FsTool): def description(self) -> str: return ( "Perform a small, exact replacement in one file by replacing " - "old_text with new_text. Use this for narrow text substitutions " + "old_text with new_text. When replacing text in an existing file, " + "old_text and new_text must be different. Use this for narrow text substitutions " "with old_text copied from read_file. For multi-file, structural, " "or generated code edits, prefer apply_patch. If old_text matches " "multiple times, provide more context or set occurrence, line_hint, " @@ -862,9 +863,12 @@ class EditFileTool(_FsTool): return ToolResult.error("Error: expected_replacements must be >= 1.") fp = self._resolve_write(path) + file_exists = fp.exists() + if file_exists and old_text == new_text: + return ToolResult.error("Error: new_text must be different from old_text.") # Create-file semantics: old_text='' + file doesn't exist → create - if not fp.exists(): + if not file_exists: if old_text == "": fp.parent.mkdir(parents=True, exist_ok=True) fp.write_text(new_text, encoding="utf-8") diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 0106d77f1..1fe4a6bab 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: 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. @@ -184,6 +185,25 @@ def _is_transient(exc: BaseException) -> bool: return type(exc).__name__ in _TRANSIENT_EXC_NAMES +def _is_transient_connection_failure(exc: BaseException) -> bool: + if isinstance(exc, BaseExceptionGroup): + group = cast(BaseExceptionGroup[BaseException], exc) + return bool(group.exceptions) and all( + _is_transient_connection_failure(nested) for nested in group.exceptions + ) + return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc) + + +def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None: + if _is_transient_connection_failure(exc): + logger.warning("MCP server '{}': transient connection failure", name) + logger.opt(exception=exc).debug( + "MCP server '{}' transient connection failure details", name + ) + return + logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint) + + def _is_session_terminated(exc: BaseException) -> bool: """Return True when the MCP SDK reports a dead client session.""" if _is_transient(exc): @@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase): 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 +1024,29 @@ async def connect_mcp_servers( ) 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 +1084,30 @@ async def connect_mcp_servers( **_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) @@ -1182,7 +1236,7 @@ async def connect_mcp_servers( " Hint: this looks like stdio protocol pollution. Make sure the MCP server writes " "only JSON-RPC to stdout and sends logs/debug output to stderr instead." ) - logger.exception("MCP server '{}': failed to connect: {}", name, hint) + _log_mcp_connection_failure(name, e, hint) return False async def connect_single_server( @@ -1229,7 +1283,7 @@ async def connect_mcp_servers( try: result = await connect_single_server(name, cfg) except Exception as e: - logger.exception("MCP server '{}' connection failed: {}", name, e) + _log_mcp_connection_failure(name, e) continue if result[1] is not None: server_stacks[result[0]] = result[1] @@ -1306,6 +1360,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: current_servers = dict(state._mcp_servers) current_names = set(current_servers) next_names = set(next_servers) + from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials + + authorization_pending = { + name + for name, cfg in next_servers.items() + if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url) + } removed = sorted(current_names - next_names) added = sorted(next_names - current_names) changed = sorted( @@ -1323,9 +1384,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: retry_missing = sorted( name for name in next_names - if name not in state._mcp_stacks and name not in set(added) | set(changed) + if name not in state._mcp_stacks + and name not in set(added) | set(changed) + and name not in authorization_pending + ) + to_connect_names = sorted( + (set(added) | set(changed) | set(retry_missing)) - authorization_pending ) - to_connect_names = sorted(set(added) | set(changed) | set(retry_missing)) to_connect = {name: next_servers[name] for name in to_connect_names} connected: dict[str, MCPConnection] = {} if to_connect: diff --git a/nanobot/agent/tools/mcp_oauth.py b/nanobot/agent/tools/mcp_oauth.py new file mode 100644 index 000000000..b20a5547d --- /dev/null +++ b/nanobot/agent/tools/mcp_oauth.py @@ -0,0 +1,401 @@ +"""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 +import secrets +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 AnyHttpUrl, 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}" +_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): + server_fingerprint: str + write_lease: str + tokens: dict[str, Any] + client_info: dict[str, Any] + redirect_uri: str + + +class _CredentialStore(TypedDict): + version: int + servers: dict[str, _StoredServer] + generations: dict[str, str] + + +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": {}, "generations": {}} + + +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 + write_lease = raw.get("write_lease") + if isinstance(write_lease, str) and write_lease: + entry["write_lease"] = write_lease + 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 + generations: dict[str, str] = {} + raw_generations = payload.get("generations") + if isinstance(raw_generations, dict): + for name, value in cast(dict[object, object], raw_generations).items(): + if isinstance(name, str) and isinstance(value, str) and value: + generations[name] = value + return { + "version": _STORE_VERSION, + "servers": servers, + "generations": generations, + } + + +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) + self._observed_generation = self._read_generation_sync() + self._write_lease: str | None = None + + def _read_generation_sync(self) -> str | None: + path = _store_path() + if not path.exists(): + return None + # Writes replace the whole file atomically, so this observes either side + # of a concurrent deletion without blocking the async connection path. + return _read_store_unlocked(path)["generations"].get(self.server_name) + + def _generation_is_current(self, payload: _CredentialStore) -> bool: + return payload["generations"].get(self.server_name) == self._observed_generation + + 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 _bind_entry_unlocked( + self, + payload: _CredentialStore, + *, + create: bool, + ) -> tuple[_StoredServer | None, bool]: + if not self._generation_is_current(payload): + return None, False + entry = self._entry_unlocked(payload) + if self._write_lease is not None: + if entry is None or entry.get("write_lease") != self._write_lease: + return None, False + return entry, False + if entry is None: + if not create: + return None, False + self._write_lease = secrets.token_urlsafe(24) + entry = _StoredServer( + server_fingerprint=self.server_fingerprint, + write_lease=self._write_lease, + ) + payload["servers"][self.server_name] = entry + return entry, True + write_lease = entry.get("write_lease") + changed = not isinstance(write_lease, str) or not write_lease + if changed: + write_lease = secrets.token_urlsafe(24) + entry["write_lease"] = write_lease + self._write_lease = write_lease + return entry, changed + + def _read_entry_sync(self) -> _StoredServer | None: + path = _store_path() + with _with_store_lock(path): + payload = _read_store_unlocked(path) + entry, changed = self._bind_entry_unlocked(payload, create=False) + if changed: + _write_store_unlocked(path, payload) + return entry + + def _update_entry_sync( + self, + update: Callable[[_StoredServer], None], + *, + create: bool = True, + claim: bool = False, + ) -> bool: + path = _store_path() + with _with_store_lock(path): + payload = _read_store_unlocked(path) + if claim: + # A browser flow owns subsequent SDK writes until another flow + # claims the entry or the configured server is removed. + if not self._generation_is_current(payload): + logger.info( + "Ignored stale MCP OAuth credential claim for '{}'", + self.server_name, + ) + return False + entry = self._entry_unlocked(payload) + if entry is None: + entry = _StoredServer(server_fingerprint=self.server_fingerprint) + payload["servers"][self.server_name] = entry + self._write_lease = secrets.token_urlsafe(24) + entry["write_lease"] = self._write_lease + else: + entry, _ = self._bind_entry_unlocked(payload, create=create) + if entry is None: + if self._write_lease is not None: + logger.info( + "Ignored stale MCP OAuth credential update for '{}'", + self.server_name, + ) + return False + update(entry) + payload["version"] = _STORE_VERSION + _write_store_unlocked(path, payload) + return True + + 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, create=False) + + 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 + + claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True) + if not claimed: + raise MCPAuthorizationRequiredError("MCP authorization was cancelled") + + 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", + client_uri=_CLIENT_URI, + logo_uri=_LOGO_URI, + 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() + with _with_store_lock(path): + payload = _read_store_unlocked(path) + servers = payload["servers"] + removed = servers.pop(server_name, None) is not None + # Rotate even when no entry exists so a flow created before removal cannot + # claim the name later and resurrect credentials. + payload["generations"][server_name] = secrets.token_urlsafe(24) + _write_store_unlocked(path, payload) + return removed diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e997fea30..4d3d2e79c 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 000000000..9a64023a1 --- /dev/null +++ b/nanobot/webui/mcp_oauth_api.py @@ -0,0 +1,415 @@ +"""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 SplitResult, parse_qs, urlsplit, urlunsplit + +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 + manual_callback: bool + 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 _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]: + cleaned = redirect_uri.strip() + parsed = urlsplit(cleaned) + try: + port = parsed.port + except ValueError as exc: + raise McpOAuthError("Invalid MCP OAuth callback URL") from exc + if ( + not parsed.netloc + or not parsed.hostname + 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") + return cleaned, parsed, port + + +def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str: + """Allow HTTPS callbacks, plus loopback HTTP for a local gateway.""" + cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri) + if parsed.scheme == "https": + return cleaned + if parsed.scheme == "http" and is_loopback_host(parsed.netloc): + return cleaned + raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost") + + +def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]: + """Use a pasteable loopback callback when a remote WebUI is served over HTTP.""" + cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri) + if parsed.scheme != "http" or is_loopback_host(parsed.netloc): + return validate_mcp_oauth_redirect_uri(cleaned), False + + loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}" + manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", "")) + return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True + + +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, manual_callback = prepare_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, + manual_callback=manual_callback, + 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 + + def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]: + """Complete a flow from a full browser callback URL pasted into the WebUI.""" + self._prune() + flow = self._flow(flow_id) + parsed = urlsplit(callback_url.strip()) + expected = urlsplit(flow.redirect_uri) + if ( + not parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None + or parsed.scheme != expected.scheme + or parsed.netloc != expected.netloc + or parsed.path != expected.path + ): + raise McpOAuthError( + "Paste the complete callback URL from the browser address bar." + ) + try: + query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16) + except ValueError as exc: + raise McpOAuthError( + "Paste the complete callback URL from the browser address bar." + ) from exc + + states = query.get("state", []) + state = states[0] if len(states) == 1 else "" + if not state or state != flow.state: + raise McpOAuthError( + "This callback belongs to a different or expired authorization request. " + "Start again.", + status=410, + ) + + codes = query.get("code", []) + errors = query.get("error", []) + if len(codes) > 1 or len(errors) > 1 or (codes and errors): + raise McpOAuthError( + "Paste the complete callback URL from the browser address bar." + ) + code = codes[0] if len(codes) == 1 else None + error = errors[0] if len(errors) == 1 else None + if (not code and not error) or (code is not None and len(code) > 8192): + raise McpOAuthError( + "Paste the complete callback URL from the browser address bar." + ) + + self.submit_callback(state=state, code=code, error=error) + return self._payload(flow) + + 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() + failed = flow.reload_result.get("failed") + if ( + not flow.reload_result.get("ok") + and not flow.reload_result.get("requires_restart") + and isinstance(failed, list) + and flow.name in failed + ): + 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 + connected = flow.reload_result.get("connected") if flow.reload_result is not None else None + 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") or ( + isinstance(connected, list) and flow.name in connected + ): + 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.manual_callback: + payload["completion_input"] = "callback_url" + 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 32b2b303f..cf7cfde1f 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -22,6 +22,10 @@ from nanobot.agent.plugins import ( discover_agent_plugin_states, set_agent_plugin_enabled, ) +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 @@ -342,6 +346,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = ( ), 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", @@ -662,6 +723,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" @@ -707,6 +770,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, @@ -757,6 +821,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, }) @@ -784,7 +849,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, @@ -793,6 +858,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, @@ -819,7 +885,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, @@ -827,12 +897,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", @@ -1177,6 +1248,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") @@ -1192,6 +1289,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: @@ -1201,12 +1305,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")), ) @@ -1251,6 +1356,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: @@ -1259,12 +1371,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), ) @@ -1289,6 +1402,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, @@ -1298,8 +1420,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, @@ -1309,8 +1434,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, @@ -1339,6 +1471,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, @@ -1378,6 +1531,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 1fc01461c..dadbec9bd 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -8,6 +8,7 @@ request mapping and response shaping. from __future__ import annotations import asyncio +import html import inspect import json import time @@ -19,6 +20,7 @@ from websockets.http11 import Response 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,11 @@ from nanobot.optional_features import ( ) 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 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, @@ -80,6 +84,7 @@ _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" _SKIP_FIELD = object() _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) +_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024 def _channel_connect_route(path: str) -> tuple[str, str] | None: @@ -131,6 +136,9 @@ _SETTINGS_MUTATION_PATHS = frozenset({ "/api/settings/channels/configure", "/api/settings/pairing/approve", "/api/settings/pairing/deny", + "/api/settings/mcp-oauth/start", + "/api/settings/mcp-oauth/complete", + "/api/settings/mcp-oauth/cancel", *_MCP_PRESET_ACTIONS_BY_PATH, }) @@ -178,6 +186,7 @@ class WebUISettingsRouter: runtime_capabilities: dict[str, Any], channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None, ) -> None: self.settings = settings self.bus = bus @@ -190,6 +199,8 @@ class WebUISettingsRouter: 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] = {} @@ -203,6 +214,8 @@ class WebUISettingsRouter: 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": @@ -282,6 +295,14 @@ class WebUISettingsRouter: return self._handle_settings_pairing_action(request, "deny") if path == "/api/settings/mcp-presets": return await self._handle_settings_mcp_presets(connection, 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/complete": + return self._handle_mcp_oauth_complete(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) @@ -1234,6 +1255,147 @@ class WebUISettingsRouter: 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) + + def _handle_mcp_oauth_complete(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + query = self._query(request) + flow_id = (_query_first(query, "flow_id") or "").strip() + if not flow_id: + return self._error_response(400, "missing MCP OAuth flow ID") + callback_url = (_query_first(query, "callback_url") or "").strip() + if not callback_url: + return self._error_response(400, "Paste the complete callback URL to continue") + if len(callback_url.encode("utf-8")) > _MCP_OAUTH_CALLBACK_URL_MAX_BYTES: + return self._error_response(400, "The MCP OAuth callback URL is too long") + try: + payload = self._mcp_oauth.submit_callback_url( + flow_id=flow_id, + callback_url=callback_url, + ) + except Exception as exc: + return self._mcp_oauth_error_response(exc, action="complete") + 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 0db563684..4942bc7d3 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -17,7 +17,7 @@ import time 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 @@ -167,6 +167,9 @@ _WEBUI_MUTATION_PATHS = { "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_complete": "/api/settings/mcp-oauth/complete", + "settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel", } _WEBUI_CHANNEL_CONNECT_ACTIONS = { @@ -345,6 +348,7 @@ class GatewayHTTPHandler: 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: @@ -618,6 +622,14 @@ class GatewayHTTPHandler: 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/agent/test_mcp_connection.py b/tests/agent/test_mcp_connection.py index f0a23d514..05f03eb98 100644 --- a/tests/agent/test_mcp_connection.py +++ b/tests/agent/test_mcp_connection.py @@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack( await loop.close_mcp() +@pytest.mark.asyncio +async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + config_path = tmp_path / "config.json" + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + config = load_config() + notion = MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.notion.test/mcp", + ) + linear = MCPServerConfig( + type="streamableHttp", + auth="oauth", + url="https://mcp.linear.test/mcp", + ) + config.tools.mcp_servers.update({"notion": notion, "linear": linear}) + save_config(config) + + attempted: list[str] = [] + + async def _fake_connect(servers, _registry): + attempted.extend(servers) + stack = AsyncExitStack() + await stack.__aenter__() + return {"linear": stack} + + monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect) + monkeypatch.setattr( + "nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials", + lambda name, _url: name == "linear", + ) + loop = _make_loop(tmp_path, mcp_servers={"notion": notion}) + + result = await mcp_runtime.reload_servers(loop, loop.tools) + + assert attempted == ["linear"] + assert result["ok"] is True + assert result["failed"] == [] + assert result["retried"] == [] + assert result["connected"] == ["linear"] + await loop.close_mcp() + + @pytest.mark.asyncio async def test_mcp_tool_reconnects_after_session_terminated( tmp_path, diff --git a/tests/tools/test_filesystem_tools.py b/tests/tools/test_filesystem_tools.py index b221bfcc1..a1e152c59 100644 --- a/tests/tools/test_filesystem_tools.py +++ b/tests/tools/test_filesystem_tools.py @@ -133,6 +133,16 @@ class TestEditFileTool: assert "Successfully" in result assert f.read_text() == "hello earth" + @pytest.mark.asyncio + async def test_identical_replacement_returns_clear_error(self, tool, tmp_path): + f = tmp_path / "a.py" + f.write_text("hello world", encoding="utf-8") + + result = await tool.execute(path=str(f), old_text="world", new_text="world") + + assert result == "Error: new_text must be different from old_text." + assert f.read_text(encoding="utf-8") == "hello world" + @pytest.mark.asyncio async def test_crlf_normalisation(self, tool, tmp_path): f = tmp_path / "crlf.py" diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py new file mode 100644 index 000000000..bdec36a3e --- /dev/null +++ b/tests/tools/test_mcp_oauth.py @@ -0,0 +1,325 @@ +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_deleted_credentials_reject_late_writes_from_stale_oauth_flow( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + server_url = "https://mcp.linear.example/mcp" + stale = MCPOAuthStorage("linear", server_url) + await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback") + + assert delete_mcp_oauth_credentials("linear") + await stale.set_tokens(OAuthToken(access_token="late-after-delete")) + assert not mcp_oauth_has_credentials("linear", server_url) + + replacement = MCPOAuthStorage("linear", server_url) + await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback") + await stale.set_tokens(OAuthToken(access_token="late-after-replacement")) + + assert not mcp_oauth_has_credentials("linear", server_url) + assert await replacement.get_tokens() is None + + await replacement.set_tokens(OAuthToken(access_token="fresh-token")) + stored = await replacement.get_tokens() + assert stored is not None + assert stored.access_token == "fresh-token" + + +@pytest.mark.asyncio +async def test_delete_before_oauth_claim_rejects_late_credential_writes( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_data_dir(tmp_path, monkeypatch) + server_url = "https://mcp.linear.example/mcp" + stale = MCPOAuthStorage("linear", server_url) + + assert not delete_mcp_oauth_credentials("linear") + with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"): + await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback") + await stale.set_tokens(OAuthToken(access_token="late-after-delete")) + assert not mcp_oauth_has_credentials("linear", server_url) + + replacement = MCPOAuthStorage("linear", server_url) + await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback") + await replacement.set_tokens(OAuthToken(access_token="fresh-token")) + assert mcp_oauth_has_credentials("linear", server_url) + + +@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 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") + 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": + 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"], + "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 e2c282ebd..d94050ad6 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -826,19 +826,23 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint( ) -> None: messages: list[str] = [] - def _error(message: str, *args: object) -> None: - messages.append(message.format(*args)) - @asynccontextmanager async def _broken_stdio_client(_params: object): raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers") yield # pragma: no cover monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client) - monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error) + sink = mcp_mod.logger.add( + lambda message: messages.append(message.record["message"]), level="ERROR" + ) registry = ToolRegistry() - stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry) + try: + stacks = await connect_mcp_servers( + {"gh": MCPServerConfig(command="github-mcp")}, registry + ) + finally: + mcp_mod.logger.remove(sink) assert stacks == {} assert messages @@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint( assert "stderr" in messages[-1] +def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None: + records: list[dict] = [] + sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG") + error = ExceptionGroup("transport failed", [httpx.ConnectError("")]) + try: + mcp_mod._log_mcp_connection_failure("notion", error) + finally: + mcp_mod.logger.remove(sink) + + warning = next(record for record in records if record["level"].name == "WARNING") + debug = next(record for record in records if record["level"].name == "DEBUG") + assert warning["exception"] is None + assert "transient connection failure" in warning["message"] + assert debug["exception"] is not None + assert not any(record["level"].name == "ERROR" for record in records) + + +def test_unexpected_connection_failure_keeps_error_trace() -> None: + records: list[dict] = [] + sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG") + try: + mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom")) + finally: + mcp_mod.logger.remove(sink) + + error = next(record for record in records if record["level"].name == "ERROR") + assert error["exception"] is not None + assert not any(record["level"].name == "WARNING" for record in records) + + @pytest.mark.asyncio @pytest.mark.parametrize( "config", @@ -1210,6 +1244,129 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout( 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 000000000..1bdeb5d7f --- /dev/null +++ b/tests/webui/test_mcp_oauth_api.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from nanobot.config.schema import MCPServerConfig +from nanobot.webui.mcp_oauth_api import ( + McpOAuthError, + McpOAuthManager, + prepare_mcp_oauth_redirect_uri, + 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_retries_current_server_and_ignores_unrelated_reload_failure( + 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 + if reload_calls == 1: + return { + "ok": False, + "requires_restart": False, + "failed": ["xmind"], + } + return { + "ok": False, + "requires_restart": False, + "connected": ["xmind"], + "failed": ["notion"], + "message": "MCP config reloaded, but some servers did not connect: notion", + } + + 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 == 2: + break + assert reload_calls == 2 + + 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 first["hot_reload"]["failed"] == ["notion"] + assert received["callback"] == ("oauth-code", "state-123") + assert reload_calls == 2 + assert connection.closed is True + + +@pytest.mark.asyncio +async def test_remote_http_flow_accepts_a_pasted_loopback_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = McpOAuthManager() + connection = _Connection() + received: dict[str, object] = {} + + monkeypatch.setattr( + "nanobot.webui.mcp_oauth_api.validate_url_target", + lambda _url: (True, ""), + ) + + async def connect(_servers, _registry, *, oauth_handlers): + handlers = oauth_handlers["linear"] + received["redirect_uri"] = handlers.redirect_uri + await handlers.redirect_handler( + "https://accounts.example.com/authorize?client_id=test&state=manual-state" + ) + received["callback"] = await handlers.callback_handler() + return {"linear": connection} + + monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect) + + started = await manager.start( + "linear", + _config(), + "http://192.0.2.10:8765/auth/mcp/callback", + reload_mcp=lambda: asyncio.sleep( + 0, + result={"ok": True, "requires_restart": False}, + ), + ) + + assert started["status"] == "authorization_required" + assert started["completion_input"] == "callback_url" + assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback" + + with pytest.raises(McpOAuthError, match="complete callback URL"): + manager.submit_callback_url( + flow_id=started["flow_id"], + callback_url=( + "http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state" + ), + ) + with pytest.raises(McpOAuthError, match="different or expired"): + manager.submit_callback_url( + flow_id=started["flow_id"], + callback_url=( + "http://127.0.0.1:8765/auth/mcp/callback" + "?code=oauth-code&state=other-state" + ), + ) + + submitted = manager.submit_callback_url( + flow_id=started["flow_id"], + callback_url=( + "http://127.0.0.1:8765/auth/mcp/callback" + "?code=oauth-code&state=manual-state" + ), + ) + assert submitted["status"] == "connecting" + + for _ in range(20): + await asyncio.sleep(0) + result = await manager.status(started["flow_id"]) + if result["status"] == "connected": + break + + assert result["status"] == "connected" + assert result["completion_input"] == "callback_url" + assert received["callback"] == ("oauth-code", "manual-state") + 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="HTTPS or localhost"): + 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") + + +def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None: + assert prepare_mcp_oauth_redirect_uri( + "https://agent.example.com/auth/mcp/callback" + ) == ("https://agent.example.com/auth/mcp/callback", False) + assert prepare_mcp_oauth_redirect_uri( + "http://127.0.0.1:8765/auth/mcp/callback" + ) == ("http://127.0.0.1:8765/auth/mcp/callback", False) + assert prepare_mcp_oauth_redirect_uri( + "http://agent.example.com:9443/auth/mcp/callback" + ) == ("http://127.0.0.1:9443/auth/mcp/callback", True) diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index c377bffa2..0840895b9 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -7,8 +7,10 @@ from functools import partial from pathlib import Path import pytest +from mcp.shared.auth import OAuthToken from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA +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, @@ -92,6 +94,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 @@ -171,6 +176,37 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action( assert rows[0]["source"] == "custom" +@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, @@ -412,11 +448,11 @@ def test_test_mcp_preset_scrubs_connection_errors( 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 @@ -530,6 +566,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 f0a469f14..92607743c 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -2,7 +2,7 @@ from __future__ import annotations 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,121 @@ 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.asyncio +async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None: + callback_url = ( + "http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123" + ) + router = _router() + submit = MagicMock( + return_value={ + "flow_id": "flow-123", + "name": "linear", + "status": "connecting", + "expires_in": 299, + "completion_input": "callback_url", + } + ) + router._mcp_oauth = SimpleNamespace(submit_callback_url=submit) + request = _mutation_request( + "/api/settings/mcp-oauth/complete", + {"flow_id": "flow-123", "callback_url": callback_url}, + ) + + response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete") + + assert response is not None + assert response.status_code == 200 + assert json.loads(response.body)["status"] == "connecting" + assert b"oauth-code" not in response.body + submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url) + + denied = _router(authorized=False) + denied_response = await denied.dispatch( + None, + request, + "/api/settings/mcp-oauth/complete", + ) + assert denied_response is not None + assert denied_response.status_code == 401 + + @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 000000000..df0a2b27c --- /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 534be0a38..14f2d798b 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, forwardRef, + useId, useMemo, useRef, useState, @@ -114,7 +115,9 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { Textarea } from "@/components/ui/textarea"; import { isLoopbackHost } from "@/lib/network"; import { + cancelMcpOAuth, checkVersion, + completeMcpOAuth, completeProviderOAuth, createModelConfiguration, createProviderSettings, @@ -127,6 +130,7 @@ import { fetchSettingsUsage, fetchCliApps, fetchMcpPresets, + fetchMcpOAuthStatus, fetchNanobotFeatures, fetchProviderModels, importMcpConfig, @@ -137,6 +141,7 @@ import { runCliAppAction, runMcpPresetAction, saveCustomMcpServer, + startMcpOAuth, startApiService, stopApiService, updateAutomation, @@ -182,6 +187,7 @@ import type { CliAppsPayload, ImageGenerationSettingsUpdate, McpPresetInfo, + McpOAuthFlowPayload, McpPresetsPayload, NanobotFeatureInfo, NanobotFeaturesPayload, @@ -226,6 +232,31 @@ function isProviderOAuthPending( return (payload as ProviderOAuthPending).status === "pending"; } +function isExpectedMcpOAuthPendingReloadFailure( + payload: McpPresetsPayload, + expectedName?: string, +): boolean { + if ( + !expectedName + || payload.last_action?.ok === false + || payload.hot_reload?.ok !== false + ) return false; + + const normalizedName = expectedName.trim().toLowerCase(); + const failed = payload.hot_reload.failed ?? []; + if ( + !normalizedName + || failed.length !== 1 + || failed[0].trim().toLowerCase() !== normalizedName + ) return false; + + return payload.presets.some((preset) => ( + preset.name.trim().toLowerCase() === normalizedName + && preset.auth === "oauth" + && preset.status === "authorization_required" + )); +} + type AppsKindFilter = "ready" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationSort = "next" | "last" | "updated" | "name"; @@ -274,6 +305,7 @@ type ProviderForm = { }; type CustomProviderDraft = ProviderForm & { name: string }; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; +type CustomMcpAuth = "none" | "oauth" | "headers"; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const; const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]); @@ -463,6 +495,7 @@ const SETTINGS_SEARCH_INPUT_CLASS = cn( interface CustomMcpForm { name: string; transport: CustomMcpTransport; + auth: CustomMcpAuth; command: string; args: string; url: string; @@ -495,6 +528,7 @@ const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = { const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = { name: "", transport: "stdio", + auth: "none", command: "", args: "", url: "", @@ -752,6 +786,14 @@ 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 [mcpOAuthCallbackUrl, setMcpOAuthCallbackUrl] = useState(""); + const [mcpOAuthCompleting, setMcpOAuthCompleting] = useState(false); + const [mcpOAuthCallbackError, setMcpOAuthCallbackError] = useState(null); const [providerSaving, setProviderSaving] = useState(null); const [providerOAuthFlow, setProviderOAuthFlow] = useState(null); @@ -1987,6 +2029,255 @@ 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); + setMcpOAuthCallbackUrl(""); + setMcpOAuthCompleting(false); + setMcpOAuthCallbackError(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); + setMcpOAuthCallbackUrl(""); + setMcpOAuthCompleting(false); + setMcpOAuthCallbackError(null); + setMcpError((err as Error).message); + return; + } + } + }; + + const handleMcpOAuthConnect = async (name: string) => { + openMcpOAuthPopup(); + const key = `oauth:${name}`; + setMcpPresetAction(key); + setMcpMessage(null); + setMcpError(null); + setMcpOAuthCallbackUrl(""); + setMcpOAuthCompleting(false); + setMcpOAuthCallbackError(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); + setMcpOAuthCallbackUrl(""); + setMcpOAuthCompleting(false); + setMcpOAuthCallbackError(null); + setMcpError((err as Error).message); + } + }; + + const handleMcpOAuthCancel = async () => { + const flow = mcpOAuthFlowRef.current; + if (!flow) return; + mcpOAuthFlowRef.current = null; + setMcpOAuthFlow(null); + setMcpPresetAction(null); + setMcpOAuthCallbackUrl(""); + setMcpOAuthCompleting(false); + setMcpOAuthCallbackError(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 handleMcpOAuthComplete = async () => { + const flow = mcpOAuthFlowRef.current; + const callbackUrl = mcpOAuthCallbackUrl.trim(); + if (!flow || flow.completion_input !== "callback_url") return; + if (!callbackUrl) { + setMcpOAuthCallbackError(t("settings.oauth.pasteCallbackToContinue")); + return; + } + setMcpOAuthCompleting(true); + setMcpOAuthCallbackError(null); + try { + const next = await completeMcpOAuth(client, flow.flow_id, callbackUrl); + if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; + mcpOAuthFlowRef.current = next; + setMcpOAuthFlow(next); + } catch (err) { + if (mcpOAuthFlowRef.current?.flow_id !== flow.flow_id) return; + setMcpOAuthCallbackError((err as Error).message); + } finally { + if (mcpOAuthFlowRef.current?.flow_id === flow.flow_id) { + setMcpOAuthCompleting(false); + } + } + }; + + const applyMcpActionFeedback = ( + payload: McpPresetsPayload, + announceSuccess = false, + expectedOAuthPendingName?: string, + ) => { + const expectedOAuthPending = isExpectedMcpOAuthPendingReloadFailure( + payload, + expectedOAuthPendingName, + ); + const actionError = payload.last_action?.ok === false + ? payload.last_action.error || payload.last_action.message + : payload.hot_reload?.ok === false && !expectedOAuthPending + ? payload.hot_reload.message + : null; + setMcpError(actionError || null); + setMcpMessage( + actionError || !announceSuccess + ? null + : payload.last_action?.message ?? null, + ); + }; + const handleMcpPresetAction = async ( action: "enable" | "disable" | "remove" | "test", name: string, @@ -1999,7 +2290,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); } @@ -2019,6 +2310,9 @@ export function SettingsView({ const handleSaveCustomMcp = async () => { const name = customMcpForm.name.trim(); + const expectsOAuthAuthorization = ( + customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth" + ); const key = `custom:${name || "new"}`; setMcpPresetAction(key); setMcpMessage(null); @@ -2027,15 +2321,26 @@ export function SettingsView({ const payload = await saveCustomMcpServer(client, { name, transport: customMcpForm.transport, + auth: + customMcpForm.transport !== "stdio" && customMcpForm.auth === "oauth" + ? "oauth" + : "", command: customMcpForm.command, args: customMcpForm.args, url: customMcpForm.url, env: customMcpForm.env, - headers: customMcpForm.headers, + headers: + customMcpForm.transport !== "stdio" && customMcpForm.auth === "headers" + ? customMcpForm.headers + : "", tool_timeout: customMcpForm.toolTimeout, }); setMcpPresets(payload); - setMcpMessage(payload.last_action?.message ?? null); + applyMcpActionFeedback( + payload, + false, + expectsOAuthAuthorization ? name : undefined, + ); notifyMcpPresetsChanged(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -2056,7 +2361,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 })); @@ -2077,7 +2382,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 })); @@ -2273,6 +2578,11 @@ export function SettingsView({ filter={appsKindFilter} cliActionKey={cliAppsAction} mcpActionKey={mcpPresetAction} + mcpOAuthFlow={mcpOAuthFlow} + mcpOAuthPopupBlocked={mcpOAuthPopupBlocked} + mcpOAuthCallbackUrl={mcpOAuthCallbackUrl} + mcpOAuthCompleting={mcpOAuthCompleting} + mcpOAuthCallbackError={mcpOAuthCallbackError} cliMessage={cliAppsMessage} cliError={cliAppsError} cliFocusName={cliAppsFocusName} @@ -2287,6 +2597,14 @@ export function SettingsView({ onFilterChange={setAppsKindFilter} onCliAction={handleCliAppAction} onMcpAction={handleMcpPresetAction} + onMcpOAuthConnect={handleMcpOAuthConnect} + onMcpOAuthCancel={() => void handleMcpOAuthCancel()} + onMcpOAuthOpen={handleMcpOAuthOpen} + onMcpOAuthCallbackUrlChange={(value) => { + setMcpOAuthCallbackUrl(value); + setMcpOAuthCallbackError(null); + }} + onMcpOAuthComplete={() => void handleMcpOAuthComplete()} onDismissStatus={() => { setCliAppsMessage(null); setCliAppsError(null); @@ -7336,6 +7654,11 @@ function AppsCatalogSettings({ filter, cliActionKey, mcpActionKey, + mcpOAuthFlow, + mcpOAuthPopupBlocked, + mcpOAuthCallbackUrl, + mcpOAuthCompleting, + mcpOAuthCallbackError, cliMessage, cliError, cliFocusName, @@ -7350,6 +7673,11 @@ function AppsCatalogSettings({ onFilterChange, onCliAction, onMcpAction, + onMcpOAuthConnect, + onMcpOAuthCancel, + onMcpOAuthOpen, + onMcpOAuthCallbackUrlChange, + onMcpOAuthComplete, onDismissStatus, onBackToChat, onMcpFieldChange, @@ -7369,6 +7697,11 @@ function AppsCatalogSettings({ filter: AppsKindFilter; cliActionKey: string | null; mcpActionKey: string | null; + mcpOAuthFlow: McpOAuthFlowPayload | null; + mcpOAuthPopupBlocked: boolean; + mcpOAuthCallbackUrl: string; + mcpOAuthCompleting: boolean; + mcpOAuthCallbackError: string | null; cliMessage: string | null; cliError: string | null; cliFocusName: string | null; @@ -7383,6 +7716,11 @@ function AppsCatalogSettings({ onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; + onMcpOAuthConnect: (name: string) => void; + onMcpOAuthCancel: () => void; + onMcpOAuthOpen: () => void; + onMcpOAuthCallbackUrlChange: (value: string) => void; + onMcpOAuthComplete: () => void; onDismissStatus: () => void; onBackToChat: () => void; onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void; @@ -7399,7 +7737,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[] = [ @@ -7435,7 +7773,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 @@ -7451,8 +7789,17 @@ function AppsCatalogSettings({ mcpError || (!focusedApp ? cliMessage || mcpMessage : null); const statusIsError = Boolean(cliError || mcpError); + const oauthStatusAnnouncement = mcpOAuthFlow + ? mcpOAuthStatusText( + mcpOAuthFlow.status, + mcpOAuthPopupBlocked, + tx, + mcpOAuthFlow.completion_input, + ) + : ""; return (
+
{oauthStatusAnnouncement}
@@ -7489,7 +7836,7 @@ function AppsCatalogSettings({ {requiresRestartPending ? ( @@ -7497,7 +7844,11 @@ function AppsCatalogSettings({
- {tx("settings.apps.featured", "Tools")} + + {filter === "mcp" + ? tx("settings.apps.mcpTools", "MCP tools") + : tx("settings.apps.featured", "Tools")} + {items.length} @@ -7508,7 +7859,7 @@ function AppsCatalogSettings({ {tx("settings.apps.loading", "Loading Apps...")}
) : items.length ? ( -
+
{items.map((item) => item.kind === "cli" ? ( ), @@ -7553,13 +7914,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.", )}

)} @@ -7677,17 +8038,37 @@ function McpAppsCatalogRow({ preset, values, actionKey, + oauthFlow, + oauthPopupBlocked, + oauthCallbackUrl, + oauthCompleting, + oauthCallbackError, showBrandLogos, onFieldChange, onAction, + onOAuthConnect, + onOAuthCancel, + onOAuthOpen, + onOAuthCallbackUrlChange, + onOAuthComplete, onToolsChange, }: { preset: McpPresetInfo; values: Record; actionKey: string | null; + oauthFlow: McpOAuthFlowPayload | null; + oauthPopupBlocked: boolean; + oauthCallbackUrl: string; + oauthCompleting: boolean; + oauthCallbackError: string | null; showBrandLogos: boolean; onFieldChange: (presetName: string, fieldName: string, value: string) => void; onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; + onOAuthConnect: (name: string) => void; + onOAuthCancel: () => void; + onOAuthOpen: () => void; + onOAuthCallbackUrlChange: (value: string) => void; + onOAuthComplete: () => void; onToolsChange: (name: string, enabledTools: string[]) => void; }) { const { t } = useTranslation(); @@ -7699,9 +8080,12 @@ function McpAppsCatalogRow({ const removeBusy = actionKey === `remove:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; - const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy; + const oauthBusy = actionKey === `oauth:${preset.name}`; + const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; + const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy || oauthBusy; const agentPlugin = preset.source === "agent-plugin"; const toggleable = preset.enabled !== undefined; + const isOAuth = preset.auth === "oauth"; const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const hasFields = preset.required_fields.length > 0; const needsSetupInput = missingFields.length > 0; @@ -7720,12 +8104,21 @@ function McpAppsCatalogRow({ const statusLabel = toggleable ? tx("settings.nanobotFeatures.enabled", "Enabled") : mcpPresetStatusLabel(preset.status, tx); + const manualCallback = + oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url); + const callbackInputId = `mcp-oauth-callback-${preset.name}`; + const callbackHelpId = `${callbackInputId}-help`; + const callbackErrorId = `${callbackInputId}-error`; 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; @@ -7746,8 +8139,15 @@ function McpAppsCatalogRow({ }; return ( -
-
+
+
@@ -7755,18 +8155,24 @@ function McpAppsCatalogRow({ {agentPlugin ? tx("settings.apps.filterPlugins", "Plugins") - : tx("settings.apps.mcpLabel", "Integration")} + : tx("settings.apps.mcpLabel", "MCP")}

{detail}

-
+
{readyInstalled ? ( <> onAction("enable", preset.name, values)} - > - - + /> + ) : 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 ? ( - - + /> ) : ( - - - + )}
+ {manualCallback ? ( +
{ + event.preventDefault(); + onOAuthComplete(); + }} + > +
+ +
+

+ {t("settings.oauth.pasteCallbackToContinue")} +

+

+ {tx( + "settings.mcp.manualCallbackHelp", + "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.", + )} +

+
+
+
+ +