Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1971,15 +1971,52 @@ Add MCP servers to your `config.json`:
}
```

Two transport modes are supported:
MCP servers can run locally over stdio or connect remotely over HTTP:

| Mode | Config | Example |
| Connection | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |

Remote HTTP servers may use browser OAuth instead of static headers. In the
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
**Connect**. For manual configuration, add `auth: "oauth"` and open
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
the config automatically on first click.

```json
{
"tools": {
"mcpServers": {
"notion": {
"type": "streamableHttp",
"url": "https://mcp.notion.com/mcp",
"auth": "oauth"
}
}
}
}
```

nanobot opens the server's authorization page and handles the callback through
the gateway. The tools become available immediately when hot reload succeeds;
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
registration data are stored in the nanobot data directory under
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
from Apps also removes its saved OAuth credentials. Normal gateway startup never
opens a browser or registers a new OAuth client when credentials are
missing—interactive authorization starts only after a user clicks **Connect**.

For a remotely accessed WebUI, HTTPS is recommended. Configure
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
nanobot can register the matching HTTPS callback and finish automatically. A
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
nanobot instead registers a localhost callback and asks you to paste the complete
callback URL from the browser address bar after authorization.

> [!IMPORTANT]
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.

Use `toolTimeout` to override the default 30s per-call timeout for slow servers:

Expand Down
15 changes: 12 additions & 3 deletions docs/guides/configure-mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ remote HTTP endpoint.
For local interactive setup:

1. Run `nanobot webui` and open **Apps**.
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
return automatically. From a remote plain-HTTP WebUI, copy the complete
localhost callback URL from the browser address bar and paste it into nanobot.
3. Limit the enabled tools when the server exposes more than the task needs.
4. Save and restart when prompted.
5. Mention the integration with `@` in the next message and ask for a small test action.
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.

For manual or deployment-managed config, add this to `~/.nanobot/config.json`:

Expand All @@ -58,12 +63,16 @@ Restart nanobot and ask a question that requires the MCP tool.
- Prefer `enabledTools` over exposing every tool by default.
- Use `toolTimeout` for slow MCP operations.
- Use HTTP MCP only for endpoints you trust.
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
browser connection from **Apps → MCP**.
- Keep MCP server commands stable and versioned in deployment docs or scripts.

## Security notes

- Stdio MCP starts a local process; review the command before enabling it.
- HTTP/SSE MCP uses nanobot's SSRF guard.
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
token exchange, and redirects.
- OAuth credentials live in the nanobot data directory, not in `config.json`.
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
- Do not place secrets in command arguments when environment variables or
headers can be used.
Expand Down
13 changes: 9 additions & 4 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,13 @@ turn. The default **Ready** view shows only tools that can be used immediately:
- **Apps** are local command-line adapters that nanobot runs on your machine.
Installing an adapter does not modify the native desktop or web app it
connects to.
- **Integrations** are MCP servers. Presets provide known configurations, and
the custom integration panel accepts stdio, HTTP, and SSE servers.
- **MCP** lists Model Context Protocol servers. Presets provide known
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
headers. After saving an OAuth server, choose **Connect** to open its sign-in
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
field for pasting the complete localhost callback URL.

Apps intentionally does not list nanobot runtime support packages such as
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
Expand All @@ -226,8 +231,8 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
It is an optional integration and does not replace nanobot's built-in web search
provider; mention `@parallel-search` when a turn should use it.

After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message.
After an App or MCP server is available, mention it from the composer with `@`
to attach that tool to the next message.

## Skills

Expand Down
91 changes: 78 additions & 13 deletions nanobot/agent/tools/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition

from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
from nanobot.config.schema import MCPServerConfig

# Transient connection errors that warrant a single retry.
Expand Down Expand Up @@ -184,6 +185,25 @@ def _is_transient(exc: BaseException) -> bool:
return type(exc).__name__ in _TRANSIENT_EXC_NAMES


def _is_transient_connection_failure(exc: BaseException) -> bool:
if isinstance(exc, BaseExceptionGroup):
group = cast(BaseExceptionGroup[BaseException], exc)
return bool(group.exceptions) and all(
_is_transient_connection_failure(nested) for nested in group.exceptions
)
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)


def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
if _is_transient_connection_failure(exc):
logger.warning("MCP server '{}': transient connection failure", name)
logger.opt(exception=exc).debug(
"MCP server '{}' transient connection failure details", name
)
return
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)


def _is_session_terminated(exc: BaseException) -> bool:
"""Return True when the MCP SDK reports a dead client session."""
if _is_transient(exc):
Expand Down Expand Up @@ -961,7 +981,10 @@ async def execute(self, **kwargs: Any) -> str:


async def connect_mcp_servers(
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
mcp_servers: "dict[str, MCPServerConfig]",
registry: ToolRegistry,
*,
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.

Expand Down Expand Up @@ -1001,6 +1024,29 @@ async def open_single_server(
)
return False

oauth_auth: httpx.Auth | None = None
if cfg.auth == "oauth":
if transport_type not in {"sse", "streamableHttp"}:
logger.warning(
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
name,
)
return False
from nanobot.agent.tools.mcp_oauth import (
MCPAuthorizationRequiredError,
create_mcp_oauth_auth,
)

try:
oauth_auth = await create_mcp_oauth_auth(
name,
cfg.url,
(oauth_handlers or {}).get(name),
)
except MCPAuthorizationRequiredError:
logger.info("MCP server '{}': waiting for browser authorization", name)
return False

if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
cfg.command,
Expand Down Expand Up @@ -1038,22 +1084,30 @@ def httpx_client_factory(
**_pinned_transport_kwargs(),
)

sse_kwargs: dict[str, Any] = {
"httpx_client_factory": httpx_client_factory,
}
if oauth_auth is not None:
sse_kwargs["auth"] = oauth_auth
read, write = await server_stack.enter_async_context(
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
sse_client(cfg.url, **sse_kwargs)
)
elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
return False

http_client_kwargs: dict[str, Any] = {
"headers": cfg.headers or None,
"event_hooks": {"request": [_validate_mcp_request_url]},
"follow_redirects": True,
"timeout": httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
}
if oauth_auth is not None:
http_client_kwargs["auth"] = oauth_auth
http_client = await server_stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.headers or None,
event_hooks={"request": [_validate_mcp_request_url]},
follow_redirects=True,
timeout=httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
)
httpx.AsyncClient(**http_client_kwargs)
)
read, write, _ = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
Expand Down Expand Up @@ -1182,7 +1236,7 @@ def httpx_client_factory(
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
_log_mcp_connection_failure(name, e, hint)
return False

async def connect_single_server(
Expand Down Expand Up @@ -1229,7 +1283,7 @@ async def own_connection() -> None:
try:
result = await connect_single_server(name, cfg)
except Exception as e:
logger.exception("MCP server '{}' connection failed: {}", name, e)
_log_mcp_connection_failure(name, e)
continue
if result[1] is not None:
server_stacks[result[0]] = result[1]
Expand Down Expand Up @@ -1302,6 +1356,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials

authorization_pending = {
name
for name, cfg in next_servers.items()
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
}
removed = sorted(current_names - next_names)
added = sorted(next_names - current_names)
changed = sorted(
Expand All @@ -1319,9 +1380,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
retry_missing = sorted(
name
for name in next_names
if name not in state._mcp_stacks and name not in set(added) | set(changed)
if name not in state._mcp_stacks
and name not in set(added) | set(changed)
and name not in authorization_pending
)
to_connect_names = sorted(
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
)
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
Expand Down
Loading
Loading