Files
hermes-agent/tools/connectors/mcp_oauth.py
beardthelion 6aaa1c480b fix(profile-scope): bind home and secret tokens inside the try that resets them
Several profile-scope binders called set_hermes_home_override and
set_secret_scope before the try/finally that releases them. A raise in
scope setup (a corrupt or removed profile home) propagated with the
foreign override still bound to the caller's context, silently re-homing
every later read — and in _reregister_orphaned_adopters it also skipped
every remaining adopter. The set calls now run inside the try with
None-guarded resets; the same shape is fixed in the routed-turn scope,
the cron external worker (which also leaked the multiplex flag), the
kanban worker scope, the MCP OAuth paths, the launch-profile policy,
and model_switch, which releases partially-bound scopes on raise.
2026-09-23 06:46:02 -07:00

347 lines
15 KiB
Python

"""MCP OAuth worker and callback receivers used by connection cards and gateway RPC flows."""
from __future__ import annotations
import http.server
import logging
import os
import secrets
import threading
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
URL_TIMEOUT_SECONDS = 30.0
def probe_with_rollback(
server_name: str, cfg: dict, hermes_home: str, flow, reconnect_live: bool, *,
on_commit: Optional[Callable[[], None]] = None) -> None:
"""Roll back failures through initialize; commit authorization before tool discovery.
``on_commit`` runs right after the configuration is saved: a card install persists its setup
values there, so they land with the authorization and never before it."""
from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server
from tools.mcp_dashboard_oauth import exception_message
from tools.mcp_oauth import HermesTokenStorage, login_connect_timeout
from tools.mcp_oauth_manager import get_manager
manager = get_manager()
storage = HermesTokenStorage(server_name)
# An attempt that replaced a still-running one starts from that one's half-written files, so
# it carries the older attempt's snapshot: the state from before either of them.
backup = getattr(flow, "inherited_backup", None) or storage.snapshot()
if flow is not None:
flow.backup = backup
previous_entry = None
details: Dict[str, Any] = {}
tools: list = []
discovery_error = ""
def undo() -> None:
# ``manager.remove`` cleared the pre-attempt tokens, so anything on disk now is this
# attempt's grant, and it is not being kept: put the snapshot back. A newer attempt for
# the same server owns the token files; an older one must not write over it.
if flow is not None and _ACTIVE.get((hermes_home, server_name)) not in (None, flow):
return
storage.restore(backup)
manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home)
try:
previous_entry = manager.remove(server_name, hermes_home=hermes_home)
tools = _probe_single_server(
server_name, cfg, connect_timeout=login_connect_timeout(cfg), details=details)
if not _oauth_tokens_present(server_name):
details["initialized"] = False
raise RuntimeError(
"The server responded, but no OAuth token was obtained — "
"this provider may require a manually-registered OAuth client.")
except Exception as exc:
if not details.get("initialized"):
undo()
raise
tools, discovery_error = [], exception_message(exc)
try:
_commit(server_name, cfg, on_commit, flow)
except AttemptCanceled:
undo()
raise
if flow is not None:
flow.tools = [{"name": t, "description": d} for t, d in tools]
flow.discovery_error = discovery_error
flow.mark_approved()
if discovery_error:
return
if reconnect_live:
from tools.mcp_tool_loop import reconnect_mcp_server
reconnect_mcp_server(server_name)
class AttemptCanceled(RuntimeError):
"""The user canceled this attempt before it committed; nothing of it is kept."""
# One lock orders a cancel against the commit, so an attempt is either canceled with nothing kept or
# committed with everything kept. A worker parked inside the token request cannot be interrupted;
# it is stopped here, at the one point where its result would be adopted.
_COMMIT_GUARD = threading.Lock()
# (hermes home, server) -> the newest card attempt. A retry or a new operation replaces an attempt
# whose worker is still waiting on the browser; the older one is canceled so it cannot commit later.
_ACTIVE: Dict[tuple, Any] = {}
def cancel_attempt(flow) -> bool:
"""Cancel a card attempt. True when it had already committed: the authorization stays."""
with _COMMIT_GUARD:
if getattr(flow, "committed", False):
return True
flow.cancelled = True
# Wakes a worker that is still waiting for the browser; a cancelled flow is never re-minted.
flow.mark_error("canceled", cancelled=True)
return False
def _commit(server_name: str, cfg: dict, on_commit: Optional[Callable[[], None]], flow=None) -> None:
from hermes_cli.mcp_config import _save_mcp_server
with _COMMIT_GUARD:
if flow is not None and getattr(flow, "cancelled", False):
raise AttemptCanceled("canceled")
if not _save_mcp_server(server_name, cfg):
raise RuntimeError(f"'{server_name}' was rejected: suspicious command/args configuration")
if on_commit is not None:
on_commit()
if flow is not None:
flow.committed = True
def _reuse_saved_authorization(
server_name: str, cfg: dict, flow, on_commit: Optional[Callable[[], None]]) -> bool:
"""Connect with the tokens already on disk, with no browser step and no consent.
Retrying discovery for a server that is authorized must not ask the user to sign in again, and
must not delete the working grant first. Any failure here falls through to the interactive
flow, which replaces the grant."""
from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server
from tools.mcp_oauth import suppress_interactive_oauth
if not _oauth_tokens_present(server_name):
return False
try:
with suppress_interactive_oauth():
tools = _probe_single_server(server_name, cfg, connect_timeout=30)
_commit(server_name, cfg, on_commit, flow)
except Exception as exc:
logger.debug("saved authorization for %s was not usable: %s", server_name, exc)
return False
if flow is not None:
flow.tools = [{"name": t, "description": d} for t, d in tools]
flow.discovery_error = ""
flow.mark_approved()
return True
def run_worker(
hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool, *,
flow, on_done: Optional[Callable[[], None]] = None,
env: Optional[Dict[str, str]] = None, on_commit: Optional[Callable[[], None]] = None,
reuse_saved: bool = False) -> None:
"""Drive the interactive MCP OAuth probe under the shared callback bridge.
``env`` holds a card install's setup values. They join the secret scope for this attempt only,
so the configuration can reference them before anything is saved. ``reuse_saved`` is the
card's rule: a server whose saved tokens still work connects with no consent step. The RPC
session surface keeps it off, because its caller waits for an authorization URL."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
try:
from agent.secret_scope import (
build_profile_secret_scope, reset_secret_scope, set_secret_scope)
from tools.mcp_dashboard_oauth import dashboard_oauth_flow
from tools.mcp_oauth import force_interactive_oauth
home_token = secret_token = None
try:
home_token = set_hermes_home_override(hermes_home)
secret_token = set_secret_scope(
{**build_profile_secret_scope(Path(hermes_home)), **(env or {})}, profile_home=hermes_home)
if not (reuse_saved and flow is not None
and _reuse_saved_authorization(server_name, cfg, flow, on_commit)):
with force_interactive_oauth(), dashboard_oauth_flow(flow):
probe_with_rollback(
server_name, cfg, hermes_home, flow, reconnect_live, on_commit=on_commit)
finally:
if secret_token is not None:
reset_secret_scope(secret_token)
if home_token is not None:
reset_hermes_home_override(home_token)
except Exception as exc:
from tools.mcp_dashboard_oauth import exception_message
msg = exception_message(exc)
with suppress(Exception):
from tools.mcp_oauth import humanize_oauth_registration_error
msg = humanize_oauth_registration_error(
server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
) or msg
if flow is not None:
flow.mark_error(msg)
finally:
if flow is not None:
flow.mark_worker_done()
with _COMMIT_GUARD:
if _ACTIVE.get((hermes_home, server_name)) is flow:
_ACTIVE.pop((hermes_home, server_name), None)
if on_done is not None:
on_done()
def _validate_client_redirect_uri(uri: str) -> str:
"""Accept only plain-http loopback URLs (RFC 8252)."""
parsed = urlparse(str(uri or "").strip())
host = (parsed.hostname or "").lower()
if (parsed.scheme != "http" or host not in ("127.0.0.1", "localhost", "::1") or not parsed.port
or parsed.username is not None or parsed.password is not None):
raise ValueError(
"client_redirect_uri must be a loopback http URL like http://127.0.0.1:<port>/callback")
return f"http://{'[' + host + ']' if ':' in host else host}:{parsed.port}{parsed.path or '/callback'}"
def _start_loopback_receiver(flow) -> "http.server.HTTPServer":
"""Bind the single backend-hosted one-shot receiver and feed its callback into ``flow``."""
from tools.mcp_oauth import _parse_redirect_query
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path.rstrip("/") not in ("/callback", ""):
self.send_response(404)
self.end_headers()
return
body = b"<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>"
status = 200
try:
flow.deliver_callback(**_parse_redirect_query(parsed.query))
except Exception:
body = b"<h1>OAuth callback rejected</h1><p>The callback was invalid or already used.</p>"
status = 400
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
with suppress(Exception):
self.wfile.write(body)
def log_message(self, format, *args):
return
httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.5}, daemon=True,
name=f"mcp-oauth-cb-{flow.server_name}").start()
return httpd
def _pinned_loopback(cfg: dict) -> bool:
oauth = cfg.get("oauth") or {}
return bool(oauth.get("client_id") and oauth.get("redirect_port"))
def _pinned_redirect_uri(cfg: dict) -> str:
oauth = cfg.get("oauth") or {}
host = oauth.get("redirect_host") or "127.0.0.1"
return f"http://{host}:{oauth['redirect_port']}/callback"
def choose_callback_receiver(flow, cfg: dict, client_redirect_uri: Optional[str] = None):
"""Set the redirect consumed by the SDK and return the optional backend HTTP receiver."""
if _pinned_loopback(cfg):
flow.redirect_uri = _pinned_redirect_uri(cfg)
return None
if client_redirect_uri is not None:
flow.redirect_uri = _validate_client_redirect_uri(client_redirect_uri)
return None
httpd = _start_loopback_receiver(flow)
flow.redirect_uri = f"http://127.0.0.1:{httpd.server_address[1]}/callback"
return httpd
def _ssh_detail(redirect_uri: str) -> str:
if not (os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY")) or not redirect_uri:
return ""
from tools.mcp_oauth import _SSH_HINT_LOOPBACK
parsed = urlparse(redirect_uri)
return _SSH_HINT_LOOPBACK.format(host=parsed.hostname or "127.0.0.1", port=parsed.port or 0).strip()
@dataclass
class OAuthAttempt:
"""One flow in flight: its URL, callback bridge, and outcome."""
auth_url: str
flow: Any
detail: str = ""
def poll(self) -> Dict[str, Any]:
snapshot = self.flow.snapshot()
raw = snapshot.get("status")
status = raw if raw in ("approved", "error") else "pending"
tools = [str(t.get("name") or "") for t in (getattr(self.flow, "tools", None) or [])]
return {"status": status, "error": snapshot.get("error") or "",
"tools": [t for t in tools if t] if status == "approved" else [],
"discovery_error": (getattr(self.flow, "discovery_error", "") or "")
if status == "approved" else ""}
def start(
server_name: str, *, url_timeout: float = URL_TIMEOUT_SECONDS,
client_redirect_uri: Optional[str] = None, cfg: Optional[dict] = None,
env: Optional[Dict[str, str]] = None, on_commit: Optional[Callable[[], None]] = None,
) -> OAuthAttempt:
"""Start a card OAuth flow and wait until its authorization URL is published.
``cfg`` is an install's in-memory configuration; without it the saved one is authorized."""
from hermes_cli.mcp_config import _get_mcp_servers
from hermes_constants import get_hermes_home
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
from tui_gateway import mcp_oauth_sessions
cfg = dict(cfg if cfg is not None else _get_mcp_servers().get(server_name) or {})
if not cfg:
raise RuntimeError(f"'{server_name}' is not a configured MCP server")
if not cfg.get("url"):
raise RuntimeError(f"'{server_name}' is a stdio server: it takes env keys, not OAuth")
cfg["auth"] = "oauth"
hermes_home = str(get_hermes_home().expanduser().resolve(strict=False))
flow = DashboardOAuthFlow(
flow_id=secrets.token_urlsafe(24), server_name=server_name, profile=None,
hermes_home=hermes_home, redirect_uri="", reconnect_live=False)
with _COMMIT_GUARD:
older = _ACTIVE.get((hermes_home, server_name))
_ACTIVE[(hermes_home, server_name)] = flow
if older is not None and not older.worker_done:
flow.inherited_backup = getattr(older, "backup", None)
cancel_attempt(older)
httpd = choose_callback_receiver(flow, cfg, client_redirect_uri)
mcp_oauth_sessions.register_flow(flow, httpd=httpd)
threading.Thread(
target=run_worker, args=(hermes_home, server_name, cfg, False),
kwargs={"flow": flow, "on_done": lambda: mcp_oauth_sessions.finish_flow(flow.flow_id),
"reuse_saved": True,
**({"env": env, "on_commit": on_commit} if env or on_commit else {})},
daemon=True, name=f"mcp-oauth-{server_name}").start()
deadline = time.time() + url_timeout
while time.time() < deadline:
snapshot = flow.snapshot()
if snapshot.get("status") == "approved":
return OAuthAttempt(auth_url="", flow=flow) # the saved authorization still works
if snapshot.get("authorization_url"):
return OAuthAttempt(
auth_url=snapshot["authorization_url"], flow=flow,
detail=_ssh_detail(flow.redirect_uri) if client_redirect_uri is None else "")
if snapshot.get("status") == "error":
raise RuntimeError(snapshot.get("error") or "the OAuth flow failed before authorization")
time.sleep(0.05)
flow.mark_error("Timed out waiting for MCP authorization URL")
raise TimeoutError(f"timed out waiting for the authorization URL for '{server_name}'")