mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""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)
|