Files
hermes-agent/tools/mcp_oauth.py
T

1434 lines
58 KiB
Python

#!/usr/bin/env python3
"""
MCP OAuth 2.1 Client Support
Browser-based OAuth 2.1 authorization-code flow with PKCE for MCP servers. The
MCP SDK's ``OAuthClientProvider`` (an ``httpx.Auth``) does discovery, client
identification, PKCE, exchange, refresh and step-up; this module supplies
``HermesTokenStorage`` (on-disk persistence), the localhost callback listener,
and ``build_oauth_auth()`` (legacy entry point). Per the MCP 2026-07-28 spec the
client_id is Hermes' published Client ID Metadata Document URL (CIMD) when the
server advertises support, else RFC 7591 dynamic client registration (DCR).
Configuration in config.yaml::
mcp_servers:
my_server:
url: "https://mcp.example.com/mcp"
auth: oauth
oauth: # all fields optional
client_id: "pre-registered-id" # skip dynamic registration
client_secret: "secret" # confidential clients only
scope: "read write" # default: server-provided
redirect_port: 0 # 0 = auto-pick free port
redirect_uri: "https://proxy/callback" # default: loopback callback
redirect_host: "localhost" # loopback hostname (WAF-safe)
client_name: "My Custom Client" # default: "Hermes Agent"
client_metadata_url: "https://me/cimd.json" # self-hosted CIMD
cimd: false # force DCR for this server
"""
import asyncio
import contextvars
import importlib.util as _importlib_util
import json
import logging
import os
import re
import secrets
import socket
import stat
import sys
import threading
import time
import webbrowser
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from hermes_constants import secure_parent_dir
logger = logging.getLogger(__name__)
# Lazy SDK imports: availability is detected WITHOUT importing mcp (~170 ms).
# Module-level names stay None placeholders so tests can patch.object them;
# _ensure_sdk_loaded() binds the real classes on first use.
_OAUTH_AVAILABLE = _importlib_util.find_spec("mcp") is not None
if not _OAUTH_AVAILABLE:
logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")
OAuthClientProvider: Any = None
OAuthClientInformationFull: Any = None
OAuthClientMetadata: Any = None
OAuthMetadata: Any = None
OAuthToken: Any = None
# Cache of the real SDK classes so a test that patches a name and restores it
# to None afterwards doesn't strand the module in a broken state.
_SDK_CLASSES: dict[str, Any] = {}
_SDK_LOAD_FAILED = False
def _ensure_sdk_loaded() -> bool:
"""Bind the SDK OAuth classes into module globals; True when available.
Only names that are currently ``None`` are (re)bound, so test patches of
the module-level names are left untouched.
"""
global _SDK_LOAD_FAILED, _OAUTH_AVAILABLE
if _SDK_LOAD_FAILED:
return False
if not _SDK_CLASSES:
try:
from mcp.client.auth import OAuthClientProvider as _Provider
from mcp.shared.auth import (
OAuthClientInformationFull as _InfoFull,
OAuthClientMetadata as _ClientMeta,
OAuthMetadata as _Meta,
OAuthToken as _Token,
)
except ImportError:
_SDK_LOAD_FAILED = True
_OAUTH_AVAILABLE = False
logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")
return False
_SDK_CLASSES.update(
OAuthClientProvider=_Provider,
OAuthClientInformationFull=_InfoFull,
OAuthClientMetadata=_ClientMeta,
OAuthMetadata=_Meta,
OAuthToken=_Token,
)
g = globals()
for _name, _cls in _SDK_CLASSES.items():
if g.get(_name) is None:
g[_name] = _cls
return True
def _sdk_class(name: str) -> Any:
"""Return the (possibly test-patched) SDK class bound under *name*, or None."""
if globals().get(name) is None and not _ensure_sdk_loaded():
return None
return globals().get(name)
try:
from pydantic import AnyUrl
except ImportError:
AnyUrl = None # type: ignore[assignment, misc]
class OAuthNonInteractiveError(RuntimeError):
"""Raised when OAuth requires browser interaction in a non-interactive env."""
# Port used by the most recent callback-port resolution. Legacy global; the
# per-flow closures are the real mechanism (concurrent flows must not share it).
_oauth_port: int | None = None
# Interactivity gates for OAuth stdin prompts. ContextVars (NOT threading.local):
# background MCP discovery sets them on the discovery thread while connect+OAuth
# runs on the `mcp-event-loop` thread via run_coroutine_threadsafe, which copies
# the calling context into the coroutine — a threading.local would not cross.
_oauth_interactive_enabled: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
"_oauth_interactive_enabled", default=True
)
# Forces _is_interactive() past the stdin-TTY check for GUI-driven flows
# (dashboard/desktop REST): the browser + callback server do the work and the
# stdin paste fallback degrades harmlessly (EOF swallowed). Suppression wins —
# background discovery must never start a browser flow.
_oauth_interactive_forced: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
"_oauth_interactive_forced", default=False
)
# Skip tokens accepted at the paste prompt — exit OAuth without auth.
_SKIP_TOKENS = frozenset({"skip", "cancel", "s", "n", "no", "q", "quit"})
# Written to result["error"] on stdin skip; the waiter maps it to
# OAuthNonInteractiveError("user_skipped") so MCP setup treats it as a
# non-fatal "continue without this server".
_USER_SKIPPED_SENTINEL = "__hermes_user_skipped__"
def _get_token_dir(hermes_home: str | Path | None = None) -> Path:
"""``HERMES_HOME/mcp-tokens/`` — per-profile token directory."""
from hermes_constants import get_hermes_home
base = Path(hermes_home) if hermes_home is not None else Path(get_hermes_home())
return base / "mcp-tokens"
def _safe_filename(name: str) -> str:
"""Sanitize a server name for use as a filename (no path separators)."""
return re.sub(r"[^\w\-]", "_", name).strip("_")[:128] or "default"
# Bound-but-not-listening sockets for pending callback flows, keyed by port.
# Holding the socket from port selection until the waiter adopts it closes the
# TOCTOU window where another process grabs the port in between. Bounded FIFO
# so reconnect loops cannot leak fds.
_reserved_sockets: "dict[int, socket.socket]" = {}
_MAX_RESERVED_SOCKETS = 8
def _park_reserved_socket(port: int, sock: socket.socket) -> None:
"""Hold *sock* bound to *port* until the callback waiter adopts it.
Pinned CIMD sockets are never evicted: the published document only
declares the pinned ports, so losing one mid-flow would reopen the exact
race the parking prevents. The FIFO cap applies to ephemeral ports only.
"""
while len(_reserved_sockets) >= _MAX_RESERVED_SOCKETS:
stale_port = next((p for p in _reserved_sockets if p not in _CIMD_PORTS), None)
if stale_port is None:
break # only pinned sockets remain — never evict those
stale = _reserved_sockets.pop(stale_port, None)
if stale is None:
continue
try:
stale.close()
except OSError:
pass
_reserved_sockets[port] = sock
def _bind_reserved(port: int) -> int | None:
"""Bind ``127.0.0.1:port`` (0 = ephemeral) and park it; None if taken."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(("127.0.0.1", port))
except OSError:
sock.close()
if port:
return None
raise
bound = sock.getsockname()[1]
_park_reserved_socket(bound, sock)
return bound
def _reserve_callback_port() -> int:
"""Pick an ephemeral callback port and keep its socket bound (parked)."""
return _bind_reserved(0) # type: ignore[return-value] # port 0 never returns None
def _cached_client_info(storage: "HermesTokenStorage | None") -> dict | None:
"""The on-disk client registration for *storage*, or None."""
if storage is None:
return None
try:
return _read_json(storage._client_info_path())
except (AttributeError, TypeError, ValueError):
return None
def _cached_redirect_uris(storage: "HermesTokenStorage | None"):
"""Yield ``(raw_uri, parsed)`` for each redirect URI in the cached registration."""
for uri in (_cached_client_info(storage) or {}).get("redirect_uris") or []:
try:
parsed = urlparse(str(uri))
except (TypeError, ValueError):
continue
yield str(uri), parsed
def _cached_redirect_port(storage: "HermesTokenStorage | None") -> int | None:
"""Loopback callback port from the cached client registration.
Providers bind a dynamically-registered ``client_id`` to the exact
redirect URI registered with it; picking a new random port on restart
while reusing the stored ``client_id`` gets ``redirect_uri does not match
any registered URIs``.
"""
for _uri, parsed in _cached_redirect_uris(storage):
if (
parsed.scheme == "http"
and parsed.hostname in {"127.0.0.1", "localhost"}
and parsed.path == "/callback"
and parsed.port is not None
):
return int(parsed.port)
return None
def _cached_redirect_uri(storage: "HermesTokenStorage | None") -> str | None:
"""A cached non-loopback (https) redirect URI, if one was registered."""
for uri, parsed in _cached_redirect_uris(storage):
if parsed.scheme == "https" and parsed.netloc:
return uri
return None
def _is_interactive() -> bool:
"""True if we can reasonably expect to interact with a user."""
if not _oauth_interactive_enabled.get():
return False
if _oauth_interactive_forced.get():
return True
try:
return sys.stdin.isatty()
except (AttributeError, ValueError):
return False
def _raise_if_non_interactive(lead: str) -> None:
"""Raise ``OAuthNonInteractiveError`` unless interactive.
``lead`` is the boundary-specific first sentence; the shared
``hermes mcp login`` next-step wording lives here only.
"""
if not _is_interactive():
raise OAuthNonInteractiveError(
f"{lead} "
"Run `hermes mcp login <server>` interactively to (re)authorize, "
"then restart or reload the gateway."
)
@contextmanager
def _contextvar_set(var: "contextvars.ContextVar", value):
token = var.set(value)
try:
yield
finally:
var.reset(token)
def force_interactive_oauth():
"""Treat the current context as interactive despite no TTY (GUI-driven auth).
The user IS present — just not on stdin. Propagates across the MCP
event-loop thread boundary like ``suppress_interactive_oauth``.
"""
return _contextvar_set(_oauth_interactive_forced, True)
def suppress_interactive_oauth():
"""Disable stdin-based OAuth prompts for the current execution context.
ContextVar-based so suppression set on a background-discovery thread
reaches the coroutine scheduled on the MCP event-loop thread.
"""
return _contextvar_set(_oauth_interactive_enabled, False)
def _can_open_browser() -> bool:
"""True if opening a browser is likely to work."""
if os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"):
return False # explicit SSH session → no local display
if os.name == "nt":
return True
try:
if os.uname().sysname == "Darwin":
return True
except AttributeError:
pass
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
def _read_json(path: Path) -> dict | None:
"""Read a JSON file, returning None if it doesn't exist or is invalid."""
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to read %s: %s", path, exc)
return None
def _write_json(path: Path, data: dict) -> None:
"""Atomically write *data* as JSON created at 0o600.
``os.open`` with ``O_EXCL`` + explicit mode avoids the write-then-chmod
TOCTOU window where the file briefly inherits the umask (often
world-readable). The parent dir is tightened to 0o700 (no-op on Windows;
``secure_parent_dir`` refuses /, top-level dirs and the install tree).
"""
path.parent.mkdir(parents=True, exist_ok=True)
secure_parent_dir(path)
# Per-process random suffix avoids collisions between concurrent writers
# and stale leftovers from a crashed write.
tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, default=str)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise
# ---------------------------------------------------------------------------
# HermesTokenStorage -- persistent token/client-info on disk
# ---------------------------------------------------------------------------
class HermesTokenStorage:
"""Persist OAuth tokens and client registration to JSON files.
File layout::
HERMES_HOME/mcp-tokens/<server_name>.json -- tokens
HERMES_HOME/mcp-tokens/<server_name>.client.json -- client info
HERMES_HOME/mcp-tokens/<server_name>.meta.json -- oauth server metadata
HERMES_HOME/mcp-tokens/<server_name>.cimd-off -- CIMD refused here
"""
def __init__(self, server_name: str, *, hermes_home: str | Path | None = None):
self._server_name = _safe_filename(server_name)
self._hermes_home = Path(hermes_home) if hermes_home is not None else None
def _path(self, suffix: str) -> Path:
return _get_token_dir(self._hermes_home) / f"{self._server_name}{suffix}"
def _tokens_path(self) -> Path:
return self._path(".json")
def _client_info_path(self) -> Path:
return self._path(".client.json")
def _meta_path(self) -> Path:
return self._path(".meta.json")
def _cimd_rejected_path(self) -> Path:
return self._path(".cimd-off")
def _state_paths(self) -> tuple[Path, Path, Path]:
return self._tokens_path(), self._client_info_path(), self._meta_path()
@staticmethod
def _load_model(path: Path, sdk_name: str, label: str, fixup=None):
"""Read *path* into SDK model *sdk_name*; None if absent, no SDK, or corrupt.
``fixup(data)`` may rewrite the raw dict before validation.
"""
data = _read_json(path)
cls = _sdk_class(sdk_name) if data is not None else None
if cls is None:
return None
if fixup is not None:
fixup(data)
try:
return cls.model_validate(data)
except (ValueError, TypeError, KeyError) as exc:
logger.warning("Corrupt %s at %s -- ignoring: %s", label, path, exc)
return None
# -- tokens ------------------------------------------------------------
def _rebase_expires_in(self, data: dict) -> None:
"""Rewrite ``expires_in`` to the seconds remaining now.
``set_tokens`` stores an absolute ``expires_at`` (not an SDK field, so
it is stripped here); a relative value reloaded after restart would make
``is_token_valid()`` True for tokens that expired while down. Legacy
files without it use the file mtime as a best-effort write time,
clamped to zero (self-heals on the next ``set_tokens``).
"""
absolute_expiry = data.pop("expires_at", None)
if absolute_expiry is not None:
data["expires_in"] = int(max(absolute_expiry - time.time(), 0))
elif data.get("expires_in") is not None:
try:
implied_expiry = self._tokens_path().stat().st_mtime + int(data["expires_in"])
data["expires_in"] = int(max(implied_expiry - time.time(), 0))
except (OSError, TypeError, ValueError):
pass
async def get_tokens(self) -> "OAuthToken | None":
return self._load_model(self._tokens_path(), "OAuthToken", "tokens", self._rebase_expires_in)
async def set_tokens(self, tokens: "OAuthToken") -> None:
payload = tokens.model_dump(mode="json", exclude_none=True)
# Persist an absolute ``expires_at``: a relative ``expires_in`` reloaded
# after restart has no wall-clock reference, leaving the SDK's
# ``token_expiry_time=None`` and ``is_token_valid()`` falsely True.
expires_in = payload.get("expires_in")
if expires_in is not None:
try:
payload["expires_at"] = time.time() + int(expires_in)
except (TypeError, ValueError):
pass # mock tokens / odd shapes: skip rather than fail persistence
_write_json(self._tokens_path(), payload)
logger.debug("OAuth tokens saved for %s", self._server_name)
# -- client info -------------------------------------------------------
@staticmethod
def _coerce_secret_auth_method(data: dict) -> bool:
"""Set ``client_secret_post`` when a secret is present but no method is.
Some DCR providers (notably Supabase) return a ``client_secret`` but
omit ``token_endpoint_auth_method``; the SDK defaults it to ``none``,
omits the secret at the token endpoint and the exchange fails.
"""
if data.get("client_secret") and data.get("token_endpoint_auth_method") in (None, "none", ""):
data["token_endpoint_auth_method"] = "client_secret_post"
return True
return False
async def get_client_info(self) -> "OAuthClientInformationFull | None":
coerced: list[bool] = []
info = self._load_model(
self._client_info_path(), "OAuthClientInformationFull", "client info",
lambda data: coerced.append(self._coerce_secret_auth_method(data)),
)
if info is not None and coerced and coerced[0]:
# Persist the effective method so later flows skip the coercion.
_write_json(self._client_info_path(), info.model_dump(mode="json", exclude_none=True))
return info
async def set_client_info(self, client_info: "OAuthClientInformationFull") -> None:
data = client_info.model_dump(mode="json", exclude_none=True)
self._coerce_secret_auth_method(data)
_write_json(self._client_info_path(), data)
logger.debug("OAuth client info saved for %s", self._server_name)
# -- oauth server metadata --------------------------------------------
# The SDK keeps discovered ``OAuthMetadata`` in memory only. Persisting it
# lets a restarted process refresh without re-discovery; otherwise the SDK
# guesses ``{server_url}/token`` (404 on most providers) and forces a full
# browser re-authorization.
def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None:
_write_json(self._meta_path(), metadata.model_dump(exclude_none=True, mode="json"))
logger.debug("OAuth metadata saved for %s", self._server_name)
def load_oauth_metadata(self) -> "OAuthMetadata | None":
return self._load_model(self._meta_path(), "OAuthMetadata", "OAuth metadata")
# -- CIMD refusal ------------------------------------------------------
def mark_cimd_rejected(self) -> None:
"""Durably record that this server refused our Client ID Metadata Document.
The in-memory fallback only holds for one process; without a marker
every restart re-presents a refused client_id. Cleared by ``remove()``
(``hermes mcp login`` / ``remove``) so a fixed document gets a retry.
"""
path = self._cimd_rejected_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
except OSError as exc: # non-fatal — worst case we retry CIMD later
logger.debug("Could not record CIMD rejection at %s: %s", path, exc)
def cimd_rejected(self) -> bool:
"""True when this server has refused our metadata document before."""
return self._cimd_rejected_path().exists()
# -- cleanup -----------------------------------------------------------
def remove(self) -> None:
"""Delete all stored OAuth state for this server."""
for p in (*self._state_paths(), self._cimd_rejected_path()):
p.unlink(missing_ok=True)
def snapshot(self) -> dict[str, bytes]:
"""filename -> bytes for the existing state files; feed to ``restore()``
to undo a ``remove()`` after a failed re-auth so a valid token survives."""
snap: dict[str, bytes] = {}
for p in self._state_paths():
try:
snap[p.name] = p.read_bytes()
except OSError:
pass
return snap
def restore(self, snapshot: dict[str, bytes], *, only_if_absent: bool = False) -> None:
"""Revert to a snapshot without overwriting a concurrent successful write."""
if only_if_absent and any(path.exists() for path in self._state_paths()):
logger.info(
"Skipping OAuth rollback for %s because newer state exists",
self._server_name,
)
return
self.remove()
if not snapshot:
return
token_dir = _get_token_dir(self._hermes_home)
token_dir.mkdir(parents=True, exist_ok=True)
for fname, data in snapshot.items():
path = token_dir / fname
try:
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
except OSError as exc:
logger.warning("Failed to restore OAuth state %s: %s", fname, exc)
def poison_client_registration(self) -> bool:
"""Discard a dead dynamically-registered client (``invalid_client`` at the
token endpoint) so the SDK re-runs DCR next flow; stale ``meta.json``
goes too. Tokens are kept: re-auth overwrites them and a valid refresh
token survives if re-registration never completes. Keeps one ``.bak``.
Returns True if a client file was present and removed.
"""
client_path = self._client_info_path()
if not client_path.exists():
return False
backup = client_path.with_name(client_path.name + ".bak")
try:
backup.write_bytes(client_path.read_bytes())
except OSError as exc: # non-fatal — proceed with the removal anyway
logger.warning("Could not back up client info at %s: %s", client_path, exc)
client_path.unlink(missing_ok=True)
self._meta_path().unlink(missing_ok=True)
logger.warning(
"MCP OAuth '%s': cached client registration rejected as invalid_client; "
"removed client.json + meta.json (backup at %s) to force re-registration",
self._server_name, backup.name,
)
return True
def has_cached_tokens(self) -> bool:
"""True if we have tokens on disk (may be expired)."""
return self._tokens_path().exists()
# ---------------------------------------------------------------------------
# Callback capture -- HTTP listener and stdin paste share one result dict
# ---------------------------------------------------------------------------
def _authorization_code_result(code: str, state: "str | None", iss: "str | None" = None):
"""Package redirect parameters in the shape the installed SDK expects.
mcp 2.0's ``callback_handler`` contract returns an ``AuthorizationCodeResult``
(the SDK reads ``.state`` / ``.iss`` off it); older SDKs take a tuple.
"""
try:
from mcp.shared.auth import AuthorizationCodeResult
except ImportError: # mcp < 2.0
return code, state
return AuthorizationCodeResult(code=code, state=state, iss=iss)
def _parse_redirect_query(query: str) -> dict[str, Any]:
"""Extract code/state/error/iss from a redirect query string.
``iss`` is the RFC 9207 authorization-response issuer: mcp 2.0 rejects a
response that omits it when the server advertised
``authorization_response_iss_parameter_supported``, so it must be kept.
"""
params = parse_qs(query)
return {k: params.get(k, [None])[0] for k in ("code", "state", "error", "iss")}
def _result_taken(result: dict) -> bool:
return result.get("auth_code") is not None or result.get("error") is not None
def _fill_result(result: dict, parsed: dict[str, Any]) -> None:
result["auth_code"] = parsed["code"]
result["state"] = parsed["state"]
result["error"] = parsed["error"]
result["iss"] = parsed["iss"]
def _make_callback_handler() -> tuple[type, dict]:
"""Create a per-flow callback HTTP handler class with its own result dict.
Each call returns a fresh ``(HandlerClass, result_dict)`` so concurrent
flows don't stomp on each other.
"""
result: dict[str, Any] = {"auth_code": None, "state": None, "error": None, "iss": None}
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = _parse_redirect_query(urlparse(self.path).query)
_fill_result(result, parsed)
body = (
"<html><body><h2>Authorization Successful</h2>"
"<p>You can close this tab and return to Hermes.</p></body></html>"
) if parsed["code"] else (
"<html><body><h2>Authorization Failed</h2>"
f"<p>Error: {parsed['error'] or 'unknown'}</p></body></html>"
)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, fmt: str, *args: Any) -> None:
logger.debug("OAuth callback: %s", fmt % args)
return _Handler, result
def _paste_callback_reader(result: dict) -> None:
"""Read one stdin line, parse it as an OAuth redirect, write to *result*.
Accepts a full redirect URL, the provider's own callback URL, just the
query string (``?code=...&state=...`` or ``code=...``), or a skip token
(``skip``/``cancel``/``s``/``n``/``no``/``q``/``quit``) which exits the flow
without auth. Parse failures, EOF and interrupts are swallowed — this is a
best-effort fallback racing the HTTP listener, which stays primary.
"""
try:
line = sys.stdin.readline()
except (KeyboardInterrupt, OSError, ValueError):
return
line = line.strip() if line else ""
if not line or _result_taken(result):
return # EOF / blank, or the HTTP listener already won
if line.lower() in _SKIP_TOKENS:
result["error"] = _USER_SKIPPED_SENTINEL
print(
" OAuth skipped. Run `hermes mcp login <server>` later to "
"authenticate, or set ``enabled: false`` on that server in "
"config.yaml to disable persistently.",
file=sys.stderr,
)
return
# Full URL or "?code=...": take everything after the first "?".
query = line.split("?", 1)[1] if "?" in line else line
if query.startswith("?"):
query = query[1:]
try:
parsed = _parse_redirect_query(query)
except (ValueError, TypeError):
print(" Could not parse pasted input as an OAuth redirect — ignoring.", file=sys.stderr)
return
if not parsed["code"] and not parsed["error"]:
print(" Pasted input did not contain ``code=`` or ``error=`` — ignoring.", file=sys.stderr)
return
if _result_taken(result): # one more race-check before writing
return
_fill_result(result, parsed)
if parsed["code"]:
print(" Got authorization code from paste — completing flow.", file=sys.stderr)
# ---------------------------------------------------------------------------
# Async redirect + callback handlers for OAuthClientProvider
# ---------------------------------------------------------------------------
def _print_ssh_hint(port: int, redirect_uri: str | None) -> None:
"""Remote-session guidance printed under the authorization URL."""
if redirect_uri:
# A proxy callback (e.g. Tailscale Funnel) forwards the redirect to the
# listener on this machine, so no tunnel/paste is needed.
print(
f" Remote session detected. After you authorize, the provider redirects to\n"
f" {redirect_uri}\n"
f" which forwards to the callback listener on this machine — no SSH tunnel needed.\n",
file=sys.stderr,
)
elif port:
# Loopback default: the redirect reaches the *remote* machine's listener,
# not the browser's machine. Paste the redirect URL back, or SSH-forward.
print(
f" Remote session detected. After you authorize, the provider redirects to\n"
f" http://127.0.0.1:{port}/callback\n"
f" which only the listener on THIS machine can receive. Two options:\n"
f"\n"
f" 1. Easiest — when your browser shows a connection error after\n"
f" authorizing, copy the full URL from the address bar and paste\n"
f" it at the prompt below. The pasted ``code=...&state=...`` is\n"
f" enough to complete the flow.\n"
f"\n"
f" 2. Or forward the port first in a separate terminal:\n"
f" ssh -N -L {port}:127.0.0.1:{port} <user>@<this-host>\n"
f" then open the URL above and let it redirect normally.\n"
f"\n"
f" See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n",
file=sys.stderr,
)
def _make_redirect_handler(port: int, redirect_uri: str | None = None):
"""Return a redirect handler closing over this flow's port.
A closure (not the module-level ``_oauth_port``) keeps concurrent
server flows isolated. ``redirect_uri`` is a configured proxy callback
(or None for loopback) and only tailors the remote-session hint.
"""
async def _redirect_handler(authorization_url: str) -> None:
"""Open the browser when possible; always print the URL as a fallback."""
from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow
dashboard_flow = get_dashboard_oauth_flow()
if dashboard_flow is not None:
await dashboard_flow.publish_authorization_url(authorization_url)
return
# Fail fast in non-interactive contexts (systemd gateway, cron,
# background discovery): a cached-but-unusable token makes the SDK fall
# through to the authorization-code flow even though the token-file
# guard passed, and we would otherwise block in the waiter for the full
# timeout. Deliberately re-checks interactivity here.
_raise_if_non_interactive(
"MCP OAuth requires browser authorization but no interactive "
"session is available (non-interactive/background context)."
)
print(
f"\n MCP OAuth: authorization required.\n"
f" Open this URL in your browser:\n\n"
f" {authorization_url}\n",
file=sys.stderr,
)
if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
_print_ssh_hint(port, redirect_uri)
if not _can_open_browser():
print(" (Headless environment detected — open the URL manually.)\n", file=sys.stderr)
return
try:
opened = webbrowser.open(authorization_url)
except Exception:
opened = False
if opened:
print(" (Browser opened automatically.)\n", file=sys.stderr)
else:
print(" (Could not open browser — please open the URL manually.)\n", file=sys.stderr)
return _redirect_handler
def _start_callback_server(port: int, handler_cls: type) -> HTTPServer:
"""Bind the callback listener on *port*, adopting a parked reserved socket.
Adopting the bound socket closes the select→bind TOCTOU window.
``allow_reuse_address`` is set BEFORE binding (a no-op afterwards) so a
lingering TIME_WAIT socket from a previous flow cannot block the next.
"""
try:
server = HTTPServer(("127.0.0.1", port), handler_cls, bind_and_activate=False)
reserved = _reserved_sockets.pop(port, None)
if reserved is not None:
server.socket.close()
server.socket = reserved
server.server_address = reserved.getsockname()
else:
server.allow_reuse_address = True
server.server_bind()
server.server_activate()
except OSError as exc:
# Genuinely in use: a concurrent flow, leftover listener, or colliding
# fixed `oauth.redirect_port`. Nothing to poll — say so, not "timed out".
raise OAuthNonInteractiveError(
f"OAuth callback port {port} is already in use ({exc}). "
"Close any other in-progress login, or set a free `oauth.redirect_port` "
"in the server config, then retry."
) from exc
return server
def _make_callback_waiter(
port: int, cimd_url: str | None = None, timeout: float = 300.0
):
"""Return a callback waiter bound to one flow's port (isolating concurrent flows).
``timeout`` is the only place ``oauth.timeout`` applies (mcp 2.0 dropped the
provider's own). ``cimd_url`` only tailors the timeout message: a server
that refuses the document aborts at the *authorization* endpoint, so no
redirect arrives and a bare "timed out" would hide the cause. On a TTY the
HTTP listener races a stdin paste fallback. Raises
``OAuthNonInteractiveError`` on timeout or when non-interactive.
"""
async def _wait():
from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow
dashboard_flow = get_dashboard_oauth_flow()
if dashboard_flow is not None:
# Dashboard flow speaks the legacy tuple; normalize to one shape.
dash_code, dash_state = await dashboard_flow.wait_for_callback()
return _authorization_code_result(dash_code, dash_state)
# Reaching here means the SDK entered the authorization-code flow, so
# any cached token is unusable. Reject BEFORE binding: binding would
# block for the full timeout and collide with the TIME_WAIT port on
# retry (``Address already in use``). Holds regardless of token files.
_raise_if_non_interactive(
"OAuth callback requires an interactive session but none is "
"available (non-interactive/background context); skipping browser "
"authorization without binding a callback listener."
)
handler_cls, result = _make_callback_handler()
server = _start_callback_server(port, handler_cls)
threading.Thread(target=server.handle_request, daemon=True).start()
# Paste fallback races the HTTP listener; whichever fills result first wins.
if _is_interactive():
print(
"\n Or paste the redirect URL here (or the ``?code=...&state=...`` "
"portion) and press Enter. Type ``skip`` + Enter to continue "
"without this server:",
file=sys.stderr,
flush=True,
)
threading.Thread(target=_paste_callback_reader, args=(result,), daemon=True).start()
poll_interval = 0.5
elapsed = 0.0
try:
while elapsed < timeout and not _result_taken(result):
await asyncio.sleep(poll_interval)
elapsed += poll_interval
finally:
server.server_close()
if result["error"] == _USER_SKIPPED_SENTINEL:
raise OAuthNonInteractiveError("user_skipped")
if result["error"]:
raise RuntimeError(f"OAuth authorization failed: {result['error']}")
if result["auth_code"] is None:
hint = ""
if cimd_url:
hint = (
" If the browser showed an invalid-client error instead of "
"an approval prompt, the authorization server rejected "
f"Hermes' Client ID Metadata Document ({cimd_url}); set "
"``cimd: false`` under that server's ``oauth:`` block in "
"config.yaml to authorize via dynamic client registration "
"instead."
)
raise OAuthNonInteractiveError(
"OAuth callback timed out — no authorization code received. "
"Ensure you completed the browser authorization flow." + hint
)
return _authorization_code_result(result["auth_code"], result["state"], result.get("iss"))
return _wait
# ---------------------------------------------------------------------------
# OAuth provider class (legacy build_oauth_auth path)
# ---------------------------------------------------------------------------
HermesOAuthClientProvider: Any = None
def _get_hermes_oauth_provider_class() -> type | None:
global HermesOAuthClientProvider
if HermesOAuthClientProvider is not None:
return HermesOAuthClientProvider
if not _ensure_sdk_loaded():
return None
from tools.mcp_oauth_provider import HermesProviderMixin
class _HermesOAuthClientProvider(HermesProviderMixin, OAuthClientProvider):
"""SDK provider plus Hermes' token-endpoint fixes (see ``HermesProviderMixin``)."""
_hermes_logger = logger
_HermesOAuthClientProvider.__name__ = "HermesOAuthClientProvider"
_HermesOAuthClientProvider.__qualname__ = "HermesOAuthClientProvider"
HermesOAuthClientProvider = _HermesOAuthClientProvider
return HermesOAuthClientProvider
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def remove_oauth_tokens(
server_name: str,
*,
hermes_home: str | Path | None = None,
) -> None:
"""Delete stored OAuth tokens and client info for a server."""
HermesTokenStorage(server_name, hermes_home=hermes_home).remove()
logger.info("OAuth tokens removed for '%s'", server_name)
# ---------------------------------------------------------------------------
# CIMD -- OAuth Client ID Metadata Documents
#
# Under CIMD the client_id IS an HTTPS URL the authorization server fetches to
# learn our name, logo and permitted redirect URIs, replacing per-install DCR.
# The SDK does the protocol work; Hermes only decides whether a flow is
# eligible and hands the URL to ``OAuthClientProvider``.
# ---------------------------------------------------------------------------
# Published from ``website/static/oauth/client-metadata.json`` by the docs
# deploy. The github.io origin is deliberate: an authorization server MUST NOT
# follow HTTP redirects when fetching the document, and
# hermes-agent.nousresearch.com/docs/* 301s here.
_CIMD_CLIENT_METADATA_URL = (
"https://nousresearch.github.io/hermes-agent/docs/oauth/client-metadata.json"
)
# Loopback callback ports declared in that document. The redirect URI must be
# an exact string match against a listed one, so a CIMD flow cannot use an
# ephemeral port. Below Linux's 32768 ephemeral floor so the kernel never hands
# one to another process. Keep in sync with the document
# (tests/tools/test_mcp_cimd.py enforces it).
_CIMD_PORTS = (27890, 27891, 27892, 27893, 27894)
# Loopback hostnames the document lists alongside each port, so the
# ``oauth.redirect_host: localhost`` WAF workaround still works under CIMD.
_CIMD_REDIRECT_HOSTS = frozenset({"127.0.0.1", "localhost"})
def _is_valid_cimd_url(url: str) -> bool:
"""True when *url* is usable as a CIMD client_id on the installed SDK.
Delegates to the SDK's validator (ImportError = SDK predates CIMD → DCR
only). The SDK checks only https-scheme and non-root-path; userinfo,
fragments and dot segments are rejected here because they fail at the
authorization server mid-browser-flow as an opaque invalid-client page.
"""
try:
from mcp.client.auth.utils import is_valid_client_metadata_url
except ImportError:
return False
if not is_valid_client_metadata_url(url):
return False
try:
parsed = urlparse(url)
has_userinfo = bool(parsed.username or parsed.password) # netloc parse can raise
except ValueError:
return False
if has_userinfo or parsed.fragment:
return False
return not any(seg in {".", ".."} for seg in parsed.path.split("/"))
# Pinned ports this process has committed to, in order taken. A provider is
# built once per server and keeps its port for the process lifetime, so
# assignments are never released. Includes ports restored from a cached
# registration so a sibling server is never handed one already in use.
_assigned_cimd_ports: "list[int]" = []
def _note_assigned_cimd_port(port: int) -> None:
"""Claim *port* for this process when it belongs to the pinned range."""
if port in _CIMD_PORTS and port not in _assigned_cimd_ports:
_assigned_cimd_ports.append(port)
def _pick_cimd_port() -> int | None:
"""Reserve a pinned CIMD callback port, or None when none is usable.
Holding the bound socket until adoption prevents the same steal window as
for ephemeral ports, and makes contention cooperative: another profile or
sibling server finds the bind refused and moves down the range. Once every
pinned port belongs to this process the range wraps rather than falling
back to DCR — a reused port only bites if both servers authorize at the
same moment (reported clearly by the waiter), whereas DCR may be
unsupported by the server entirely.
"""
for port in _CIMD_PORTS:
if port in _assigned_cimd_ports:
continue
if _bind_reserved(port) is not None:
_assigned_cimd_ports.append(port)
return port
return _assigned_cimd_ports[0] if _assigned_cimd_ports else None
def _server_declined_cimd(storage: "HermesTokenStorage | None") -> bool:
"""True when cached metadata shows this server doesn't advertise CIMD.
The SDK decides CIMD vs DCR in its 401 branch — after Hermes must fix
the redirect URI. Cached authorization-server metadata closes the gap for
every server already reached: only a genuinely unknown server pays the
optimistic pin.
"""
if storage is None:
return False
try:
metadata = storage.load_oauth_metadata()
except (AttributeError, TypeError, ValueError):
return False
if metadata is None:
return False
return getattr(metadata, "client_id_metadata_document_supported", None) is not True
def _maybe_use_cimd(
cfg: dict,
storage: "HermesTokenStorage | None" = None,
) -> "tuple[str, int] | None":
"""Return ``(client_id URL, pinned callback port)``, or None to use DCR.
Every early return is a case where the redirect URI Hermes would send is
not one the document declares, where the client identity is already
settled, or where the server is known not to want a document — passing a
metadata URL anyway would make the authorization server reject the flow.
"""
if cfg.get("cimd") is False:
return None
url = cfg.get("client_metadata_url") or _CIMD_CLIENT_METADATA_URL
if not _is_valid_cimd_url(url):
return None
# A pinned client is the user's explicit choice; a secret means a
# confidential client, which the document forbids.
if cfg.get("client_id") or cfg.get("client_secret"):
return None
# The document supplies name and auth method; a caller setting either asks
# for an identity CIMD cannot present (Figma's DCR name allowlist, e.g.).
if cfg.get("client_name") or (cfg.get("token_endpoint_auth_method") or "none") != "none":
return None
# Dashboard/desktop flows redirect to a deployment-specific server URL
# that can never appear in a static document.
from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow
if get_dashboard_oauth_flow() is not None:
return None
if cfg.get("redirect_uri") or cfg.get("redirect_port"):
return None
if (cfg.get("redirect_host") or "127.0.0.1") not in _CIMD_REDIRECT_HOSTS:
return None
# An existing registration is bound to its redirect URI; swapping in a
# CIMD client_id now would invalidate stored tokens.
if _cached_client_info(storage) is not None:
return None
if storage is not None and storage.cimd_rejected():
return None
if _server_declined_cimd(storage):
return None
port = _pick_cimd_port()
if port is None:
return None
return url, port
def cimd_provider_kwargs(cfg: dict) -> dict[str, Any]:
"""``client_metadata_url=`` for ``OAuthClientProvider``, when CIMD applies.
Returned as kwargs so the argument is omitted entirely on a DCR flow: an
SDK too old for CIMD rejects the keyword outright.
"""
url = cfg.get("_cimd_url")
return {"client_metadata_url": url} if url else {}
def token_request_user_agent(cfg: dict) -> str | None:
"""Configured ``oauth.user_agent`` for token-endpoint requests, or None.
Opt-in and per-server; anything but a non-empty string is unset so a
null/empty YAML value never sends a blank header. Applied ONLY to
authorization-code exchange and refresh — never MCP traffic or discovery;
no other headers are configurable (secrets would land in config.yaml).
"""
ua = cfg.get("user_agent")
if isinstance(ua, str) and ua.strip():
return ua.strip()
return None
def _configure_callback_port(
cfg: dict,
storage: "HermesTokenStorage | None" = None,
) -> int:
"""Resolve the callback port into ``cfg['_resolved_port']`` (0 = non-loopback URI).
Precedence: dashboard flow / cached https redirect URI → CIMD pinned port
(also sets ``cfg['_cimd_url']``) → ``oauth.redirect_port`` → cached
registration port → fresh ephemeral port. Only the fresh pick is parked;
fixed ports bind via reuse_address. Also sets the legacy ``_oauth_port``.
"""
global _oauth_port
from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow
dashboard_flow = get_dashboard_oauth_flow()
if dashboard_flow is not None:
cfg["_resolved_port"] = 0
cfg["redirect_uri"] = cfg.get("redirect_uri") or dashboard_flow.redirect_uri
return 0
cached_redirect_uri = _cached_redirect_uri(storage)
if not cfg.get("redirect_uri") and cached_redirect_uri:
cfg["redirect_uri"] = cached_redirect_uri
cfg["_resolved_port"] = 0
return 0
cimd = _maybe_use_cimd(cfg, storage)
if cimd is not None:
cfg["_cimd_url"], port = cimd
else:
port = int(cfg.get("redirect_port", 0)) or _cached_redirect_port(storage) or _reserve_callback_port()
# A cached port may be a pinned CIMD port left by an earlier CIMD
# login; claim it so a sibling's _pick_cimd_port doesn't reuse it.
_note_assigned_cimd_port(port)
cfg["_resolved_port"] = port
_oauth_port = port
return port
def _resolve_redirect_uri(cfg: dict, port: int) -> str:
"""Configured ``redirect_uri`` (proxy, e.g. Tailscale Funnel) or
``http://<redirect_host>:<port>/callback``.
Client metadata and pre-registered client info must both derive the URI
here so they stay identical. ``redirect_host`` (default ``127.0.0.1``)
only changes the hostname: some WAFs reject a literal ``127.0.0.1`` in the
authorize query, and ``localhost`` works around it. The listener still
binds ``127.0.0.1``.
"""
configured = cfg.get("redirect_uri")
if configured:
return configured
host = cfg.get("redirect_host") or "127.0.0.1"
return f"http://{host}:{port}/callback"
# Figma's remote MCP implements DCR as a client_name *allowlist*: "Claude Code"
# and "Codex" register (200); "Hermes Agent"/"Cursor"/… get 403. Register under
# an allowlisted name so the browser flow can start; oauth.client_name overrides.
_FIGMA_DCR_CLIENT_NAME = "Claude Code"
_FIGMA_DEFAULT_SCOPE = "mcp:connect"
def _is_figma_remote_mcp(
server_name: str | None = None,
server_url: str | None = None,
) -> bool:
"""True when this MCP server is Figma's hosted remote endpoint."""
url = (server_url or "").lower()
name = (server_name or "").lower()
from utils import base_url_host_matches, base_url_hostname
if base_url_host_matches(url, "mcp.figma.com") or (
base_url_host_matches(url, "figma.com") and "/mcp" in url
):
return True
# Name-only match only when the URL isn't some other host called figma-*.
return "figma" in name and (not url or "figma" in base_url_hostname(url))
def apply_oauth_provider_defaults(
cfg: dict,
*,
server_name: str = "",
server_url: str | None = None,
) -> dict:
"""Mutate *cfg* with provider-specific OAuth workarounds. Returns *cfg*.
Call before building client metadata / pre-registering. Only fills keys
the user left unset — explicit ``oauth.client_name`` / ``oauth.scope`` win.
"""
if _is_figma_remote_mcp(server_name, server_url):
if not cfg.get("client_name"):
cfg["client_name"] = _FIGMA_DCR_CLIENT_NAME
logger.info(
"MCP OAuth '%s': Figma DCR allowlist — registering as "
"client_name=%r (override via oauth.client_name)",
server_name or server_url,
_FIGMA_DCR_CLIENT_NAME,
)
if not cfg.get("scope"):
cfg["scope"] = _FIGMA_DEFAULT_SCOPE
# Figma advertises token_endpoint_auth_method=none yet returns a
# client_secret and then demands it at the token endpoint; request a
# confidential-client registration so the SDK posts the secret.
if not cfg.get("token_endpoint_auth_method"):
cfg["token_endpoint_auth_method"] = "client_secret_post"
return cfg
def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata":
"""Build OAuthClientMetadata; requires ``_configure_callback_port`` first."""
port = cfg.get("_resolved_port")
if port is None:
raise ValueError(
"_configure_callback_port() must be called before _build_client_metadata()"
)
if OAuthClientMetadata is None:
_ensure_sdk_loaded()
# Public client by default; confidential only with a known secret or a
# provider (e.g. Figma) that needs confidential-style token posts.
auth_method = cfg.get("token_endpoint_auth_method") or (
"client_secret_post" if cfg.get("client_secret") else "none"
)
metadata_kwargs: dict[str, Any] = {
"client_name": cfg.get("client_name", "Hermes Agent"),
"redirect_uris": [AnyUrl(_resolve_redirect_uri(cfg, port))],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": auth_method,
# SEP-837: clients MUST declare application_type so OIDC-strict servers
# accept loopback redirect_uris; Hermes is a CLI/desktop app → "native".
# Overridable for a hosted dashboard fronting a real https redirect.
"application_type": cfg.get("application_type", "native"),
}
if cfg.get("scope"):
metadata_kwargs["scope"] = cfg["scope"]
try:
return OAuthClientMetadata.model_validate(metadata_kwargs)
except Exception:
# mcp 1.x metadata models predate SEP-837 and reject the unknown field.
metadata_kwargs.pop("application_type", None)
return OAuthClientMetadata.model_validate(metadata_kwargs)
def _invalidate_tokens_on_client_change(
storage: "HermesTokenStorage",
new_client_id: str,
new_client_secret: str | None,
) -> None:
"""Drop cached tokens when the configured OAuth client identity changes.
Tokens are minted for a specific ``client_id``; after editing
``oauth.client_id`` / ``oauth.client_secret`` (or switching from DCR to a
pre-registered client) the old tokens fail refresh with ``invalid_client``.
Pre-registered clients are exempt from the auto-poison path, so without
this check stale tokens wedge every request until a manual wipe. Compares
on-disk ``client.json`` against the incoming identity BEFORE it is
overwritten; a matching identity is a no-op.
"""
existing = _read_json(storage._client_info_path())
if not isinstance(existing, dict):
return
old_client_id = existing.get("client_id")
if not old_client_id:
return
old_client_secret = existing.get("client_secret") or None
if old_client_id == new_client_id and old_client_secret == (new_client_secret or None):
return
removed = False
for path in (storage._tokens_path(), storage._meta_path()):
try:
if path.exists():
path.unlink()
removed = True
except OSError as exc: # non-fatal — stale tokens fail later anyway
logger.warning(
"MCP OAuth '%s': could not remove stale %s after client "
"change: %s", storage._server_name, path.name, exc,
)
if removed:
logger.warning(
"MCP OAuth '%s': configured OAuth client changed (client_id %r "
"-> %r); discarded tokens minted under the previous client. "
"Re-authorize with: hermes mcp login %s",
storage._server_name, old_client_id, new_client_id,
storage._server_name,
)
def _maybe_preregister_client(
storage: "HermesTokenStorage",
cfg: dict,
client_metadata: "OAuthClientMetadata",
) -> None:
"""If cfg has a pre-registered client_id, persist it to storage."""
client_id = cfg.get("client_id")
if not client_id:
return
if OAuthClientInformationFull is None:
_ensure_sdk_loaded()
_invalidate_tokens_on_client_change(storage, client_id, cfg.get("client_secret"))
info_dict: dict[str, Any] = {
"client_id": client_id,
"redirect_uris": [_resolve_redirect_uri(cfg, cfg["_resolved_port"])],
"grant_types": client_metadata.grant_types,
"response_types": client_metadata.response_types,
"token_endpoint_auth_method": client_metadata.token_endpoint_auth_method,
}
for key in ("client_secret", "client_name", "scope"):
if cfg.get(key):
info_dict[key] = cfg[key]
client_info = OAuthClientInformationFull.model_validate(info_dict)
_write_json(storage._client_info_path(), client_info.model_dump(mode="json", exclude_none=True))
logger.debug("Pre-registered client_id=%s for '%s'", client_id, storage._server_name)
def humanize_oauth_registration_error(
server_name: str,
exc: BaseException | str,
*,
server_url: str | None = None,
) -> str | None:
"""Turn a Dynamic Client Registration 403/Forbidden into a useful next step.
Returns None for anything else so the caller keeps the original text.
Figma gates DCR on exact ``client_name``; Hermes auto-sets ``Claude Code``,
so this fires when the user overrode it or an older Hermes is running.
"""
msg = str(exc)
lowered = msg.lower()
if "403" not in msg and "forbidden" not in lowered:
return None
looks_like_registration = (
"regist" in lowered
or "client registration" in lowered
or "dcr" in lowered
or "dynamic client" in lowered
or lowered.strip() in {"forbidden", "403 forbidden", "http 403: forbidden"}
or ("403" in msg and "forbidden" in lowered)
)
if not looks_like_registration:
return None
if _is_figma_remote_mcp(server_name, server_url):
return (
f"'{server_name}' is Figma's remote MCP — DCR is allowlisted by "
f"exact client_name (\"{_FIGMA_DCR_CLIENT_NAME}\" and \"Codex\" "
"work; most other names 403). Hermes defaults to "
f"client_name: {_FIGMA_DCR_CLIENT_NAME!r} automatically. If you "
"set oauth.client_name yourself, change it to one of those, or "
"clear it and re-run:\n"
f" hermes mcp login {server_name}"
)
return (
f"'{server_name}' only allows pre-approved OAuth clients — it rejected "
"client registration (403), so no browser flow can start. Options: "
"set oauth.client_name to a name the provider allowlists, add a "
"pre-registered client (oauth: {client_id: ..., client_secret: ...}), "
"or use the provider's stdio / API-key / local server instead."
)
def build_oauth_auth(
server_name: str,
server_url: str,
oauth_config: dict | None = None,
) -> "OAuthClientProvider | None":
"""Build an ``httpx.Auth``-compatible OAuth handler for an MCP server.
Legacy public API; new code should use
:func:`tools.mcp_oauth_manager.get_manager` so OAuth state is shared
across config-time, runtime, and reconnect paths. Returns None if the MCP
SDK lacks OAuth support.
"""
if not _OAUTH_AVAILABLE or (
OAuthClientProvider is None and not _ensure_sdk_loaded()
):
logger.warning(
"MCP OAuth requested for '%s' but SDK auth types are not available. "
"Install with: pip install 'mcp>=1.26.0'",
server_name,
)
return None
from tools.mcp_oauth_provider import build_provider_kwargs, prepare_oauth_config
cfg, storage = prepare_oauth_config(server_name, server_url, oauth_config)
if not _is_interactive() and not storage.has_cached_tokens():
raise OAuthNonInteractiveError(
"MCP OAuth for "
f"'{server_name}': non-interactive environment and no cached tokens "
"found. The OAuth flow requires browser authorization. Run "
f"`hermes mcp login {server_name}` interactively first to complete "
"initial authorization, then cached tokens will be reused."
)
kwargs = build_provider_kwargs(cfg, storage, ssh_proxy_hint=True)
provider_class = _get_hermes_oauth_provider_class()
if provider_class is None:
logger.warning(
"MCP OAuth requested for '%s' but the provider class is unavailable",
server_name,
)
return None
return provider_class(server_url=server_url, **kwargs)