Files
hermes-agent/tools/mcp_oauth.py

1118 lines
54 KiB
Python

#!/usr/bin/env python3
"""MCP OAuth 2.1 client support: browser authorization-code flow with PKCE.
The 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).
The client_id is Hermes' published Client ID Metadata Document URL (CIMD) when the server
advertises support, else RFC 7591 dynamic client registration (DCR).
``mcp_servers.<name>.oauth`` keys (all optional): ``client_id`` (skip DCR), ``client_secret``
(confidential clients), ``scope``, ``redirect_port`` (0 = auto), ``redirect_uri`` (proxy
callback), ``redirect_host`` (loopback hostname, WAF-safe), ``client_name`` (default "Hermes
Agent"), ``client_metadata_url`` (self-hosted CIMD), ``cimd: false`` (force DCR),
``user_agent``, ``timeout``.
"""
import asyncio
import contextlib
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 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
from tools.mcp_dashboard_oauth import contextvar_set as _contextvar_set, get_dashboard_oauth_flow
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 and _SDK_CLASSES caches them so a test that patches a name and
# restores it to None afterwards doesn't strand the module in a broken state.
_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
_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
currently ``None`` are (re)bound, so test patches stay intact."""
global _SDK_LOAD_FAILED, _OAUTH_AVAILABLE
if _SDK_LOAD_FAILED:
return False
if not _SDK_CLASSES:
try:
from mcp.client import auth as _client_auth
from mcp.shared import auth as _shared_auth
_SDK_CLASSES["OAuthClientProvider"] = _client_auth.OAuthClientProvider
for _name in ("OAuthClientInformationFull", "OAuthClientMetadata", "OAuthMetadata", "OAuthToken"):
_SDK_CLASSES[_name] = getattr(_shared_auth, _name)
except (ImportError, AttributeError):
_SDK_CLASSES.clear()
_SDK_LOAD_FAILED = True
_OAUTH_AVAILABLE = False
logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")
return False
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("_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("_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
return Path(hermes_home if hermes_home is not None else get_hermes_home()) / "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"
# -- Callback-port reservation ---------------------------------------------
# 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 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
with contextlib.suppress(OSError):
_reserved_sockets.pop(stale_port).close()
_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
# -- Cached registration lookups ---------------------------------------------
def _cached_client_info(storage: "HermesTokenStorage | None") -> dict | None:
"""The on-disk client registration for *storage*, or None."""
try:
return _read_json(storage._client_info_path()) if storage is not None else None
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 []:
with contextlib.suppress(TypeError, ValueError):
yield str(uri), urlparse(str(uri))
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; a new
random port under the stored ``client_id`` gets ``redirect_uri does not match any
registered URIs``."""
for _uri, parsed in _cached_redirect_uris(storage):
is_loopback_callback = parsed.scheme == "http" and parsed.path == "/callback" and parsed.hostname in {"127.0.0.1", "localhost"}
if is_loopback_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
# -- Interactivity -----------------------------------------------------------
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 ``hermes mcp login`` next step is shared."""
if not _is_interactive():
raise OAuthNonInteractiveError(
f"{lead} Run `hermes mcp login <server>` interactively to (re)authorize, then restart or reload the gateway."
)
def force_interactive_oauth():
"""Treat the current context as interactive despite no TTY (GUI-driven auth): the user IS
present, just not on stdin. Crosses the MCP event-loop thread 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 a background-discovery thread's suppression 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" or (hasattr(os, "uname") and os.uname().sysname == "Darwin"):
return True
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
# -- JSON file I/O ------------------------------------------------------------
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). The per-process
random tmp suffix avoids collisions between concurrent writers and stale crash leftovers."""
path.parent.mkdir(parents=True, exist_ok=True)
secure_parent_dir(path)
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:
with contextlib.suppress(OSError):
tmp.unlink(missing_ok=True)
raise
def _model_json(model: Any) -> dict:
"""The on-disk JSON shape of an SDK pydantic model."""
return model.model_dump(mode="json", exclude_none=True)
# -- 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 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:
with contextlib.suppress(OSError, TypeError, ValueError):
implied_expiry = self._tokens_path().stat().st_mtime + int(data["expires_in"])
data["expires_in"] = int(max(implied_expiry - time.time(), 0))
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 = _model_json(tokens)
# 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.
if payload.get("expires_in") is not None:
with contextlib.suppress(TypeError, ValueError): # mock tokens / odd shapes: skip, don't fail persistence
payload["expires_at"] = time.time() + int(payload["expires_in"])
_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(), _model_json(info))
return info
async def set_client_info(self, client_info: "OAuthClientInformationFull") -> None:
data = _model_json(client_info)
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(), _model_json(metadata))
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():
with contextlib.suppress(OSError):
snap[p.name] = p.read_bytes()
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():
try:
fd = os.open(str(token_dir / fname), 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`` 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
issuer: mcp 2.0 rejects a response that omits it when the server advertised
``authorization_response_iss_parameter_supported``, so keep it."""
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.update(auth_code=parsed["code"], state=parsed["state"], error=parsed["error"], iss=parsed["iss"])
def _make_callback_handler() -> tuple[type, dict]:
"""Fresh ``(HandlerClass, result_dict)`` per flow 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)
if parsed["code"]:
body = "<h2>Authorization Successful</h2><p>You can close this tab and return to Hermes.</p>"
else:
body = f"<h2>Authorization Failed</h2><p>Error: {parsed['error'] or 'unknown'}</p>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(f"<html><body>{body}</body></html>".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
try:
parsed = _parse_redirect_query(query.removeprefix("?"))
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 --------------
# Remote-session guidance printed under the authorization URL. With a proxy callback (e.g.
# Tailscale Funnel) the redirect is forwarded to this machine's listener, so no tunnel/paste
# is needed. On the loopback default the redirect reaches the *remote* machine's listener,
# not the browser's, so the user must paste the redirect URL back or SSH-forward the port.
_SSH_HINT_PROXY = (
" Remote session detected. After you authorize, the provider redirects to\n"
" {redirect_uri}\n"
" which forwards to the callback listener on this machine — no SSH tunnel needed.\n"
)
_SSH_HINT_LOOPBACK = (
" Remote session detected. After you authorize, the provider redirects to\n"
" http://127.0.0.1:{port}/callback\n"
" which only the listener on THIS machine can receive. Two options:\n"
"\n"
" 1. Easiest — when your browser shows a connection error after\n"
" authorizing, copy the full URL from the address bar and paste\n"
" it at the prompt below. The pasted ``code=...&state=...`` is\n"
" enough to complete the flow.\n"
"\n"
" 2. Or forward the port first in a separate terminal:\n"
" ssh -N -L {port}:127.0.0.1:{port} <user>@<this-host>\n"
" then open the URL above and let it redirect normally.\n"
"\n"
" See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n"
)
def _announce_authorization_url(authorization_url: str, port: int, redirect_uri: str | None) -> None:
"""Print the URL (always, as the fallback) and open the browser when possible."""
print(f"\n MCP OAuth: authorization required.\n Open this URL in your browser:\n\n {authorization_url}\n", file=sys.stderr)
if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
if redirect_uri:
print(_SSH_HINT_PROXY.format(redirect_uri=redirect_uri), file=sys.stderr)
elif port:
print(_SSH_HINT_LOOPBACK.format(port=port), file=sys.stderr)
if not _can_open_browser():
note = "Headless environment detected — open the URL manually."
else:
try:
opened = webbrowser.open(authorization_url)
except Exception:
opened = False
note = "Browser opened automatically." if opened else "Could not open browser — please open the URL manually."
print(f" ({note})\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 (None for loopback) and only tailors the remote-session hint."""
async def _redirect_handler(authorization_url: str) -> None:
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)."
)
_announce_authorization_url(authorization_url, port, redirect_uri)
return _redirect_handler
def _start_callback_server(port: int, handler_cls: type) -> HTTPServer:
"""Bind the callback listener on *port*, adopting a parked reserved 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, server.server_address = reserved, 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
async def _poll_callback_result(server: HTTPServer, result: dict, timeout: float) -> None:
"""Poll *result* until filled or *timeout* elapses; always closes the listener."""
elapsed = 0.0
try:
while elapsed < timeout and not _result_taken(result):
await asyncio.sleep(0.5)
elapsed += 0.5
finally:
server.server_close()
def _callback_outcome(result: dict, cimd_url: str | None):
"""Turn a filled/empty result dict into the SDK's callback value, or raise."""
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 the browser showed an invalid-client error instead of an approval prompt, the authorization "
f"server rejected 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."
) if cimd_url else ""
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"))
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():
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()
await _poll_callback_result(server, result, timeout)
return _callback_outcome(result, cimd_url)
return _wait
# -- OAuth provider class (legacy build_oauth_auth path) ---------------------
HermesOAuthClientProvider: Any = None
def _get_hermes_oauth_provider_class() -> type | None:
"""Build (once) and cache ``HermesOAuthClientProvider``; None without the SDK."""
global HermesOAuthClientProvider
if HermesOAuthClientProvider is None and _ensure_sdk_loaded():
from tools.mcp_oauth_provider import HermesProviderMixin
HermesOAuthClientProvider = type("HermesOAuthClientProvider", (HermesProviderMixin, OAuthClientProvider), {
"__doc__": "SDK provider plus Hermes' token-endpoint fixes (see ``HermesProviderMixin``).",
"__module__": __name__,
"_hermes_logger": logger,
})
return HermesOAuthClientProvider
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 (exact string match, 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
return not (has_userinfo or parsed.fragment or 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 _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 not in _assigned_cimd_ports and _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."""
try:
metadata = storage.load_oauth_metadata() if storage is not None else None
except (AttributeError, TypeError, ValueError):
return False
return metadata is not None and 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
ineligibility case is one 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."""
url = cfg.get("client_metadata_url") or _CIMD_CLIENT_METADATA_URL
ineligible = (
cfg.get("cimd") is False
or not _is_valid_cimd_url(url)
# A pinned client is the user's explicit choice; a secret means a confidential
# client, which the document forbids.
or cfg.get("client_id") or cfg.get("client_secret")
# The document supplies name and auth method; a caller setting either asks for an
# identity CIMD cannot present (Figma's DCR name allowlist).
or cfg.get("client_name") or (cfg.get("token_endpoint_auth_method") or "none") != "none"
# Dashboard/desktop flows redirect to a deployment-specific server URL that can
# never appear in a static document.
or get_dashboard_oauth_flow() is not None
or cfg.get("redirect_uri") or cfg.get("redirect_port")
or (cfg.get("redirect_host") or "127.0.0.1") not in _CIMD_REDIRECT_HOSTS
# An existing registration is bound to its redirect URI; swapping in a CIMD
# client_id now would invalidate stored tokens.
or _cached_client_info(storage) is not None
or (storage is not None and storage.cimd_rejected())
or _server_declined_cimd(storage)
)
if ineligible:
return None
port = _pick_cimd_port()
return None if port is None else (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")
return ua.strip() if isinstance(ua, str) and ua.strip() else 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
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 = None if cfg.get("redirect_uri") else _cached_redirect_uri(storage)
if 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.
if port in _CIMD_PORTS and port not in _assigned_cimd_ports:
_assigned_cimd_ports.append(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``."""
return cfg.get("redirect_uri") or f"http://{cfg.get('redirect_host') or '127.0.0.1'}:{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.
cfg["token_endpoint_auth_method"] = cfg.get("token_endpoint_auth_method") or "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()")
metadata_cls = _sdk_class("OAuthClientMetadata")
# 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 metadata_cls.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 metadata_cls.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())
old_client_id = existing.get("client_id") if isinstance(existing, dict) else None
if not old_client_id:
return
if old_client_id == new_client_id and (existing.get("client_secret") or None) == (new_client_secret or None):
return
removed = False
for path in (storage._tokens_path(), storage._meta_path()):
if not path.exists():
continue
try:
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
info_cls = _sdk_class("OAuthClientInformationFull")
_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,
**{key: cfg[key] for key in ("client_secret", "client_name", "scope") if cfg.get(key)},
}
_write_json(storage._client_info_path(), _model_json(info_cls.model_validate(info_dict)))
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; 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 = (
any(k in lowered for k in ("regist", "dcr", "dynamic client"))
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 exact client_name "
f"(\"{_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, "
f"change it to one of those, or clear it and re-run:\n 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(
f"MCP OAuth for '{server_name}': non-interactive environment and no cached tokens found. The OAuth flow "
f"requires browser authorization. Run `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)