mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e77f3f8a4 | ||
|
|
b3b0517611 |
+41
-4
@@ -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` |
|
| **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]
|
> [!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:
|
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,15 @@ remote HTTP endpoint.
|
|||||||
For local interactive setup:
|
For local interactive setup:
|
||||||
|
|
||||||
1. Run `nanobot webui` and open **Apps**.
|
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.
|
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||||
4. Save and restart when prompted.
|
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`:
|
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.
|
- Prefer `enabledTools` over exposing every tool by default.
|
||||||
- Use `toolTimeout` for slow MCP operations.
|
- Use `toolTimeout` for slow MCP operations.
|
||||||
- Use HTTP MCP only for endpoints you trust.
|
- 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.
|
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- Stdio MCP starts a local process; review the command before enabling it.
|
- 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.
|
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||||
- Do not place secrets in command arguments when environment variables or
|
- Do not place secrets in command arguments when environment variables or
|
||||||
headers can be used.
|
headers can be used.
|
||||||
|
|||||||
+9
-4
@@ -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.
|
- **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
|
Installing an adapter does not modify the native desktop or web app it
|
||||||
connects to.
|
connects to.
|
||||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
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
|
Apps intentionally does not list nanobot runtime support packages such as
|
||||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
`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
|
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.
|
provider; mention `@parallel-search` when a turn should use it.
|
||||||
|
|
||||||
After an App or integration is available, mention it from the composer with
|
After an App or MCP server is available, mention it from the composer with `@`
|
||||||
`@` to attach that tool to the next message.
|
to attach that tool to the next message.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
@@ -827,7 +827,8 @@ class EditFileTool(_FsTool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Perform a small, exact replacement in one file by replacing "
|
"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, "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
"multiple times, provide more context or set occurrence, line_hint, "
|
"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.")
|
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||||
|
|
||||||
fp = self._resolve_write(path)
|
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
|
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||||
if not fp.exists():
|
if not file_exists:
|
||||||
if old_text == "":
|
if old_text == "":
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
|
|||||||
+78
-13
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
|
|||||||
from mcp.types import Prompt, Resource
|
from mcp.types import Prompt, Resource
|
||||||
from mcp.types import Tool as MCPToolDefinition
|
from mcp.types import Tool as MCPToolDefinition
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# 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
|
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:
|
def _is_session_terminated(exc: BaseException) -> bool:
|
||||||
"""Return True when the MCP SDK reports a dead client session."""
|
"""Return True when the MCP SDK reports a dead client session."""
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
@@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
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]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
@@ -1001,6 +1024,29 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
return False
|
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":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
cfg.command,
|
cfg.command,
|
||||||
@@ -1038,22 +1084,30 @@ async def connect_mcp_servers(
|
|||||||
**_pinned_transport_kwargs(),
|
**_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(
|
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":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
return False
|
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(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(**http_client_kwargs)
|
||||||
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(),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write, _ = await server_stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
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 "
|
" 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."
|
"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
|
return False
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
@@ -1229,7 +1283,7 @@ async def connect_mcp_servers(
|
|||||||
try:
|
try:
|
||||||
result = await connect_single_server(name, cfg)
|
result = await connect_single_server(name, cfg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
_log_mcp_connection_failure(name, e)
|
||||||
continue
|
continue
|
||||||
if result[1] is not None:
|
if result[1] is not None:
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
@@ -1302,6 +1356,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
current_servers = dict(state._mcp_servers)
|
current_servers = dict(state._mcp_servers)
|
||||||
current_names = set(current_servers)
|
current_names = set(current_servers)
|
||||||
next_names = set(next_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)
|
removed = sorted(current_names - next_names)
|
||||||
added = sorted(next_names - current_names)
|
added = sorted(next_names - current_names)
|
||||||
changed = sorted(
|
changed = sorted(
|
||||||
@@ -1319,9 +1380,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
retry_missing = sorted(
|
retry_missing = sorted(
|
||||||
name
|
name
|
||||||
for name in next_names
|
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}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, MCPConnection] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
if to_connect:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -373,6 +373,7 @@ class MCPServerConfig(Base):
|
|||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
|
|
||||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
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")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -16,6 +16,10 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
|
delete_mcp_oauth_credentials,
|
||||||
|
mcp_oauth_has_credentials,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||||
@@ -337,6 +341,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
|||||||
),
|
),
|
||||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
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(
|
McpPreset(
|
||||||
name="github",
|
name="github",
|
||||||
display_name="GitHub",
|
display_name="GitHub",
|
||||||
@@ -657,6 +718,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
|||||||
return "not_installed" if preset.install_supported else "coming_soon"
|
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):
|
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||||
return "missing_credentials"
|
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):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
return "missing_dependency"
|
return "missing_dependency"
|
||||||
return "configured"
|
return "configured"
|
||||||
@@ -702,6 +765,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": server.auth if server and server.auth else None,
|
||||||
"command": server.command if server and server.command else None,
|
"command": server.command if server and server.command else None,
|
||||||
"args": list(server.args) 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,
|
"url": _connection_summary(server) if server and server.url else None,
|
||||||
@@ -752,6 +816,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"command": cfg.command or None,
|
"command": cfg.command or None,
|
||||||
"url": _connection_summary(cfg) if cfg.url else None,
|
"url": _connection_summary(cfg) if cfg.url else None,
|
||||||
})
|
})
|
||||||
@@ -779,7 +844,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||||
cfg = configured_servers.get(preset.name)
|
cfg = configured_servers.get(preset.name)
|
||||||
status = _status_for(preset, cfg)
|
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)
|
logo_url = _favicon_url(preset.brand_domain)
|
||||||
return {
|
return {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
@@ -788,6 +853,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
|||||||
"description": preset.description,
|
"description": preset.description,
|
||||||
"docs_url": preset.docs_url,
|
"docs_url": preset.docs_url,
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||||
"requires": preset.requires,
|
"requires": preset.requires,
|
||||||
"note": preset.note,
|
"note": preset.note,
|
||||||
"install_supported": preset.install_supported,
|
"install_supported": preset.install_supported,
|
||||||
@@ -814,7 +880,11 @@ def _custom_payload(
|
|||||||
transport = cfg.type
|
transport = cfg.type
|
||||||
if not transport:
|
if not transport:
|
||||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
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 {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"display_name": name,
|
"display_name": name,
|
||||||
@@ -822,12 +892,13 @@ def _custom_payload(
|
|||||||
"description": "Custom MCP server from nanobot config.",
|
"description": "Custom MCP server from nanobot config.",
|
||||||
"docs_url": "",
|
"docs_url": "",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"requires": "",
|
"requires": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"install_supported": True,
|
"install_supported": True,
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"configured": True,
|
"configured": configured,
|
||||||
"available": _config_available(cfg),
|
"available": configured and _config_available(cfg),
|
||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": None,
|
"logo_url": None,
|
||||||
"brand_color": "#64748B",
|
"brand_color": "#64748B",
|
||||||
@@ -1127,6 +1198,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
|||||||
return normalized # type: ignore[return-value]
|
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:
|
def _validated_server_name(name: str) -> str:
|
||||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||||
raise McpPresetError("invalid MCP server name")
|
raise McpPresetError("invalid MCP server name")
|
||||||
@@ -1142,6 +1239,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("stdio MCP servers require a command")
|
raise McpPresetError("stdio MCP servers require a command")
|
||||||
if transport in {"sse", "streamableHttp"} and not url:
|
if transport in {"sse", "streamableHttp"} and not url:
|
||||||
raise McpPresetError("remote MCP servers require a 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()
|
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||||
if raw_timeout:
|
if raw_timeout:
|
||||||
@@ -1151,12 +1255,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||||
cfg = MCPServerConfig(
|
cfg = MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=_parse_string_list(_query_first(query, "args")),
|
args=_parse_string_list(_query_first(query, "args")),
|
||||||
env=_parse_string_map(_query_first(query, "env")),
|
env=_parse_string_map(_query_first(query, "env")),
|
||||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=_parse_string_map(_query_first(query, "headers")),
|
headers=headers,
|
||||||
tool_timeout=tool_timeout,
|
tool_timeout=tool_timeout,
|
||||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||||
)
|
)
|
||||||
@@ -1201,6 +1306,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
headers = cast(dict[object, object], headers_value)
|
headers = cast(dict[object, object], headers_value)
|
||||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
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")
|
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):
|
if not isinstance(enabled_tools_value, list):
|
||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
else:
|
else:
|
||||||
@@ -1209,12 +1321,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
return server_name, MCPServerConfig(
|
return server_name, MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=cast(list[str], args),
|
args=cast(list[str], args),
|
||||||
env=cast(dict[str, str], env),
|
env=cast(dict[str, str], env),
|
||||||
cwd=cwd if transport == "stdio" else "",
|
cwd=cwd if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=cast(dict[str, str], headers),
|
headers=typed_headers,
|
||||||
tool_timeout=timeout_int,
|
tool_timeout=timeout_int,
|
||||||
enabled_tools=cast(list[str], enabled_tools_value),
|
enabled_tools=cast(list[str], enabled_tools_value),
|
||||||
)
|
)
|
||||||
@@ -1239,6 +1352,15 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
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(
|
def custom_mcp_action(
|
||||||
action: str,
|
action: str,
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
@@ -1248,8 +1370,11 @@ def custom_mcp_action(
|
|||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
if action == "custom":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
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
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
if delete_credentials:
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(
|
||||||
last_action=_server_action_message(action, name),
|
last_action=_server_action_message(action, name),
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
@@ -1259,8 +1384,15 @@ def custom_mcp_action(
|
|||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
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)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
for name in delete_credentials:
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(
|
||||||
last_action={
|
last_action={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -1289,6 +1421,27 @@ def custom_mcp_action(
|
|||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
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(
|
def mcp_presets_action(
|
||||||
action: str,
|
action: str,
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
@@ -1328,6 +1481,7 @@ def mcp_presets_action(
|
|||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ request mapping and response shaping.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import html
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import time
|
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.image_generation import request_image_generation_reload
|
||||||
from nanobot.agent.tools.mcp import request_mcp_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.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels._setup import channel_setup_spec
|
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.pairing import approve_code, deny_code, list_pending
|
||||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
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 is_local_browser_request as _is_local_browser_request
|
||||||
from nanobot.webui.http_utils import query_first as _query_first
|
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 (
|
from nanobot.webui.nanobot_features_api import (
|
||||||
nanobot_feature_instance_target,
|
nanobot_feature_instance_target,
|
||||||
nanobot_features_action,
|
nanobot_features_action,
|
||||||
@@ -80,6 +84,7 @@ _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
|||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_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:
|
def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||||
@@ -130,6 +135,9 @@ _SETTINGS_MUTATION_PATHS = frozenset({
|
|||||||
"/api/settings/channels/configure",
|
"/api/settings/channels/configure",
|
||||||
"/api/settings/pairing/approve",
|
"/api/settings/pairing/approve",
|
||||||
"/api/settings/pairing/deny",
|
"/api/settings/pairing/deny",
|
||||||
|
"/api/settings/mcp-oauth/start",
|
||||||
|
"/api/settings/mcp-oauth/complete",
|
||||||
|
"/api/settings/mcp-oauth/cancel",
|
||||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,6 +185,7 @@ class WebUISettingsRouter:
|
|||||||
runtime_capabilities: dict[str, Any],
|
runtime_capabilities: dict[str, Any],
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
|
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@@ -189,6 +198,8 @@ class WebUISettingsRouter:
|
|||||||
self._runtime_capabilities = runtime_capabilities
|
self._runtime_capabilities = runtime_capabilities
|
||||||
self._channel_feature_action = channel_feature_action
|
self._channel_feature_action = channel_feature_action
|
||||||
self._channel_runtime_status = channel_runtime_status
|
self._channel_runtime_status = channel_runtime_status
|
||||||
|
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
||||||
|
self._mcp_oauth = McpOAuthManager()
|
||||||
self._restart_sections: set[str] = set()
|
self._restart_sections: set[str] = set()
|
||||||
self._channel_connectors: dict[str, Any] = {}
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -202,6 +213,8 @@ class WebUISettingsRouter:
|
|||||||
405,
|
405,
|
||||||
"WebUI mutations require an authenticated WebSocket",
|
"WebUI mutations require an authenticated WebSocket",
|
||||||
)
|
)
|
||||||
|
if path == MCP_OAUTH_CALLBACK_PATH:
|
||||||
|
return self._handle_mcp_oauth_callback(request)
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
if path == "/api/settings/usage":
|
if path == "/api/settings/usage":
|
||||||
@@ -281,6 +294,14 @@ class WebUISettingsRouter:
|
|||||||
return self._handle_settings_pairing_action(request, "deny")
|
return self._handle_settings_pairing_action(request, "deny")
|
||||||
if path == "/api/settings/mcp-presets":
|
if path == "/api/settings/mcp-presets":
|
||||||
return await self._handle_settings_mcp_presets(request)
|
return await self._handle_settings_mcp_presets(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/start":
|
||||||
|
return await self._handle_mcp_oauth_start(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/status":
|
||||||
|
return await self._handle_mcp_oauth_status(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/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":
|
if path == "/api/settings/version-check":
|
||||||
return await self._handle_settings_version_check(request)
|
return await self._handle_settings_version_check(request)
|
||||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||||
@@ -1230,6 +1251,147 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
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 = "<script>setTimeout(() => window.close(), 700)</script>" if ok else ""
|
||||||
|
body = (
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
f"<title>{safe_title}</title><style>"
|
||||||
|
"body{font:16px system-ui;margin:0;min-height:100vh;display:grid;place-items:center;"
|
||||||
|
"background:#f7f7f6;color:#171717}.card{max-width:34rem;margin:2rem;padding:2rem;"
|
||||||
|
"border:1px solid #ddd;border-radius:16px;background:white}h1{font-size:1.35rem}"
|
||||||
|
"p{line-height:1.55;color:#555}</style></head><body><main class='card'>"
|
||||||
|
f"<h1>{safe_title}</h1><p>{safe_message}</p></main>{close_script}</body></html>"
|
||||||
|
).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:
|
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import time
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from websockets.datastructures import Headers
|
from websockets.datastructures import Headers
|
||||||
@@ -166,6 +166,9 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
"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 = {
|
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||||
@@ -344,6 +347,7 @@ class GatewayHTTPHandler:
|
|||||||
runtime_capabilities=self._capabilities,
|
runtime_capabilities=self._capabilities,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
@@ -617,6 +621,14 @@ class GatewayHTTPHandler:
|
|||||||
expected_path = _normalize_config_path(self.config.path)
|
expected_path = _normalize_config_path(self.config.path)
|
||||||
return f"{scheme}://{host}{expected_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 -----------------------------------------------------
|
# -- Session routes -----------------------------------------------------
|
||||||
|
|
||||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
|
|||||||
@@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
|||||||
await loop.close_mcp()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_tool_reconnects_after_session_terminated(
|
async def test_mcp_tool_reconnects_after_session_terminated(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ class TestEditFileTool:
|
|||||||
assert "Successfully" in result
|
assert "Successfully" in result
|
||||||
assert f.read_text() == "hello earth"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_crlf_normalisation(self, tool, tmp_path):
|
async def test_crlf_normalisation(self, tool, tmp_path):
|
||||||
f = tmp_path / "crlf.py"
|
f = tmp_path / "crlf.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"
|
||||||
@@ -826,19 +826,23 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|||||||
) -> None:
|
) -> None:
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
def _error(message: str, *args: object) -> None:
|
|
||||||
messages.append(message.format(*args))
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _broken_stdio_client(_params: object):
|
async def _broken_stdio_client(_params: object):
|
||||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
||||||
yield # pragma: no cover
|
yield # pragma: no cover
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
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()
|
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 stacks == {}
|
||||||
assert messages
|
assert messages
|
||||||
@@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|||||||
assert "stderr" in messages[-1]
|
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.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"config",
|
"config",
|
||||||
@@ -1210,6 +1244,129 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
|||||||
assert timeout.pool == 30.0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.shared.auth import OAuthToken
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
@@ -38,6 +40,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
"aws-docs",
|
"aws-docs",
|
||||||
"brave-search",
|
"brave-search",
|
||||||
"postman",
|
"postman",
|
||||||
|
"xmind",
|
||||||
|
"notion",
|
||||||
|
"linear",
|
||||||
}.issubset(names)
|
}.issubset(names)
|
||||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||||
assert browserbase["installed"] is False
|
assert browserbase["installed"] is False
|
||||||
@@ -55,6 +60,37 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_action("enable", {"name": ["xmind"]})
|
||||||
|
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "xmind")
|
||||||
|
assert row["installed"] is True
|
||||||
|
assert row["configured"] is False
|
||||||
|
assert row["status"] == "authorization_required"
|
||||||
|
assert row["transport"] == "streamableHttp"
|
||||||
|
assert row["auth"] == "oauth"
|
||||||
|
config = load_config()
|
||||||
|
cfg = config.tools.mcp_servers["xmind"]
|
||||||
|
assert cfg.type == "streamableHttp"
|
||||||
|
assert cfg.auth == "oauth"
|
||||||
|
assert cfg.url == "https://app.xmind.com/api/mcp"
|
||||||
|
|
||||||
|
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
|
||||||
|
connected = mcp_presets_payload()
|
||||||
|
row = next(item for item in connected["presets"] if item["name"] == "xmind")
|
||||||
|
assert row["configured"] is True
|
||||||
|
assert row["status"] == "configured"
|
||||||
|
|
||||||
|
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||||
|
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
||||||
|
|
||||||
|
|
||||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -296,11 +332,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
|
|||||||
assert "<redacted>" in payload["last_action"]["error"]
|
assert "<redacted>" 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)
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
with pytest.raises(McpPresetError) as exc:
|
with pytest.raises(McpPresetError) as exc:
|
||||||
mcp_presets_action("enable", {"name": ["linear"]})
|
mcp_presets_action("enable", {"name": ["asana"]})
|
||||||
|
|
||||||
assert exc.value.status == 404
|
assert exc.value.status == 404
|
||||||
|
|
||||||
@@ -414,6 +450,72 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
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(
|
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -28,6 +28,7 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
),
|
),
|
||||||
runtime_surface="browser",
|
runtime_surface="browser",
|
||||||
runtime_capabilities={},
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
("provider", "authorization_response"),
|
("provider", "authorization_response"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -319,8 +319,8 @@
|
|||||||
"filterInstalled": "Enabled",
|
"filterInstalled": "Enabled",
|
||||||
"filterNotInstalled": "Not enabled",
|
"filterNotInstalled": "Not enabled",
|
||||||
"searchPlaceholder": "Search MCP presets",
|
"searchPlaceholder": "Search MCP presets",
|
||||||
"moreOptions": "Add integration",
|
"moreOptions": "Add MCP server",
|
||||||
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
|
"moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.",
|
||||||
"customTitle": "Custom MCP",
|
"customTitle": "Custom MCP",
|
||||||
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
||||||
"customAction": "Custom",
|
"customAction": "Custom",
|
||||||
@@ -328,9 +328,14 @@
|
|||||||
"serverName": "Server name",
|
"serverName": "Server name",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"authNone": "None",
|
||||||
|
"authHeaders": "Headers",
|
||||||
"command": "Command",
|
"command": "Command",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "Headers JSON",
|
||||||
|
"oauthAfterSave": "Save the server, then select Connect to sign in.",
|
||||||
|
"headersHelp": "Add the request headers used by this server.",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Tool timeout",
|
"timeout": "Tool timeout",
|
||||||
"advancedOptions": "Advanced options",
|
"advancedOptions": "Advanced options",
|
||||||
@@ -357,6 +362,21 @@
|
|||||||
"keepExisting": "Leave blank to keep existing",
|
"keepExisting": "Leave blank to keep existing",
|
||||||
"statusConfigured": "Configured",
|
"statusConfigured": "Configured",
|
||||||
"statusMissingCredentials": "Needs key",
|
"statusMissingCredentials": "Needs key",
|
||||||
|
"connectingAccount": "Connecting {{name}}",
|
||||||
|
"connectingLabel": "Connecting…",
|
||||||
|
"continueSignIn": "Continue sign-in",
|
||||||
|
"preparingSignIn": "Preparing secure sign-in…",
|
||||||
|
"openSignInToContinue": "Open the sign-in page to continue.",
|
||||||
|
"finishSignInInBrowser": "Finish signing in in the browser window.",
|
||||||
|
"manualCallbackRequired": "Finish signing in, then paste the callback URL into nanobot.",
|
||||||
|
"manualCallbackHelp": "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
|
||||||
|
"finishingConnection": "Finishing connection…",
|
||||||
|
"activatingTools": "Activating tools…",
|
||||||
|
"connected": "Connected.",
|
||||||
|
"connectionFailed": "Connection failed.",
|
||||||
|
"connectionCancelled": "Connection cancelled.",
|
||||||
|
"reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
|
||||||
|
"oauthFailed": "Unable to connect. Try signing in again.",
|
||||||
"statusMissingDependency": "Needs dependency",
|
"statusMissingDependency": "Needs dependency",
|
||||||
"statusComingSoon": "Coming soon",
|
"statusComingSoon": "Coming soon",
|
||||||
"comingSoon": "Coming soon",
|
"comingSoon": "Coming soon",
|
||||||
@@ -584,27 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"description": "Add tools to nanobot, then @ them in chat.",
|
||||||
"cliLabel": "App",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integration",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
"filterAll": "Ready",
|
"filterAll": "Ready",
|
||||||
"filterPlugins": "Plugins",
|
"filterPlugins": "Plugins",
|
||||||
"filterCli": "Apps",
|
"filterCli": "Apps",
|
||||||
"filterMcp": "Integrations",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} ready",
|
"enabledSummary": "{{count}} ready",
|
||||||
"caption": "{{cli}} apps · {{mcp}} integrations",
|
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
||||||
"searchPlaceholder": "Search tools",
|
"searchPlaceholder": "Search tools",
|
||||||
"featured": "Tools",
|
"featured": "Tools",
|
||||||
|
"mcpTools": "MCP tools",
|
||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No tools match your search.",
|
"empty": "No tools match your search.",
|
||||||
"emptyApps": "No apps available.",
|
"emptyApps": "No apps available.",
|
||||||
"emptyIntegrations": "No integrations available.",
|
"emptyIntegrations": "No MCP tools available.",
|
||||||
"emptyReady": "No tools are ready yet.",
|
"emptyReady": "No tools are ready yet.",
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
"browseApps": "Browse apps",
|
"browseApps": "Browse apps",
|
||||||
"browseIntegrations": "Browse integrations",
|
"browseIntegrations": "Browse MCP tools",
|
||||||
"emptyIntegrationsHint": "Add a custom integration below.",
|
"emptyIntegrationsHint": "Add a custom MCP server below.",
|
||||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
"restartRequired": "Restart nanobot to apply updated apps and MCP tools."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "Habilitados",
|
"filterInstalled": "Habilitados",
|
||||||
"filterNotInstalled": "No habilitados",
|
"filterNotInstalled": "No habilitados",
|
||||||
"searchPlaceholder": "Buscar preajustes MCP",
|
"searchPlaceholder": "Buscar preajustes MCP",
|
||||||
"moreOptions": "Más opciones de MCP",
|
"moreOptions": "Añadir servidor MCP",
|
||||||
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
|
"moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.",
|
||||||
"customTitle": "MCP personalizado",
|
"customTitle": "MCP personalizado",
|
||||||
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
|
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
|
||||||
"customAction": "Personalizado",
|
"customAction": "Personalizado",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "Nombre del servidor",
|
"serverName": "Nombre del servidor",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
|
"authentication": "Autenticación",
|
||||||
|
"authNone": "Ninguna",
|
||||||
|
"authHeaders": "Encabezados",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Argumentos JSON",
|
||||||
"headers": "Encabezados JSON",
|
"headers": "Encabezados JSON",
|
||||||
|
"oauthAfterSave": "Guarda el servidor y selecciona Conectar para iniciar sesión.",
|
||||||
|
"headersHelp": "Añade los encabezados de solicitud que utiliza este servidor.",
|
||||||
"env": "Entorno JSON",
|
"env": "Entorno JSON",
|
||||||
"timeout": "Tiempo límite de herramienta",
|
"timeout": "Tiempo límite de herramienta",
|
||||||
"advancedOptions": "Opciones avanzadas",
|
"advancedOptions": "Opciones avanzadas",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "Déjalo en blanco para conservar el valor actual",
|
"keepExisting": "Déjalo en blanco para conservar el valor actual",
|
||||||
"statusConfigured": "Configurado",
|
"statusConfigured": "Configurado",
|
||||||
"statusMissingCredentials": "Necesita clave",
|
"statusMissingCredentials": "Necesita clave",
|
||||||
|
"connectingAccount": "Conectando {{name}}",
|
||||||
|
"connectingLabel": "Conectando…",
|
||||||
|
"continueSignIn": "Continuar inicio de sesión",
|
||||||
|
"preparingSignIn": "Preparando un inicio de sesión seguro…",
|
||||||
|
"openSignInToContinue": "Abre la página de inicio de sesión para continuar.",
|
||||||
|
"finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.",
|
||||||
|
"manualCallbackRequired": "Termina de iniciar sesión y pega la URL de devolución en nanobot.",
|
||||||
|
"manualCallbackHelp": "Después de aprobar el acceso, la página de localhost no se cargará. Copia la URL completa de la barra de direcciones y pégala aquí.",
|
||||||
|
"finishingConnection": "Finalizando la conexión…",
|
||||||
|
"activatingTools": "Activando herramientas…",
|
||||||
|
"connected": "Conectado.",
|
||||||
|
"connectionFailed": "Error de conexión.",
|
||||||
|
"connectionCancelled": "Conexión cancelada.",
|
||||||
|
"reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.",
|
||||||
|
"oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.",
|
||||||
"statusMissingDependency": "Necesita dependencia",
|
"statusMissingDependency": "Necesita dependencia",
|
||||||
"statusComingSoon": "Próximamente",
|
"statusComingSoon": "Próximamente",
|
||||||
"comingSoon": "Próximamente",
|
"comingSoon": "Próximamente",
|
||||||
@@ -571,27 +591,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicaciones",
|
"filterCli": "Aplicaciones",
|
||||||
"filterMcp": "Integraciones",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} listos",
|
"enabledSummary": "{{count}} listos",
|
||||||
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
|
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
||||||
"searchPlaceholder": "Buscar aplicaciones",
|
"searchPlaceholder": "Buscar aplicaciones",
|
||||||
"featured": "Herramientas",
|
"featured": "Herramientas",
|
||||||
|
"mcpTools": "Herramientas MCP",
|
||||||
"loading": "Cargando aplicaciones...",
|
"loading": "Cargando aplicaciones...",
|
||||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||||
"emptyApps": "No hay aplicaciones disponibles.",
|
"emptyApps": "No hay aplicaciones disponibles.",
|
||||||
"emptyIntegrations": "No hay integraciones disponibles.",
|
"emptyIntegrations": "No hay herramientas MCP disponibles.",
|
||||||
"emptyReady": "Todavía no hay herramientas listas.",
|
"emptyReady": "Todavía no hay herramientas listas.",
|
||||||
"clearSearch": "Borrar búsqueda",
|
"clearSearch": "Borrar búsqueda",
|
||||||
"browseApps": "Explorar aplicaciones",
|
"browseApps": "Explorar aplicaciones",
|
||||||
"browseIntegrations": "Explorar integraciones",
|
"browseIntegrations": "Explorar herramientas MCP",
|
||||||
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
|
"emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.",
|
||||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "Activés",
|
"filterInstalled": "Activés",
|
||||||
"filterNotInstalled": "Non activés",
|
"filterNotInstalled": "Non activés",
|
||||||
"searchPlaceholder": "Rechercher des préréglages MCP",
|
"searchPlaceholder": "Rechercher des préréglages MCP",
|
||||||
"moreOptions": "Plus d'options MCP",
|
"moreOptions": "Ajouter un serveur MCP",
|
||||||
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
"moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.",
|
||||||
"customTitle": "MCP personnalisé",
|
"customTitle": "MCP personnalisé",
|
||||||
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
||||||
"customAction": "Personnalisé",
|
"customAction": "Personnalisé",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "Nom du serveur",
|
"serverName": "Nom du serveur",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Authentification",
|
||||||
|
"authNone": "Aucune",
|
||||||
|
"authHeaders": "En-têtes",
|
||||||
"command": "Commande",
|
"command": "Commande",
|
||||||
"args": "Arguments JSON",
|
"args": "Arguments JSON",
|
||||||
"headers": "En-têtes JSON",
|
"headers": "En-têtes JSON",
|
||||||
|
"oauthAfterSave": "Enregistrez le serveur, puis sélectionnez Se connecter pour vous identifier.",
|
||||||
|
"headersHelp": "Ajoutez les en-têtes de requête utilisés par ce serveur.",
|
||||||
"env": "Environnement JSON",
|
"env": "Environnement JSON",
|
||||||
"timeout": "Délai d'outil",
|
"timeout": "Délai d'outil",
|
||||||
"advancedOptions": "Options avancées",
|
"advancedOptions": "Options avancées",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
||||||
"statusConfigured": "Configuré",
|
"statusConfigured": "Configuré",
|
||||||
"statusMissingCredentials": "Clé requise",
|
"statusMissingCredentials": "Clé requise",
|
||||||
|
"connectingAccount": "Connexion à {{name}}",
|
||||||
|
"connectingLabel": "Connexion…",
|
||||||
|
"continueSignIn": "Continuer la connexion",
|
||||||
|
"preparingSignIn": "Préparation d’une connexion sécurisée…",
|
||||||
|
"openSignInToContinue": "Ouvrez la page de connexion pour continuer.",
|
||||||
|
"finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.",
|
||||||
|
"manualCallbackRequired": "Terminez la connexion, puis collez l’URL de rappel dans nanobot.",
|
||||||
|
"manualCallbackHelp": "Après avoir autorisé l’accès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre d’adresse et collez-la ici.",
|
||||||
|
"finishingConnection": "Finalisation de la connexion…",
|
||||||
|
"activatingTools": "Activation des outils…",
|
||||||
|
"connected": "Connecté.",
|
||||||
|
"connectionFailed": "Échec de la connexion.",
|
||||||
|
"connectionCancelled": "Connexion annulée.",
|
||||||
|
"reloadFailed": "Connexion réussie, mais nanobot n’a pas pu activer les outils. Essayez de redémarrer nanobot.",
|
||||||
|
"oauthFailed": "Connexion impossible. Essayez de vous reconnecter.",
|
||||||
"statusMissingDependency": "Dépendance requise",
|
"statusMissingDependency": "Dépendance requise",
|
||||||
"statusComingSoon": "Bientôt disponible",
|
"statusComingSoon": "Bientôt disponible",
|
||||||
"comingSoon": "Bientôt disponible",
|
"comingSoon": "Bientôt disponible",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
"filterPlugins": "Extensions",
|
"filterPlugins": "Extensions",
|
||||||
"filterCli": "Applications",
|
"filterCli": "Applications",
|
||||||
"filterMcp": "Intégrations",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} prêts",
|
"enabledSummary": "{{count}} prêts",
|
||||||
"caption": "{{cli}} applications · {{mcp}} intégrations",
|
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
||||||
"searchPlaceholder": "Rechercher des applications",
|
"searchPlaceholder": "Rechercher des applications",
|
||||||
"featured": "Outils",
|
"featured": "Outils",
|
||||||
|
"mcpTools": "Outils MCP",
|
||||||
"loading": "Chargement des applications...",
|
"loading": "Chargement des applications...",
|
||||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||||
"emptyApps": "Aucune application disponible.",
|
"emptyApps": "Aucune application disponible.",
|
||||||
"emptyIntegrations": "Aucune intégration disponible.",
|
"emptyIntegrations": "Aucun outil MCP disponible.",
|
||||||
"emptyReady": "Aucun outil n’est encore prêt.",
|
"emptyReady": "Aucun outil n’est encore prêt.",
|
||||||
"clearSearch": "Effacer la recherche",
|
"clearSearch": "Effacer la recherche",
|
||||||
"browseApps": "Parcourir les applications",
|
"browseApps": "Parcourir les applications",
|
||||||
"browseIntegrations": "Parcourir les intégrations",
|
"browseIntegrations": "Parcourir les outils MCP",
|
||||||
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
|
"emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.",
|
||||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
"restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "Aktif",
|
"filterInstalled": "Aktif",
|
||||||
"filterNotInstalled": "Tidak aktif",
|
"filterNotInstalled": "Tidak aktif",
|
||||||
"searchPlaceholder": "Cari prasetel MCP",
|
"searchPlaceholder": "Cari prasetel MCP",
|
||||||
"moreOptions": "Opsi MCP lainnya",
|
"moreOptions": "Tambahkan server MCP",
|
||||||
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
|
"moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.",
|
||||||
"customTitle": "MCP khusus",
|
"customTitle": "MCP khusus",
|
||||||
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
|
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
|
||||||
"customAction": "Khusus",
|
"customAction": "Khusus",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "Nama server",
|
"serverName": "Nama server",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Autentikasi",
|
||||||
|
"authNone": "Tidak ada",
|
||||||
|
"authHeaders": "Header",
|
||||||
"command": "Perintah",
|
"command": "Perintah",
|
||||||
"args": "Argumen JSON",
|
"args": "Argumen JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Header JSON",
|
||||||
|
"oauthAfterSave": "Simpan server, lalu pilih Hubungkan untuk masuk.",
|
||||||
|
"headersHelp": "Tambahkan header permintaan yang digunakan server ini.",
|
||||||
"env": "Lingkungan JSON",
|
"env": "Lingkungan JSON",
|
||||||
"timeout": "Batas waktu alat",
|
"timeout": "Batas waktu alat",
|
||||||
"advancedOptions": "Opsi lanjutan",
|
"advancedOptions": "Opsi lanjutan",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
|
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
|
||||||
"statusConfigured": "Terkonfigurasi",
|
"statusConfigured": "Terkonfigurasi",
|
||||||
"statusMissingCredentials": "Butuh kunci",
|
"statusMissingCredentials": "Butuh kunci",
|
||||||
|
"connectingAccount": "Menghubungkan {{name}}",
|
||||||
|
"connectingLabel": "Menghubungkan…",
|
||||||
|
"continueSignIn": "Lanjutkan masuk",
|
||||||
|
"preparingSignIn": "Menyiapkan proses masuk yang aman…",
|
||||||
|
"openSignInToContinue": "Buka halaman masuk untuk melanjutkan.",
|
||||||
|
"finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.",
|
||||||
|
"manualCallbackRequired": "Selesaikan proses masuk, lalu tempel URL callback ke nanobot.",
|
||||||
|
"manualCallbackHelp": "Setelah menyetujui akses, halaman localhost tidak akan terbuka. Salin URL lengkap dari bilah alamat lalu tempel di sini.",
|
||||||
|
"finishingConnection": "Menyelesaikan koneksi…",
|
||||||
|
"activatingTools": "Mengaktifkan alat…",
|
||||||
|
"connected": "Terhubung.",
|
||||||
|
"connectionFailed": "Koneksi gagal.",
|
||||||
|
"connectionCancelled": "Koneksi dibatalkan.",
|
||||||
|
"reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.",
|
||||||
|
"oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.",
|
||||||
"statusMissingDependency": "Butuh dependensi",
|
"statusMissingDependency": "Butuh dependensi",
|
||||||
"statusComingSoon": "Segera hadir",
|
"statusComingSoon": "Segera hadir",
|
||||||
"comingSoon": "Segera hadir",
|
"comingSoon": "Segera hadir",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||||
"cliLabel": "Aplikasi",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
"filterAll": "Siap",
|
"filterAll": "Siap",
|
||||||
"filterPlugins": "Plugin",
|
"filterPlugins": "Plugin",
|
||||||
"filterCli": "Aplikasi",
|
"filterCli": "Aplikasi",
|
||||||
"filterMcp": "Integrasi",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} siap",
|
"enabledSummary": "{{count}} siap",
|
||||||
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
|
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
||||||
"searchPlaceholder": "Cari aplikasi",
|
"searchPlaceholder": "Cari aplikasi",
|
||||||
"featured": "Alat",
|
"featured": "Alat",
|
||||||
|
"mcpTools": "Alat MCP",
|
||||||
"loading": "Memuat aplikasi...",
|
"loading": "Memuat aplikasi...",
|
||||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||||
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
||||||
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
|
"emptyIntegrations": "Tidak ada alat MCP yang tersedia.",
|
||||||
"emptyReady": "Belum ada alat yang siap.",
|
"emptyReady": "Belum ada alat yang siap.",
|
||||||
"clearSearch": "Hapus pencarian",
|
"clearSearch": "Hapus pencarian",
|
||||||
"browseApps": "Jelajahi aplikasi",
|
"browseApps": "Jelajahi aplikasi",
|
||||||
"browseIntegrations": "Jelajahi integrasi",
|
"browseIntegrations": "Jelajahi alat MCP",
|
||||||
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
|
"emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.",
|
||||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "有効",
|
"filterInstalled": "有効",
|
||||||
"filterNotInstalled": "未有効",
|
"filterNotInstalled": "未有効",
|
||||||
"searchPlaceholder": "MCP プリセットを検索",
|
"searchPlaceholder": "MCP プリセットを検索",
|
||||||
"moreOptions": "その他の MCP オプション",
|
"moreOptions": "MCP サーバーを追加",
|
||||||
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
|
"moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。",
|
||||||
"customTitle": "カスタム MCP",
|
"customTitle": "カスタム MCP",
|
||||||
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
|
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
|
||||||
"customAction": "カスタム",
|
"customAction": "カスタム",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "サーバー名",
|
"serverName": "サーバー名",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "トランスポート",
|
"transport": "トランスポート",
|
||||||
|
"authentication": "認証",
|
||||||
|
"authNone": "なし",
|
||||||
|
"authHeaders": "ヘッダー",
|
||||||
"command": "コマンド",
|
"command": "コマンド",
|
||||||
"args": "引数 JSON",
|
"args": "引数 JSON",
|
||||||
"headers": "ヘッダー JSON",
|
"headers": "ヘッダー JSON",
|
||||||
|
"oauthAfterSave": "サーバーを保存してから、[接続]を選択してサインインします。",
|
||||||
|
"headersHelp": "このサーバーで使用するリクエストヘッダーを追加します。",
|
||||||
"env": "環境変数 JSON",
|
"env": "環境変数 JSON",
|
||||||
"timeout": "ツールのタイムアウト",
|
"timeout": "ツールのタイムアウト",
|
||||||
"advancedOptions": "詳細オプション",
|
"advancedOptions": "詳細オプション",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "既存の値を維持するには空欄のままにします",
|
"keepExisting": "既存の値を維持するには空欄のままにします",
|
||||||
"statusConfigured": "設定済み",
|
"statusConfigured": "設定済み",
|
||||||
"statusMissingCredentials": "キーが必要",
|
"statusMissingCredentials": "キーが必要",
|
||||||
|
"connectingAccount": "{{name}} に接続しています",
|
||||||
|
"connectingLabel": "接続中…",
|
||||||
|
"continueSignIn": "サインインを続ける",
|
||||||
|
"preparingSignIn": "安全なサインインを準備しています…",
|
||||||
|
"openSignInToContinue": "サインインページを開いて続行してください。",
|
||||||
|
"finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。",
|
||||||
|
"manualCallbackRequired": "サインインを完了し、コールバック URL を nanobot に貼り付けてください。",
|
||||||
|
"manualCallbackHelp": "アクセスを承認すると localhost ページは開きません。アドレスバーから完全な URL をコピーして、ここに貼り付けてください。",
|
||||||
|
"finishingConnection": "接続を完了しています…",
|
||||||
|
"activatingTools": "ツールを有効にしています…",
|
||||||
|
"connected": "接続しました。",
|
||||||
|
"connectionFailed": "接続に失敗しました。",
|
||||||
|
"connectionCancelled": "接続をキャンセルしました。",
|
||||||
|
"reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。",
|
||||||
|
"oauthFailed": "接続できません。もう一度サインインしてください。",
|
||||||
"statusMissingDependency": "依存関係が必要",
|
"statusMissingDependency": "依存関係が必要",
|
||||||
"statusComingSoon": "近日公開",
|
"statusComingSoon": "近日公開",
|
||||||
"comingSoon": "近日公開",
|
"comingSoon": "近日公開",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "連携",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
"filterAll": "使用可能",
|
"filterAll": "使用可能",
|
||||||
"filterPlugins": "プラグイン",
|
"filterPlugins": "プラグイン",
|
||||||
"filterCli": "アプリ",
|
"filterCli": "アプリ",
|
||||||
"filterMcp": "連携",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 件使用可能",
|
"enabledSummary": "{{count}} 件使用可能",
|
||||||
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
|
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
||||||
"searchPlaceholder": "アプリを検索",
|
"searchPlaceholder": "アプリを検索",
|
||||||
"featured": "ツール",
|
"featured": "ツール",
|
||||||
|
"mcpTools": "MCP ツール",
|
||||||
"loading": "アプリを読み込み中...",
|
"loading": "アプリを読み込み中...",
|
||||||
"empty": "検索条件に一致するツールはありません。",
|
"empty": "検索条件に一致するツールはありません。",
|
||||||
"emptyApps": "利用できるアプリはありません。",
|
"emptyApps": "利用できるアプリはありません。",
|
||||||
"emptyIntegrations": "利用できる連携はありません。",
|
"emptyIntegrations": "利用できる MCP ツールはありません。",
|
||||||
"emptyReady": "使用可能なツールはまだありません。",
|
"emptyReady": "使用可能なツールはまだありません。",
|
||||||
"clearSearch": "検索をクリア",
|
"clearSearch": "検索をクリア",
|
||||||
"browseApps": "アプリを見る",
|
"browseApps": "アプリを見る",
|
||||||
"browseIntegrations": "連携を見る",
|
"browseIntegrations": "MCP ツールを見る",
|
||||||
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
|
"emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。",
|
||||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
"restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "활성화됨",
|
"filterInstalled": "활성화됨",
|
||||||
"filterNotInstalled": "비활성",
|
"filterNotInstalled": "비활성",
|
||||||
"searchPlaceholder": "MCP 프리셋 검색",
|
"searchPlaceholder": "MCP 프리셋 검색",
|
||||||
"moreOptions": "추가 MCP 옵션",
|
"moreOptions": "MCP 서버 추가",
|
||||||
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
|
"moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.",
|
||||||
"customTitle": "사용자 지정 MCP",
|
"customTitle": "사용자 지정 MCP",
|
||||||
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
|
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
|
||||||
"customAction": "사용자 지정",
|
"customAction": "사용자 지정",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "서버 이름",
|
"serverName": "서버 이름",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "전송 방식",
|
"transport": "전송 방식",
|
||||||
|
"authentication": "인증",
|
||||||
|
"authNone": "없음",
|
||||||
|
"authHeaders": "헤더",
|
||||||
"command": "명령",
|
"command": "명령",
|
||||||
"args": "인자 JSON",
|
"args": "인자 JSON",
|
||||||
"headers": "헤더 JSON",
|
"headers": "헤더 JSON",
|
||||||
|
"oauthAfterSave": "서버를 저장한 다음 연결을 선택하여 로그인하세요.",
|
||||||
|
"headersHelp": "이 서버에서 사용하는 요청 헤더를 추가하세요.",
|
||||||
"env": "환경 변수 JSON",
|
"env": "환경 변수 JSON",
|
||||||
"timeout": "도구 제한 시간",
|
"timeout": "도구 제한 시간",
|
||||||
"advancedOptions": "고급 옵션",
|
"advancedOptions": "고급 옵션",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "기존 값을 유지하려면 비워 두세요",
|
"keepExisting": "기존 값을 유지하려면 비워 두세요",
|
||||||
"statusConfigured": "구성됨",
|
"statusConfigured": "구성됨",
|
||||||
"statusMissingCredentials": "키 필요",
|
"statusMissingCredentials": "키 필요",
|
||||||
|
"connectingAccount": "{{name}} 연결 중",
|
||||||
|
"connectingLabel": "연결 중…",
|
||||||
|
"continueSignIn": "로그인 계속",
|
||||||
|
"preparingSignIn": "안전한 로그인을 준비하는 중…",
|
||||||
|
"openSignInToContinue": "계속하려면 로그인 페이지를 여세요.",
|
||||||
|
"finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.",
|
||||||
|
"manualCallbackRequired": "로그인을 완료한 다음 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||||
|
"manualCallbackHelp": "접근을 승인하면 localhost 페이지가 열리지 않습니다. 주소 표시줄에서 전체 URL을 복사해 여기에 붙여 넣으세요.",
|
||||||
|
"finishingConnection": "연결을 마무리하는 중…",
|
||||||
|
"activatingTools": "도구를 활성화하는 중…",
|
||||||
|
"connected": "연결됨.",
|
||||||
|
"connectionFailed": "연결에 실패했습니다.",
|
||||||
|
"connectionCancelled": "연결을 취소했습니다.",
|
||||||
|
"reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.",
|
||||||
|
"oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.",
|
||||||
"statusMissingDependency": "의존성 필요",
|
"statusMissingDependency": "의존성 필요",
|
||||||
"statusComingSoon": "곧 제공",
|
"statusComingSoon": "곧 제공",
|
||||||
"comingSoon": "곧 제공",
|
"comingSoon": "곧 제공",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "연동",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
"filterAll": "사용 가능",
|
"filterAll": "사용 가능",
|
||||||
"filterPlugins": "플러그인",
|
"filterPlugins": "플러그인",
|
||||||
"filterCli": "앱",
|
"filterCli": "앱",
|
||||||
"filterMcp": "연동",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}}개 사용 가능",
|
"enabledSummary": "{{count}}개 사용 가능",
|
||||||
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
|
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
||||||
"searchPlaceholder": "앱 검색",
|
"searchPlaceholder": "앱 검색",
|
||||||
"featured": "도구",
|
"featured": "도구",
|
||||||
|
"mcpTools": "MCP 도구",
|
||||||
"loading": "앱을 불러오는 중...",
|
"loading": "앱을 불러오는 중...",
|
||||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||||
"emptyApps": "사용 가능한 앱이 없습니다.",
|
"emptyApps": "사용 가능한 앱이 없습니다.",
|
||||||
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
|
"emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.",
|
||||||
"emptyReady": "아직 준비된 도구가 없습니다.",
|
"emptyReady": "아직 준비된 도구가 없습니다.",
|
||||||
"clearSearch": "검색 지우기",
|
"clearSearch": "검색 지우기",
|
||||||
"browseApps": "앱 둘러보기",
|
"browseApps": "앱 둘러보기",
|
||||||
"browseIntegrations": "연동 둘러보기",
|
"browseIntegrations": "MCP 도구 둘러보기",
|
||||||
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
|
"emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.",
|
||||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
"restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||||
|
|||||||
@@ -319,8 +319,8 @@
|
|||||||
"filterInstalled": "Habilitadas",
|
"filterInstalled": "Habilitadas",
|
||||||
"filterNotInstalled": "Não habilitadas",
|
"filterNotInstalled": "Não habilitadas",
|
||||||
"searchPlaceholder": "Buscar predefinições MCP",
|
"searchPlaceholder": "Buscar predefinições MCP",
|
||||||
"moreOptions": "Adicionar integração",
|
"moreOptions": "Adicionar servidor MCP",
|
||||||
"moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.",
|
"moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.",
|
||||||
"customTitle": "MCP personalizado",
|
"customTitle": "MCP personalizado",
|
||||||
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
|
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
|
||||||
"customAction": "Personalizado",
|
"customAction": "Personalizado",
|
||||||
@@ -328,9 +328,14 @@
|
|||||||
"serverName": "Nome do servidor",
|
"serverName": "Nome do servidor",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
|
"authentication": "Autenticação",
|
||||||
|
"authNone": "Nenhuma",
|
||||||
|
"authHeaders": "Cabeçalhos",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Argumentos JSON",
|
||||||
"headers": "Cabeçalhos JSON",
|
"headers": "Cabeçalhos JSON",
|
||||||
|
"oauthAfterSave": "Salve o servidor e selecione Conectar para entrar.",
|
||||||
|
"headersHelp": "Adicione os cabeçalhos de solicitação usados por este servidor.",
|
||||||
"env": "Ambiente JSON",
|
"env": "Ambiente JSON",
|
||||||
"timeout": "Tempo limite da ferramenta",
|
"timeout": "Tempo limite da ferramenta",
|
||||||
"advancedOptions": "Opções avançadas",
|
"advancedOptions": "Opções avançadas",
|
||||||
@@ -357,6 +362,21 @@
|
|||||||
"keepExisting": "Deixe em branco para manter o valor atual",
|
"keepExisting": "Deixe em branco para manter o valor atual",
|
||||||
"statusConfigured": "Configurado",
|
"statusConfigured": "Configurado",
|
||||||
"statusMissingCredentials": "Precisa de chave",
|
"statusMissingCredentials": "Precisa de chave",
|
||||||
|
"connectingAccount": "Conectando {{name}}",
|
||||||
|
"connectingLabel": "Conectando…",
|
||||||
|
"continueSignIn": "Continuar login",
|
||||||
|
"preparingSignIn": "Preparando login seguro…",
|
||||||
|
"openSignInToContinue": "Abra a página de login para continuar.",
|
||||||
|
"finishSignInInBrowser": "Conclua o login na janela do navegador.",
|
||||||
|
"manualCallbackRequired": "Conclua o login e cole a URL de callback no nanobot.",
|
||||||
|
"manualCallbackHelp": "Depois de autorizar o acesso, a página localhost não será carregada. Copie a URL completa da barra de endereços e cole-a aqui.",
|
||||||
|
"finishingConnection": "Finalizando conexão…",
|
||||||
|
"activatingTools": "Ativando ferramentas…",
|
||||||
|
"connected": "Conectado.",
|
||||||
|
"connectionFailed": "Falha na conexão.",
|
||||||
|
"connectionCancelled": "Conexão cancelada.",
|
||||||
|
"reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.",
|
||||||
|
"oauthFailed": "Não foi possível conectar. Tente fazer login novamente.",
|
||||||
"statusMissingDependency": "Precisa de dependência",
|
"statusMissingDependency": "Precisa de dependência",
|
||||||
"statusComingSoon": "Em breve",
|
"statusComingSoon": "Em breve",
|
||||||
"comingSoon": "Em breve",
|
"comingSoon": "Em breve",
|
||||||
@@ -584,27 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicativos",
|
"filterCli": "Aplicativos",
|
||||||
"filterMcp": "Integrações",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} prontos",
|
"enabledSummary": "{{count}} prontos",
|
||||||
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
|
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
||||||
"searchPlaceholder": "Buscar ferramentas",
|
"searchPlaceholder": "Buscar ferramentas",
|
||||||
"featured": "Ferramentas",
|
"featured": "Ferramentas",
|
||||||
|
"mcpTools": "Ferramentas MCP",
|
||||||
"loading": "Carregando aplicativos...",
|
"loading": "Carregando aplicativos...",
|
||||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||||
"emptyApps": "Nenhum aplicativo disponível.",
|
"emptyApps": "Nenhum aplicativo disponível.",
|
||||||
"emptyIntegrations": "Nenhuma integração disponível.",
|
"emptyIntegrations": "Nenhuma ferramenta MCP disponível.",
|
||||||
"emptyReady": "Ainda não há ferramentas prontas.",
|
"emptyReady": "Ainda não há ferramentas prontas.",
|
||||||
"clearSearch": "Limpar busca",
|
"clearSearch": "Limpar busca",
|
||||||
"browseApps": "Explorar aplicativos",
|
"browseApps": "Explorar aplicativos",
|
||||||
"browseIntegrations": "Explorar integrações",
|
"browseIntegrations": "Explorar ferramentas MCP",
|
||||||
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
|
"emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.",
|
||||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "Đã bật",
|
"filterInstalled": "Đã bật",
|
||||||
"filterNotInstalled": "Chưa bật",
|
"filterNotInstalled": "Chưa bật",
|
||||||
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
||||||
"moreOptions": "Tùy chọn MCP khác",
|
"moreOptions": "Thêm máy chủ MCP",
|
||||||
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
|
"moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.",
|
||||||
"customTitle": "MCP tùy chỉnh",
|
"customTitle": "MCP tùy chỉnh",
|
||||||
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
|
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
|
||||||
"customAction": "Tùy chỉnh",
|
"customAction": "Tùy chỉnh",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "Tên máy chủ",
|
"serverName": "Tên máy chủ",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Giao thức truyền",
|
"transport": "Giao thức truyền",
|
||||||
|
"authentication": "Xác thực",
|
||||||
|
"authNone": "Không có",
|
||||||
|
"authHeaders": "Header",
|
||||||
"command": "Lệnh",
|
"command": "Lệnh",
|
||||||
"args": "Đối số JSON",
|
"args": "Đối số JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Header JSON",
|
||||||
|
"oauthAfterSave": "Lưu máy chủ, sau đó chọn Kết nối để đăng nhập.",
|
||||||
|
"headersHelp": "Thêm các header yêu cầu mà máy chủ này sử dụng.",
|
||||||
"env": "Môi trường JSON",
|
"env": "Môi trường JSON",
|
||||||
"timeout": "Thời gian chờ công cụ",
|
"timeout": "Thời gian chờ công cụ",
|
||||||
"advancedOptions": "Tùy chọn nâng cao",
|
"advancedOptions": "Tùy chọn nâng cao",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "Để trống để giữ giá trị hiện tại",
|
"keepExisting": "Để trống để giữ giá trị hiện tại",
|
||||||
"statusConfigured": "Đã cấu hình",
|
"statusConfigured": "Đã cấu hình",
|
||||||
"statusMissingCredentials": "Cần khóa",
|
"statusMissingCredentials": "Cần khóa",
|
||||||
|
"connectingAccount": "Đang kết nối {{name}}",
|
||||||
|
"connectingLabel": "Đang kết nối…",
|
||||||
|
"continueSignIn": "Tiếp tục đăng nhập",
|
||||||
|
"preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…",
|
||||||
|
"openSignInToContinue": "Mở trang đăng nhập để tiếp tục.",
|
||||||
|
"finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.",
|
||||||
|
"manualCallbackRequired": "Hoàn tất đăng nhập, rồi dán URL callback vào nanobot.",
|
||||||
|
"manualCallbackHelp": "Sau khi phê duyệt quyền truy cập, trang localhost sẽ không tải được. Hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào đây.",
|
||||||
|
"finishingConnection": "Đang hoàn tất kết nối…",
|
||||||
|
"activatingTools": "Đang kích hoạt công cụ…",
|
||||||
|
"connected": "Đã kết nối.",
|
||||||
|
"connectionFailed": "Kết nối thất bại.",
|
||||||
|
"connectionCancelled": "Đã hủy kết nối.",
|
||||||
|
"reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.",
|
||||||
|
"oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.",
|
||||||
"statusMissingDependency": "Cần phụ thuộc",
|
"statusMissingDependency": "Cần phụ thuộc",
|
||||||
"statusComingSoon": "Sắp ra mắt",
|
"statusComingSoon": "Sắp ra mắt",
|
||||||
"comingSoon": "Sắp ra mắt",
|
"comingSoon": "Sắp ra mắt",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||||
"cliLabel": "Ứng dụng",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
"filterAll": "Sẵn sàng",
|
"filterAll": "Sẵn sàng",
|
||||||
"filterPlugins": "Plugin",
|
"filterPlugins": "Plugin",
|
||||||
"filterCli": "Ứng dụng",
|
"filterCli": "Ứng dụng",
|
||||||
"filterMcp": "Tích hợp",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} sẵn sàng",
|
"enabledSummary": "{{count}} sẵn sàng",
|
||||||
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
|
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
||||||
"searchPlaceholder": "Tìm ứng dụng",
|
"searchPlaceholder": "Tìm ứng dụng",
|
||||||
"featured": "Công cụ",
|
"featured": "Công cụ",
|
||||||
|
"mcpTools": "Công cụ MCP",
|
||||||
"loading": "Đang tải ứng dụng...",
|
"loading": "Đang tải ứng dụng...",
|
||||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||||
"emptyApps": "Không có ứng dụng nào.",
|
"emptyApps": "Không có ứng dụng nào.",
|
||||||
"emptyIntegrations": "Không có tích hợp nào.",
|
"emptyIntegrations": "Không có công cụ MCP nào.",
|
||||||
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
||||||
"clearSearch": "Xóa tìm kiếm",
|
"clearSearch": "Xóa tìm kiếm",
|
||||||
"browseApps": "Xem ứng dụng",
|
"browseApps": "Xem ứng dụng",
|
||||||
"browseIntegrations": "Xem tích hợp",
|
"browseIntegrations": "Xem công cụ MCP",
|
||||||
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
|
"emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.",
|
||||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
||||||
|
|||||||
@@ -319,8 +319,8 @@
|
|||||||
"filterInstalled": "已启用",
|
"filterInstalled": "已启用",
|
||||||
"filterNotInstalled": "未启用",
|
"filterNotInstalled": "未启用",
|
||||||
"searchPlaceholder": "搜索 MCP 预设",
|
"searchPlaceholder": "搜索 MCP 预设",
|
||||||
"moreOptions": "添加集成",
|
"moreOptions": "添加 MCP 服务",
|
||||||
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
|
"moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。",
|
||||||
"customTitle": "自定义 MCP",
|
"customTitle": "自定义 MCP",
|
||||||
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
||||||
"customAction": "自定义",
|
"customAction": "自定义",
|
||||||
@@ -328,9 +328,14 @@
|
|||||||
"serverName": "服务名",
|
"serverName": "服务名",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "传输方式",
|
"transport": "传输方式",
|
||||||
|
"authentication": "身份验证",
|
||||||
|
"authNone": "无",
|
||||||
|
"authHeaders": "请求头",
|
||||||
"command": "命令",
|
"command": "命令",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "请求头 JSON",
|
||||||
|
"oauthAfterSave": "保存服务器后,选择“连接”以完成登录。",
|
||||||
|
"headersHelp": "添加此服务器要求的请求头。",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "工具超时",
|
"timeout": "工具超时",
|
||||||
"advancedOptions": "高级选项",
|
"advancedOptions": "高级选项",
|
||||||
@@ -357,6 +362,21 @@
|
|||||||
"keepExisting": "留空则保留当前值",
|
"keepExisting": "留空则保留当前值",
|
||||||
"statusConfigured": "已配置",
|
"statusConfigured": "已配置",
|
||||||
"statusMissingCredentials": "需要密钥",
|
"statusMissingCredentials": "需要密钥",
|
||||||
|
"connectingAccount": "正在连接 {{name}}",
|
||||||
|
"connectingLabel": "正在连接…",
|
||||||
|
"continueSignIn": "继续登录",
|
||||||
|
"preparingSignIn": "正在准备安全登录…",
|
||||||
|
"openSignInToContinue": "打开登录页面以继续。",
|
||||||
|
"finishSignInInBrowser": "请在浏览器窗口中完成登录。",
|
||||||
|
"manualCallbackRequired": "完成登录后,将回调 URL 粘贴到 nanobot。",
|
||||||
|
"manualCallbackHelp": "授权后,localhost 页面将无法打开。请复制地址栏中的完整 URL 并粘贴到这里。",
|
||||||
|
"finishingConnection": "正在完成连接…",
|
||||||
|
"activatingTools": "正在启用工具…",
|
||||||
|
"connected": "已连接。",
|
||||||
|
"connectionFailed": "连接失败。",
|
||||||
|
"connectionCancelled": "已取消连接。",
|
||||||
|
"reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。",
|
||||||
|
"oauthFailed": "无法连接,请重新登录。",
|
||||||
"statusMissingDependency": "缺少依赖",
|
"statusMissingDependency": "缺少依赖",
|
||||||
"statusComingSoon": "暂不支持",
|
"statusComingSoon": "暂不支持",
|
||||||
"comingSoon": "即将推出",
|
"comingSoon": "即将推出",
|
||||||
@@ -584,27 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
"filterAll": "可用",
|
"filterAll": "可用",
|
||||||
"filterPlugins": "插件",
|
"filterPlugins": "插件",
|
||||||
"filterCli": "应用",
|
"filterCli": "应用",
|
||||||
"filterMcp": "集成",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 个可用",
|
"enabledSummary": "{{count}} 个可用",
|
||||||
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
|
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
||||||
"searchPlaceholder": "搜索工具",
|
"searchPlaceholder": "搜索工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在加载应用...",
|
"loading": "正在加载应用...",
|
||||||
"empty": "没有与搜索条件匹配的工具。",
|
"empty": "没有与搜索条件匹配的工具。",
|
||||||
"emptyApps": "暂无可用应用。",
|
"emptyApps": "暂无可用应用。",
|
||||||
"emptyIntegrations": "暂无可用集成。",
|
"emptyIntegrations": "暂无可用 MCP 工具。",
|
||||||
"emptyReady": "还没有就绪的工具。",
|
"emptyReady": "还没有就绪的工具。",
|
||||||
"clearSearch": "清除搜索",
|
"clearSearch": "清除搜索",
|
||||||
"browseApps": "浏览应用",
|
"browseApps": "浏览应用",
|
||||||
"browseIntegrations": "浏览集成",
|
"browseIntegrations": "浏览 MCP 工具",
|
||||||
"emptyIntegrationsHint": "可在下方添加自定义集成。",
|
"emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。",
|
||||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
"restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
||||||
|
|||||||
@@ -504,8 +504,8 @@
|
|||||||
"filterInstalled": "已啟用",
|
"filterInstalled": "已啟用",
|
||||||
"filterNotInstalled": "未啟用",
|
"filterNotInstalled": "未啟用",
|
||||||
"searchPlaceholder": "搜尋 MCP 預設",
|
"searchPlaceholder": "搜尋 MCP 預設",
|
||||||
"moreOptions": "新增整合",
|
"moreOptions": "新增 MCP 服務",
|
||||||
"moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。",
|
"moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。",
|
||||||
"customTitle": "自訂 MCP",
|
"customTitle": "自訂 MCP",
|
||||||
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
|
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
|
||||||
"customAction": "自訂",
|
"customAction": "自訂",
|
||||||
@@ -513,9 +513,14 @@
|
|||||||
"serverName": "伺服器名稱",
|
"serverName": "伺服器名稱",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "傳輸方式",
|
"transport": "傳輸方式",
|
||||||
|
"authentication": "驗證方式",
|
||||||
|
"authNone": "無",
|
||||||
|
"authHeaders": "請求標頭",
|
||||||
"command": "指令",
|
"command": "指令",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "請求標頭 JSON",
|
||||||
|
"oauthAfterSave": "儲存伺服器後,選擇「連線」以登入。",
|
||||||
|
"headersHelp": "新增此伺服器使用的請求標頭。",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "工具逾時",
|
"timeout": "工具逾時",
|
||||||
"advancedOptions": "進階選項",
|
"advancedOptions": "進階選項",
|
||||||
@@ -542,6 +547,21 @@
|
|||||||
"keepExisting": "留空以保留目前值",
|
"keepExisting": "留空以保留目前值",
|
||||||
"statusConfigured": "已設定",
|
"statusConfigured": "已設定",
|
||||||
"statusMissingCredentials": "需要金鑰",
|
"statusMissingCredentials": "需要金鑰",
|
||||||
|
"connectingAccount": "正在連接 {{name}}",
|
||||||
|
"connectingLabel": "正在連線…",
|
||||||
|
"continueSignIn": "繼續登入",
|
||||||
|
"preparingSignIn": "正在準備安全登入…",
|
||||||
|
"openSignInToContinue": "開啟登入頁面以繼續。",
|
||||||
|
"finishSignInInBrowser": "請在瀏覽器視窗中完成登入。",
|
||||||
|
"manualCallbackRequired": "完成登入後,將回呼 URL 貼到 nanobot。",
|
||||||
|
"manualCallbackHelp": "授權後,localhost 頁面將無法開啟。請複製網址列中的完整 URL 並貼到這裡。",
|
||||||
|
"finishingConnection": "正在完成連線…",
|
||||||
|
"activatingTools": "正在啟用工具…",
|
||||||
|
"connected": "已連線。",
|
||||||
|
"connectionFailed": "連線失敗。",
|
||||||
|
"connectionCancelled": "已取消連線。",
|
||||||
|
"reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。",
|
||||||
|
"oauthFailed": "無法連線,請重新登入。",
|
||||||
"statusMissingDependency": "需要相依項",
|
"statusMissingDependency": "需要相依項",
|
||||||
"statusComingSoon": "即將推出",
|
"statusComingSoon": "即將推出",
|
||||||
"comingSoon": "即將推出",
|
"comingSoon": "即將推出",
|
||||||
@@ -570,27 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "整合",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
"filterAll": "就緒",
|
"filterAll": "就緒",
|
||||||
"filterPlugins": "外掛程式",
|
"filterPlugins": "外掛程式",
|
||||||
"filterCli": "應用程式",
|
"filterCli": "應用程式",
|
||||||
"filterMcp": "整合",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 個就緒",
|
"enabledSummary": "{{count}} 個就緒",
|
||||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務",
|
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
||||||
"searchPlaceholder": "搜尋工具",
|
"searchPlaceholder": "搜尋工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在載入應用程式…",
|
"loading": "正在載入應用程式…",
|
||||||
"empty": "沒有符合搜尋條件的工具。",
|
"empty": "沒有符合搜尋條件的工具。",
|
||||||
"emptyApps": "沒有可用的應用程式。",
|
"emptyApps": "沒有可用的應用程式。",
|
||||||
"emptyIntegrations": "沒有可用的整合服務。",
|
"emptyIntegrations": "沒有可用的 MCP 工具。",
|
||||||
"emptyReady": "尚無就緒的工具。",
|
"emptyReady": "尚無就緒的工具。",
|
||||||
"clearSearch": "清除搜尋",
|
"clearSearch": "清除搜尋",
|
||||||
"browseApps": "瀏覽應用程式",
|
"browseApps": "瀏覽應用程式",
|
||||||
"browseIntegrations": "瀏覽整合服務",
|
"browseIntegrations": "瀏覽 MCP 工具",
|
||||||
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
|
"emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。",
|
||||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
|
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
|
||||||
|
|||||||
+64
-1
@@ -10,6 +10,7 @@ import type {
|
|||||||
FilePreviewPayload,
|
FilePreviewPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
McpPresetsPayload,
|
McpPresetsPayload,
|
||||||
|
McpOAuthFlowPayload,
|
||||||
MarketplaceProvider,
|
MarketplaceProvider,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
@@ -97,7 +98,19 @@ async function request<T>(
|
|||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
|
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
|
||||||
throw new ApiError(res.status, text || `HTTP ${res.status}`);
|
let message = text;
|
||||||
|
if (text.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(text);
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const error = (payload as { error?: unknown }).error;
|
||||||
|
if (typeof error === "string" && error.trim()) message = error.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Preserve non-JSON error bodies exactly as returned by the gateway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const contentType = res.headers?.get?.("content-type") ?? "";
|
const contentType = res.headers?.get?.("content-type") ?? "";
|
||||||
if (contentType && !contentType.toLowerCase().includes("application/json")) {
|
if (contentType && !contentType.toLowerCase().includes("application/json")) {
|
||||||
@@ -686,6 +699,56 @@ export async function fetchMcpPresets(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startMcpOAuth(
|
||||||
|
transport: WebUIMutationTransport,
|
||||||
|
name: string,
|
||||||
|
reset: boolean = false,
|
||||||
|
): Promise<McpOAuthFlowPayload> {
|
||||||
|
return mutation<McpOAuthFlowPayload>(
|
||||||
|
transport,
|
||||||
|
"settings.mcp.oauth_start",
|
||||||
|
{ name, ...(reset ? { reset: true } : {}) },
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMcpOAuthStatus(
|
||||||
|
token: string,
|
||||||
|
flowId: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<McpOAuthFlowPayload> {
|
||||||
|
const query = new URLSearchParams({ flow_id: flowId });
|
||||||
|
return request<McpOAuthFlowPayload>(
|
||||||
|
`${base}/api/settings/mcp-oauth/status?${query}`,
|
||||||
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeMcpOAuth(
|
||||||
|
transport: WebUIMutationTransport,
|
||||||
|
flowId: string,
|
||||||
|
callbackUrl: string,
|
||||||
|
): Promise<McpOAuthFlowPayload> {
|
||||||
|
return mutation<McpOAuthFlowPayload>(
|
||||||
|
transport,
|
||||||
|
"settings.mcp.oauth_complete",
|
||||||
|
{ flow_id: flowId, callback_url: callbackUrl },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelMcpOAuth(
|
||||||
|
transport: WebUIMutationTransport,
|
||||||
|
flowId: string,
|
||||||
|
): Promise<McpOAuthFlowPayload> {
|
||||||
|
return mutation<McpOAuthFlowPayload>(
|
||||||
|
transport,
|
||||||
|
"settings.mcp.oauth_cancel",
|
||||||
|
{ flow_id: flowId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProviderModels(
|
export async function fetchProviderModels(
|
||||||
token: string,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
|
|||||||
@@ -956,6 +956,7 @@ export interface McpPresetInfo {
|
|||||||
description: string;
|
description: string;
|
||||||
docs_url: string;
|
docs_url: string;
|
||||||
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
|
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
|
||||||
|
auth?: "oauth" | null;
|
||||||
requires: string;
|
requires: string;
|
||||||
note: string;
|
note: string;
|
||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
@@ -976,6 +977,30 @@ export interface McpPresetInfo {
|
|||||||
manifest?: AppManifest;
|
manifest?: AppManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpOAuthFlowStatus =
|
||||||
|
| "starting"
|
||||||
|
| "authorization_required"
|
||||||
|
| "connecting"
|
||||||
|
| "authorized"
|
||||||
|
| "connected"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export interface McpOAuthFlowPayload {
|
||||||
|
flow_id: string;
|
||||||
|
name: string;
|
||||||
|
status: McpOAuthFlowStatus;
|
||||||
|
expires_in: number;
|
||||||
|
authorization_url?: string;
|
||||||
|
completion_input?: "callback_url";
|
||||||
|
error?: string;
|
||||||
|
hot_reload?: {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
requires_restart?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface McpPresetsPayload {
|
export interface McpPresetsPayload {
|
||||||
presets: McpPresetInfo[];
|
presets: McpPresetInfo[];
|
||||||
installed_count: number;
|
installed_count: number;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
cancelMcpOAuth,
|
||||||
configureChannel,
|
configureChannel,
|
||||||
|
completeMcpOAuth,
|
||||||
completeProviderOAuth,
|
completeProviderOAuth,
|
||||||
createModelConfiguration,
|
createModelConfiguration,
|
||||||
createProviderSettings,
|
createProviderSettings,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
fetchApiService,
|
fetchApiService,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
fetchInstalledCliApps,
|
fetchInstalledCliApps,
|
||||||
|
fetchMcpOAuthStatus,
|
||||||
fetchMcpPresets,
|
fetchMcpPresets,
|
||||||
fetchMarketplaceSkillTrends,
|
fetchMarketplaceSkillTrends,
|
||||||
fetchNanobotFeatures,
|
fetchNanobotFeatures,
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
saveCustomMcpServer,
|
saveCustomMcpServer,
|
||||||
searchMarketplaceSkills,
|
searchMarketplaceSkills,
|
||||||
startApiService,
|
startApiService,
|
||||||
|
startMcpOAuth,
|
||||||
stopApiService,
|
stopApiService,
|
||||||
cancelChannelConnect,
|
cancelChannelConnect,
|
||||||
pollChannelConnect,
|
pollChannelConnect,
|
||||||
@@ -545,6 +549,23 @@ describe("webui API helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts user-facing messages from JSON API errors", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: async () => JSON.stringify({ error: "Paste the complete callback URL." }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchMcpOAuthStatus("tok", "flow-123")).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
message: "Paste the complete callback URL.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("times out when an API request never responds", async () => {
|
it("times out when an API request never responds", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||||
@@ -882,6 +903,35 @@ describe("webui API helpers", () => {
|
|||||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await startMcpOAuth(mutationTransport, "notion", true);
|
||||||
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.oauth_start",
|
||||||
|
{ name: "notion", reset: true },
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchMcpOAuthStatus("tok", "flow-123");
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/mcp-oauth/status?flow_id=flow-123",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callbackUrl =
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback?code=secret&state=state-123";
|
||||||
|
await completeMcpOAuth(mutationTransport, "flow-123", callbackUrl);
|
||||||
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.oauth_complete",
|
||||||
|
{ flow_id: "flow-123", callback_url: callbackUrl },
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await cancelMcpOAuth(mutationTransport, "flow-123");
|
||||||
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.oauth_cancel",
|
||||||
|
{ flow_id: "flow-123" },
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||||
@@ -899,6 +949,19 @@ describe("webui API helpers", () => {
|
|||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const oauthCustom = {
|
||||||
|
name: "company-mcp",
|
||||||
|
transport: "streamableHttp",
|
||||||
|
url: "https://mcp.example.com/mcp",
|
||||||
|
auth: "oauth",
|
||||||
|
};
|
||||||
|
await saveCustomMcpServer(mutationTransport, oauthCustom);
|
||||||
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
|
"settings.mcp.custom",
|
||||||
|
oauthCustom,
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
await importMcpConfig(
|
await importMcpConfig(
|
||||||
mutationTransport,
|
mutationTransport,
|
||||||
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
|||||||
"settings.apps.description",
|
"settings.apps.description",
|
||||||
"settings.apps.caption",
|
"settings.apps.caption",
|
||||||
"settings.apps.restartRequired",
|
"settings.apps.restartRequired",
|
||||||
|
"settings.mcp.connectingAccount",
|
||||||
|
"settings.mcp.continueSignIn",
|
||||||
|
"settings.mcp.preparingSignIn",
|
||||||
|
"settings.mcp.openSignInToContinue",
|
||||||
|
"settings.mcp.finishSignInInBrowser",
|
||||||
|
"settings.mcp.finishingConnection",
|
||||||
|
"settings.mcp.activatingTools",
|
||||||
|
"settings.mcp.connected",
|
||||||
|
"settings.mcp.connectionFailed",
|
||||||
|
"settings.mcp.connectionCancelled",
|
||||||
|
"settings.mcp.reloadFailed",
|
||||||
|
"settings.mcp.oauthFailed",
|
||||||
"settings.skills.views",
|
"settings.skills.views",
|
||||||
"settings.skills.installedTab",
|
"settings.skills.installedTab",
|
||||||
"settings.skills.discoverTab",
|
"settings.skills.discoverTab",
|
||||||
|
|||||||
@@ -334,6 +334,29 @@ const installedAnyGen = {
|
|||||||
skill_installed: true,
|
skill_installed: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const xmindMcpPreset = {
|
||||||
|
name: "xmind",
|
||||||
|
display_name: "Xmind",
|
||||||
|
category: "productivity",
|
||||||
|
description: "Create, read, and edit cloud mind maps through Xmind.",
|
||||||
|
docs_url: "https://xmind.com/user-guide/xmind-mcp",
|
||||||
|
transport: "streamableHttp",
|
||||||
|
auth: "oauth" as const,
|
||||||
|
requires: "Xmind account",
|
||||||
|
note: "Connects securely in your browser with Xmind OAuth.",
|
||||||
|
install_supported: true,
|
||||||
|
installed: false,
|
||||||
|
configured: false,
|
||||||
|
available: false,
|
||||||
|
status: "not_installed",
|
||||||
|
logo_url: null,
|
||||||
|
brand_color: "#F4B41A",
|
||||||
|
required_fields: [],
|
||||||
|
connection_summary: "",
|
||||||
|
enabled_tools: ["*"],
|
||||||
|
source: "preset",
|
||||||
|
};
|
||||||
|
|
||||||
function renderSettingsView(
|
function renderSettingsView(
|
||||||
options: {
|
options: {
|
||||||
initialSection?:
|
initialSection?:
|
||||||
@@ -652,6 +675,534 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("connects an OAuth MCP from the Apps catalog without manual callback input", async () => {
|
||||||
|
let connected = false;
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({
|
||||||
|
presets: [connected
|
||||||
|
? {
|
||||||
|
...xmindMcpPreset,
|
||||||
|
installed: true,
|
||||||
|
configured: true,
|
||||||
|
available: true,
|
||||||
|
status: "configured",
|
||||||
|
connection_summary: "https://app.xmind.com/api/mcp",
|
||||||
|
}
|
||||||
|
: xmindMcpPreset],
|
||||||
|
installed_count: connected ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-123") {
|
||||||
|
connected = true;
|
||||||
|
return jsonResponse({
|
||||||
|
flow_id: "flow-123",
|
||||||
|
name: "xmind",
|
||||||
|
status: "connected",
|
||||||
|
expires_in: 295,
|
||||||
|
hot_reload: {
|
||||||
|
ok: false,
|
||||||
|
requires_restart: false,
|
||||||
|
connected: ["xmind"],
|
||||||
|
failed: ["notion"],
|
||||||
|
message: "MCP config reloaded, but some servers did not connect: notion",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.oauth_start") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-123",
|
||||||
|
name: "xmind",
|
||||||
|
status: "authorization_required",
|
||||||
|
expires_in: 300,
|
||||||
|
authorization_url: "https://accounts.xmind.test/authorize?state=state-123",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
const replace = vi.fn();
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
closed: false,
|
||||||
|
location: { replace },
|
||||||
|
document: { title: "", body: { textContent: "" } },
|
||||||
|
focus: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
const open = vi.fn(() => popup);
|
||||||
|
vi.stubGlobal("open", open);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
expect(screen.getByText("MCP tools")).toBeInTheDocument();
|
||||||
|
const connectButton = await screen.findByRole("button", { name: "Connect Xmind" });
|
||||||
|
expect(connectButton).toHaveTextContent("Connect");
|
||||||
|
fireEvent.click(connectButton);
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith(
|
||||||
|
"about:blank",
|
||||||
|
"nanobot-mcp-oauth",
|
||||||
|
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(replace).toHaveBeenCalledWith(
|
||||||
|
"https://accounts.xmind.test/authorize?state=state-123",
|
||||||
|
));
|
||||||
|
expect(popup.opener).toBeNull();
|
||||||
|
expect(screen.queryByRole("textbox", { name: /authorization/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Finish signing in in the browser window.",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toHaveTextContent(
|
||||||
|
"Connecting…",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||||
|
.toHaveTextContent("Configured");
|
||||||
|
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
|
||||||
|
expect(popup.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replace).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/mcp-oauth/status?flow_id=flow-123",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
|
||||||
|
const customPreset = {
|
||||||
|
...xmindMcpPreset,
|
||||||
|
name: "team-mcp",
|
||||||
|
display_name: "team-mcp",
|
||||||
|
source: "custom",
|
||||||
|
installed: true,
|
||||||
|
status: "authorization_required",
|
||||||
|
connection_summary: "https://mcp.example.com/mcp",
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.custom") {
|
||||||
|
return {
|
||||||
|
presets: [customPreset],
|
||||||
|
installed_count: 1,
|
||||||
|
hot_reload: {
|
||||||
|
ok: false,
|
||||||
|
message: "MCP config reloaded, but some servers did not connect: team-mcp",
|
||||||
|
failed: ["team-mcp"],
|
||||||
|
},
|
||||||
|
last_action: { ok: true, message: "Saved custom MCP server team-mcp." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("Authentication")).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Server name"), {
|
||||||
|
target: { value: "team-mcp" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "HTTP" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("URL"), {
|
||||||
|
target: { value: "https://mcp.example.com/mcp" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const authentication = screen.getByRole("group", { name: "Authentication" });
|
||||||
|
const oauth = within(authentication).getByRole("button", { name: "OAuth" });
|
||||||
|
expect(oauth).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
fireEvent.click(within(authentication).getByRole("button", { name: "Headers" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Headers JSON"), {
|
||||||
|
target: { value: '{"Authorization":"Bearer stale"}' },
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Add the request headers used by this server.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(oauth);
|
||||||
|
expect(oauth).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.queryByLabelText("Headers JSON")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Save the server, then select Connect to sign in."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save MCP" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const saveCall = requestMutationMock.mock.calls.find(
|
||||||
|
([action]) => action === "settings.mcp.custom",
|
||||||
|
);
|
||||||
|
expect(saveCall).toBeDefined();
|
||||||
|
const values = saveCall?.[1] as Record<string, string>;
|
||||||
|
expect(values).toMatchObject({
|
||||||
|
name: "team-mcp",
|
||||||
|
transport: "streamableHttp",
|
||||||
|
url: "https://mcp.example.com/mcp",
|
||||||
|
auth: "oauth",
|
||||||
|
});
|
||||||
|
expect(values).not.toHaveProperty("headers");
|
||||||
|
expect(saveCall?.[2]).toBe(20_000);
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole("button", { name: "Connect team-mcp" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("MCP config reloaded, but some servers did not connect: team-mcp"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a pasted callback flow when the remote WebUI uses HTTP", async () => {
|
||||||
|
let completed = false;
|
||||||
|
const callbackUrl =
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=manual-state";
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({
|
||||||
|
presets: [completed
|
||||||
|
? {
|
||||||
|
...xmindMcpPreset,
|
||||||
|
installed: true,
|
||||||
|
configured: true,
|
||||||
|
available: true,
|
||||||
|
status: "configured",
|
||||||
|
connection_summary: "https://app.xmind.com/api/mcp",
|
||||||
|
}
|
||||||
|
: xmindMcpPreset],
|
||||||
|
installed_count: completed ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-manual") {
|
||||||
|
return jsonResponse({
|
||||||
|
flow_id: "flow-manual",
|
||||||
|
name: "xmind",
|
||||||
|
status: completed ? "connected" : "authorization_required",
|
||||||
|
expires_in: 298,
|
||||||
|
completion_input: "callback_url",
|
||||||
|
authorization_url: completed
|
||||||
|
? undefined
|
||||||
|
: "https://accounts.xmind.test/authorize?state=manual-state",
|
||||||
|
hot_reload: completed ? { ok: true, requires_restart: false } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.oauth_start") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-manual",
|
||||||
|
name: "xmind",
|
||||||
|
status: "authorization_required",
|
||||||
|
expires_in: 300,
|
||||||
|
completion_input: "callback_url",
|
||||||
|
authorization_url: "https://accounts.xmind.test/authorize?state=manual-state",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === "settings.mcp.oauth_complete") {
|
||||||
|
completed = true;
|
||||||
|
return {
|
||||||
|
flow_id: "flow-manual",
|
||||||
|
name: "xmind",
|
||||||
|
status: "connecting",
|
||||||
|
expires_in: 299,
|
||||||
|
completion_input: "callback_url",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
closed: false,
|
||||||
|
location: { replace: vi.fn() },
|
||||||
|
document: { title: "", body: { textContent: "" } },
|
||||||
|
focus: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("open", vi.fn(() => popup));
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||||
|
|
||||||
|
const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" });
|
||||||
|
expect(screen.getByText(/localhost page will not load/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Finish signing in, then paste the callback URL into nanobot.",
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.oauth_complete",
|
||||||
|
{ flow_id: "flow-manual", callback_url: callbackUrl },
|
||||||
|
20_000,
|
||||||
|
));
|
||||||
|
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||||
|
.toHaveTextContent("Configured");
|
||||||
|
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
|
||||||
|
expect(popup.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the user cancel an active OAuth connection after closing the popup", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-cancel") {
|
||||||
|
return new Promise<Response>(() => {});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.oauth_start") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-cancel",
|
||||||
|
name: "xmind",
|
||||||
|
status: "authorization_required",
|
||||||
|
expires_in: 300,
|
||||||
|
authorization_url: "https://accounts.xmind.test/authorize?state=cancel",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === "settings.mcp.oauth_cancel") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-cancel",
|
||||||
|
name: "xmind",
|
||||||
|
status: "cancelled",
|
||||||
|
expires_in: 299,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
closed: false,
|
||||||
|
location: { replace: vi.fn() },
|
||||||
|
document: { title: "", body: { textContent: "" } },
|
||||||
|
focus: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("open", vi.fn(() => popup));
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||||
|
|
||||||
|
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
|
||||||
|
expect(screen.getByRole("button", { name: "Connecting Xmind" })).toBeInTheDocument();
|
||||||
|
popup.closed = true;
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.oauth_cancel",
|
||||||
|
{ flow_id: "flow-cancel" },
|
||||||
|
20_000,
|
||||||
|
));
|
||||||
|
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Connecting Xmind" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||||
|
expect(popup.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently removes an MCP when the card already shows the result", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({
|
||||||
|
presets: [{
|
||||||
|
...xmindMcpPreset,
|
||||||
|
installed: true,
|
||||||
|
configured: true,
|
||||||
|
available: true,
|
||||||
|
status: "configured",
|
||||||
|
connection_summary: "https://app.xmind.com/api/mcp",
|
||||||
|
}],
|
||||||
|
installed_count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockResolvedValueOnce({
|
||||||
|
presets: [xmindMcpPreset],
|
||||||
|
installed_count: 0,
|
||||||
|
requires_restart: false,
|
||||||
|
hot_reload: {
|
||||||
|
ok: true,
|
||||||
|
message: "MCP config reloaded without restarting nanobot.",
|
||||||
|
},
|
||||||
|
last_action: {
|
||||||
|
ok: true,
|
||||||
|
message: "Removed MCP preset for Xmind. MCP config reloaded without restarting nanobot.",
|
||||||
|
removed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
|
"settings.mcp.remove",
|
||||||
|
{ name: "xmind" },
|
||||||
|
20_000,
|
||||||
|
));
|
||||||
|
expect(await screen.findByRole("button", { name: "Connect Xmind" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Removed MCP preset|reloaded without restarting/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a one-click recovery when the OAuth popup is blocked", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||||
|
}
|
||||||
|
return new Promise<Response>(() => {});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.oauth_start") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-blocked",
|
||||||
|
name: "xmind",
|
||||||
|
status: "authorization_required",
|
||||||
|
expires_in: 300,
|
||||||
|
authorization_url: "https://accounts.xmind.test/authorize?state=blocked",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
closed: false,
|
||||||
|
location: { replace: vi.fn() },
|
||||||
|
focus: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
const open = vi.fn()
|
||||||
|
.mockReturnValueOnce(null)
|
||||||
|
.mockReturnValueOnce(popup);
|
||||||
|
vi.stubGlobal("open", open);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||||
|
|
||||||
|
const continueButton = await screen.findByRole("button", { name: "Continue sign-in" });
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Open the sign-in page to continue.");
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||||
|
fireEvent.click(continueButton);
|
||||||
|
expect(open).toHaveBeenLastCalledWith(
|
||||||
|
"https://accounts.xmind.test/authorize?state=blocked",
|
||||||
|
"nanobot-mcp-oauth",
|
||||||
|
"popup,width=560,height=720,resizable=yes,scrollbars=yes",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake a COOP-isolated OAuth tab for a blocked popup", async () => {
|
||||||
|
let popupIsolated = false;
|
||||||
|
let statusCalls = 0;
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [xmindMcpPreset], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-oauth/status?flow_id=flow-coop") {
|
||||||
|
statusCalls += 1;
|
||||||
|
return jsonResponse({
|
||||||
|
flow_id: "flow-coop",
|
||||||
|
name: "xmind",
|
||||||
|
status: statusCalls === 1 ? "authorization_required" : "failed",
|
||||||
|
expires_in: 299,
|
||||||
|
error: statusCalls === 1 ? undefined : "Cancelled for test cleanup.",
|
||||||
|
authorization_url: statusCalls === 1
|
||||||
|
? "https://accounts.xmind.test/authorize?state=coop"
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, text: async () => "Not found" } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
requestMutationMock.mockImplementation(async (action: string) => {
|
||||||
|
if (action === "settings.mcp.oauth_start") {
|
||||||
|
return {
|
||||||
|
flow_id: "flow-coop",
|
||||||
|
name: "xmind",
|
||||||
|
status: "authorization_required",
|
||||||
|
expires_in: 300,
|
||||||
|
authorization_url: "https://accounts.xmind.test/authorize?state=coop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return settingsPayload();
|
||||||
|
});
|
||||||
|
const popup = {
|
||||||
|
opener: window,
|
||||||
|
get closed() {
|
||||||
|
return popupIsolated;
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
replace: vi.fn(() => {
|
||||||
|
popupIsolated = true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
document: { title: "", body: { textContent: "" } },
|
||||||
|
focus: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("open", vi.fn(() => popup));
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "apps" });
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 });
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"Finish signing in in the browser window.",
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Continue sign-in" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
@@ -694,7 +1245,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
|
expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false");
|
||||||
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
|
expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true");
|
||||||
expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Api")).not.toBeInTheDocument();
|
expect(screen.queryByText("Api")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
|
expect(screen.queryByText("0 ready")).not.toBeInTheDocument();
|
||||||
@@ -2023,8 +2574,8 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
|
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
|
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Browse integrations" }));
|
fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" }));
|
||||||
expect(await screen.findByText("Add integration")).toBeInTheDocument();
|
expect(await screen.findByText("Add MCP server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows token activity on the overview", async () => {
|
it("shows token activity on the overview", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user