Twenty-five call sites told users to run `python -c "from pm import sync_venv; sync_venv(['x'], explicit=True)"` because `hermes pm install` only took package names. Add `--extra` (repeatable; syncs the venv with the named extras and nothing else) and pm.install_hint(extra), the single builder every site now uses, so the advice stays correct when the command changes. A cold PM runtime under allow_lazy_installs:false now reports the extra the caller wanted and the command that provisions both, instead of a bare "pm-runtime: not installed".
1267 lines
66 KiB
Python
1267 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
"""MCP OAuth 2.1 client support: browser authorization-code flow with PKCE.
|
|
|
|
The SDK's ``OAuthClientProvider`` does discovery, client identification, PKCE, exchange and
|
|
refresh; this module supplies ``HermesTokenStorage`` (on-disk persistence), the localhost callback
|
|
listener and ``build_oauth_auth()`` (legacy entry point). client_id is Hermes' Client ID Metadata
|
|
Document URL (CIMD) when the server supports it, else RFC 7591 DCR. ``mcp_servers.<name>.oauth`` keys
|
|
(all optional): client_id, client_secret, scope, redirect_port, redirect_uri (proxy callback),
|
|
redirect_host, client_name, client_metadata_url, cimd, user_agent, timeout."""
|
|
|
|
from pm import install_hint
|
|
import asyncio
|
|
import contextlib
|
|
import contextvars
|
|
import errno
|
|
import html
|
|
import importlib.util as _importlib_util
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import socket
|
|
import stat
|
|
import sys
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from functools import partialmethod
|
|
|
|
# Cross-process advisory file locking for the refresh fence. Mirrors
|
|
# cron/jobs.py: fcntl is Unix-only, msvcrt is the Windows fallback.
|
|
try:
|
|
import fcntl
|
|
except ImportError: # pragma: no cover - non-Unix
|
|
fcntl = None
|
|
try:
|
|
import msvcrt
|
|
except ImportError: # pragma: no cover - non-Windows
|
|
msvcrt = None
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from hermes_constants import secure_parent_dir
|
|
from utils import atomic_json_write
|
|
from tools.mcp_dashboard_oauth import contextvar_set as _contextvar_set, get_dashboard_oauth_flow
|
|
|
|
if TYPE_CHECKING: # annotations only; the SDK is imported lazily at runtime
|
|
from mcp.client.auth import OAuthClientProvider
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, OAuthToken
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The refresh fence's critical section spans the token-endpoint POST, so it must
|
|
# outlast a slow network round trip. Bounded anyway -- a wedged peer must not
|
|
# strand us forever -- but generous enough that a healthy refresh never trips it.
|
|
_REFRESH_FENCE_TIMEOUT_SECONDS = 60.0
|
|
|
|
class RefreshFenceTimeout(RuntimeError):
|
|
"""The refresh fence could not be acquired within its bound.
|
|
|
|
Raised so the caller FAILS CLOSED. Submitting a refresh token we are not
|
|
certain we own is the whole defect class this fence exists to close: on a
|
|
provider with single-use refresh tokens it burns the credential and logs
|
|
the user out of a working session. Aborting this one refresh attempt is
|
|
strictly cheaper -- the next request retries, and by then the peer that
|
|
held the fence has published its replacement.
|
|
"""
|
|
|
|
|
|
# POSIX flock: EWOULDBLOCK/EAGAIN, EACCES on some NFS; msvcrt.locking: EACCES/EDEADLK.
|
|
# Same set as cron.scheduler._is_lock_contention_errno (not imported: that module is heavy).
|
|
_FENCE_CONTENTION_ERRNOS = frozenset({errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, errno.EDEADLK})
|
|
|
|
|
|
def _refresh_lock_path(path: "Path") -> "Path":
|
|
return path.with_suffix(path.suffix + ".refresh.lock")
|
|
|
|
|
|
async def acquire_refresh_fence(path: "Path", *, timeout: float = _REFRESH_FENCE_TIMEOUT_SECONDS) -> int:
|
|
"""Take the fence that owns one refresh generation across read -> POST -> persist.
|
|
|
|
Token files are written atomically (``_write_json``), so a reader never
|
|
sees a torn file; the only cross-process hazard is the read-modify-write
|
|
of a single-use refresh token. The damaging interleaving is:
|
|
|
|
A: get_tokens() -> R1
|
|
B: get_tokens() -> R1
|
|
A: POST R1 -> 200, receives R2
|
|
B: POST R1 -> 400, credential already burned
|
|
B: clear_tokens() -> user is logged out of a live session
|
|
|
|
No lock scoped to one file read or write can prevent it: the fence must
|
|
be held across the POST. It lives in a ``.refresh.lock`` sibling of the
|
|
token file so the holder can still read/write the tokens normally.
|
|
|
|
Entered from the SDK's coroutine-driven auth flow, so the wait is an
|
|
``asyncio.sleep`` poll on a non-blocking lock: a peer's slow network
|
|
round trip must not freeze every other task on this event loop. No
|
|
in-process lock layer is needed: an advisory lock on a fresh descriptor
|
|
already excludes sibling tasks and threads of the same process.
|
|
|
|
Acquisition failure RAISES. Degrading to "proceed unlocked" would
|
|
reintroduce the exact race. Returns the locked descriptor; the caller
|
|
hands it back to ``release_refresh_fence`` from its own exit path (the
|
|
SDK drives the refresh as a generator, so no single ``with`` block can
|
|
span the critical section).
|
|
"""
|
|
lock_path = _refresh_lock_path(path)
|
|
try:
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
|
|
mkdir_under_hermes_home(lock_path.parent)
|
|
secure_parent_dir(lock_path)
|
|
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
except OSError as exc:
|
|
# No lock file means no ownership proof. Fail closed: see the
|
|
# class docstring for why proceeding is worse than aborting.
|
|
raise RefreshFenceTimeout(
|
|
f"refresh fence unavailable ({lock_path.name}): {exc}"
|
|
) from exc
|
|
|
|
deadline = time.monotonic() + timeout
|
|
try:
|
|
while True:
|
|
try:
|
|
if fcntl is not None:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
elif msvcrt is not None:
|
|
getattr(msvcrt, "locking")(fd, getattr(msvcrt, "LK_NBLCK"), 1)
|
|
else: # pragma: no cover - no advisory locking primitive
|
|
raise RefreshFenceTimeout(
|
|
"refresh fence unsupported: no flock/msvcrt on this platform"
|
|
)
|
|
return fd
|
|
except OSError as exc:
|
|
if exc.errno not in _FENCE_CONTENTION_ERRNOS:
|
|
# Not "a peer holds it" but "this filesystem cannot lock"
|
|
# (e.g. some network mounts). Still fail closed, but say so
|
|
# now instead of spinning to the deadline and blaming a peer.
|
|
raise RefreshFenceTimeout(
|
|
f"refresh fence unavailable on this filesystem: {exc}"
|
|
) from exc
|
|
if time.monotonic() >= deadline:
|
|
raise RefreshFenceTimeout(
|
|
f"refresh fence held by a peer for {timeout:.0f}s ({lock_path.name})"
|
|
) from None
|
|
await asyncio.sleep(0.05)
|
|
except BaseException:
|
|
os.close(fd)
|
|
raise
|
|
|
|
|
|
def release_refresh_fence(fd: int) -> None:
|
|
"""Unlock and close a descriptor returned by ``acquire_refresh_fence``. Never raises."""
|
|
try:
|
|
if fcntl is not None:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
elif msvcrt is not None:
|
|
getattr(msvcrt, "locking")(fd, getattr(msvcrt, "LK_UNLCK"), 1)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
os.close(fd)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lazy imports -- MCP SDK with OAuth support is optional
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# SDK availability is detected WITHOUT importing mcp (~170 ms); classes bind lazily via _sdk_class().
|
|
_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")
|
|
|
|
_SDK_CLASS_NAMES = ("OAuthClientProvider", "OAuthClientInformationFull", "OAuthClientMetadata", "OAuthMetadata", "OAuthToken")
|
|
_SDK_CLASSES: dict[str, Any] = {}
|
|
|
|
|
|
def _sdk_class(name: str) -> Any:
|
|
"""SDK OAuth class *name*, importing the SDK on first call; None when unavailable (a broken SDK is probed once)."""
|
|
global _OAUTH_AVAILABLE
|
|
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 _SDK_CLASS_NAMES[1:]:
|
|
_SDK_CLASSES[_name] = getattr(_shared_auth, _name)
|
|
except (ImportError, AttributeError):
|
|
_SDK_CLASSES.update(dict.fromkeys(_SDK_CLASS_NAMES))
|
|
_OAUTH_AVAILABLE = False
|
|
logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")
|
|
return _SDK_CLASSES.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 of the most recent callback-port resolution. Legacy global; 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
|
|
# discovery sets them on its own thread while connect+OAuth runs on the `mcp-event-loop` thread
|
|
# via run_coroutine_threadsafe, which copies the calling context into the coroutine. `forced`
|
|
# pushes _is_interactive() past the TTY check for GUI-driven flows (dashboard/desktop REST; the
|
|
# paste fallback degrades harmlessly to EOF). Suppression wins — background discovery must never
|
|
# start a browser flow.
|
|
_oauth_interactive_enabled = contextvars.ContextVar("_oauth_interactive_enabled", default=True)
|
|
_oauth_interactive_forced = contextvars.ContextVar("_oauth_interactive_forced", default=False)
|
|
|
|
# Paste-prompt tokens that exit OAuth without auth; the waiter maps the sentinel to
|
|
# OAuthNonInteractiveError("user_skipped") so MCP setup continues without this server.
|
|
_SKIP_TOKENS = frozenset({"skip", "cancel", "s", "n", "no", "q", "quit"})
|
|
_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 keyed by port, held from selection
|
|
# until the waiter adopts them (closes the select→bind TOCTOU window). Bounded so reconnect loops cannot leak fds.
|
|
# Holding the socket from port-selection time until _wait_for_callback adopts it closes the TOCTOU window
|
|
# where another process could grab the port between _find_free_port() closing its probe socket and
|
|
# HTTPServer binding minutes later (#22161).
|
|
_reserved_sockets: "dict[int, socket.socket]" = {}
|
|
_MAX_RESERVED_SOCKETS = 8
|
|
|
|
|
|
def _bind_reserved(port: int) -> int | None:
|
|
"""Bind ``127.0.0.1:port`` (0 = ephemeral) and park it until the waiter adopts it; None if taken.
|
|
The cap evicts ephemeral parks only: losing a pinned CIMD port (the only ones the published
|
|
document declares) mid-flow would reopen the race."""
|
|
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]
|
|
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[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."""
|
|
try:
|
|
info = _read_json(storage._client_info_path()) if storage is not None else None
|
|
except (AttributeError, TypeError, ValueError):
|
|
return None
|
|
# A non-object payload is not a registration get_client_info could ever load, so callers
|
|
# (CIMD eligibility, redirect reuse) must see "no cached registration", not "exists".
|
|
return info if isinstance(info, dict) else None
|
|
|
|
|
|
def _cached_redirect(storage: "HermesTokenStorage | None") -> "tuple[str | None, int | None]":
|
|
"""``(https proxy URI, loopback callback port)`` from the cached client registration (None when
|
|
absent): a DCR ``client_id`` is bound to its registered redirect URI, so a new random port under
|
|
it gets ``redirect_uri does not match any registered URIs``."""
|
|
uri = port = None
|
|
info = _cached_client_info(storage)
|
|
uris = info.get("redirect_uris") if isinstance(info, dict) else None
|
|
for raw in uris if isinstance(uris, (list, tuple)) else ():
|
|
try:
|
|
parsed = urlparse(str(raw))
|
|
# .port/.hostname are lazy properties that can raise ValueError on access —
|
|
# resolve them inside the try so a malformed entry is skipped, not fatal.
|
|
parsed_hostname, parsed_port = parsed.hostname, parsed.port
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if uri is None and parsed.scheme == "https" and parsed.netloc:
|
|
uri = str(raw)
|
|
is_loopback_callback = parsed.scheme == "http" and parsed.path == "/callback" and parsed_hostname in {"127.0.0.1", "localhost"}
|
|
if port is None and is_loopback_callback and parsed_port is not None:
|
|
port = int(parsed_port)
|
|
return uri, port
|
|
|
|
|
|
def _stdin_is_console() -> bool:
|
|
"""A human can type on stdin. ``isatty()`` alone is wrong on Windows: the CRT reports True for
|
|
a DEVNULL / detached / CREATE_NO_WINDOW stdin (the gateway's), so a background process looked
|
|
interactive and launched browser OAuth flows nobody could finish. Confirm with the console API
|
|
there: ``GetConsoleMode`` fails on anything that is not a real console handle."""
|
|
try:
|
|
if not sys.stdin.isatty():
|
|
return False
|
|
except (AttributeError, ValueError):
|
|
return False
|
|
if os.name != "nt":
|
|
return True
|
|
try:
|
|
import ctypes
|
|
import msvcrt
|
|
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
|
|
mode = ctypes.c_ulong()
|
|
return bool(ctypes.windll.kernel32.GetConsoleMode(ctypes.c_void_p(handle), ctypes.byref(mode)))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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
|
|
return _stdin_is_console()
|
|
|
|
|
|
def _raise_if_non_interactive(lead: str) -> None:
|
|
"""Raise ``OAuthNonInteractiveError`` unless interactive; *lead* is the boundary-specific first sentence.
|
|
|
|
``lead`` is the boundary-specific first sentence; this helper appends the shared, actionable ``hermes
|
|
mcp login`` next-step so the guidance wording lives in one place across every non-interactive OAuth
|
|
boundary (#57836).
|
|
"""
|
|
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 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``.
|
|
|
|
Opens the browser + localhost callback flow that the TTY heuristic would otherwise refuse. Same
|
|
ContextVar propagation story as suppress_interactive_oauth() (#35927).
|
|
"""
|
|
return _contextvar_set(_oauth_interactive_forced, True)
|
|
|
|
|
|
def suppress_interactive_oauth():
|
|
"""Disable stdin-based OAuth prompts for the current context; ContextVar-based so a
|
|
background-discovery thread's suppression reaches the coroutine on the MCP event-loop thread.
|
|
|
|
Uses a ContextVar so the suppression propagates from a background-discovery thread onto the coroutine
|
|
scheduled (via run_coroutine_threadsafe) on the dedicated MCP event-loop thread — where the OAuth
|
|
callback actually runs (#35927). A threading.local would not cross that thread boundary.
|
|
"""
|
|
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"))
|
|
|
|
|
|
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-sig"))
|
|
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:
|
|
"""OAuth tokens/client info at 0600 from creation, parent tightened to 0700 (``secure_parent_dir``
|
|
refuses ``/``, top-level dirs and the install tree — #25821, #93050)."""
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
|
|
mkdir_under_hermes_home(path.parent)
|
|
secure_parent_dir(path)
|
|
atomic_json_write(path, data, mode=0o600, default=str)
|
|
|
|
|
|
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)
|
|
|
|
|
|
class HermesTokenStorage:
|
|
"""Persist OAuth state as ``HERMES_HOME/mcp-tokens/<server_name>`` + ``.json`` (tokens),
|
|
``.client.json`` (client info), ``.meta.json`` (server metadata), ``.cimd-off`` (CIMD refused)."""
|
|
|
|
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
|
|
# Issuer binding: ``loaded_issuer`` is what the token file on disk recorded (the authorization
|
|
# server that granted the stored refresh token); ``_bound_issuer`` is stamped onto the next
|
|
# ``set_tokens`` write. See ``tools.mcp_oauth_provider.enforce_refresh_token_issuer``.
|
|
self.loaded_issuer: str | None = None
|
|
self._bound_issuer: str | None = None
|
|
|
|
def _path(self, suffix: str) -> Path:
|
|
return _get_token_dir(self._hermes_home) / f"{self._server_name}{suffix}"
|
|
|
|
_tokens_path = partialmethod(_path, ".json")
|
|
_client_info_path = partialmethod(_path, ".client.json")
|
|
_meta_path = partialmethod(_path, ".meta.json")
|
|
_cimd_rejected_path = partialmethod(_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)
|
|
if data is not None and not isinstance(data, dict):
|
|
# A non-object payload (list/str/number) has no fields for the fixups' ``.get``/``.pop``
|
|
# and cannot validate: treat it like any other corrupt file instead of crashing auth init.
|
|
logger.warning("Corrupt %s at %s -- ignoring: expected a JSON object, got %s",
|
|
label, path, type(data).__name__)
|
|
return None
|
|
cls = _sdk_class(sdk_name) if data is not None else None
|
|
if (cls is not None and sdk_name == "OAuthMetadata" and isinstance(data, dict)
|
|
and data.get("device_authorization_endpoint")):
|
|
from tools.mcp_oauth_device import DeviceOAuthMetadata
|
|
cls = DeviceOAuthMetadata
|
|
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:
|
|
# A pydantic ValidationError's str() echoes the raw input (the token material); log
|
|
# only which fields failed.
|
|
detail = exc
|
|
if hasattr(exc, "errors"): # pydantic ValidationError
|
|
detail = "validation failed for " + ", ".join(
|
|
".".join(map(str, e.get("loc", ()))) for e in exc.errors(include_input=False))
|
|
logger.warning("Corrupt %s at %s -- ignoring: %s", label, path, detail)
|
|
return None
|
|
|
|
def _rebase_expires_in(self, data: dict) -> None:
|
|
"""Rewrite ``expires_in`` to seconds remaining from the stored absolute ``expires_at`` (not an
|
|
SDK field, so stripped): 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, 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))
|
|
|
|
def _fixup_loaded_tokens(self, data: dict) -> None:
|
|
# ``hermes_issuer`` is Hermes bookkeeping, not an SDK OAuthToken field: pop before validation.
|
|
self.loaded_issuer = data.pop("hermes_issuer", None)
|
|
self._rebase_expires_in(data)
|
|
|
|
async def get_tokens(self) -> "OAuthToken | None":
|
|
self.loaded_issuer = None
|
|
return self._load_model(self._tokens_path(), "OAuthToken", "tokens", self._fixup_loaded_tokens)
|
|
|
|
async def set_tokens(self, tokens: "OAuthToken") -> None:
|
|
payload = _model_json(tokens)
|
|
# Absolute ``expires_at``: see _rebase_expires_in.
|
|
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"])
|
|
if self._bound_issuer: # which authorization server granted these tokens (never sent on the wire)
|
|
payload["hermes_issuer"] = self._bound_issuer
|
|
self.loaded_issuer = self._bound_issuer
|
|
_write_json(self._tokens_path(), payload)
|
|
logger.debug("OAuth tokens saved for %s", self._server_name)
|
|
|
|
def bind_issuer(self, issuer: str | None) -> None:
|
|
"""Set the authorization-server issuer stamped on future token writes."""
|
|
self._bound_issuer = str(issuer) if issuer else None
|
|
|
|
def stamp_issuer(self, issuer: str) -> None:
|
|
"""Backfill ``hermes_issuer`` onto a pre-binding token file: adopt the currently discovered
|
|
issuer once instead of forcing a re-login, so the *next* read is protected."""
|
|
data = _read_json(self._tokens_path())
|
|
if data is None or data.get("hermes_issuer"):
|
|
return
|
|
data["hermes_issuer"] = str(issuer)
|
|
try:
|
|
_write_json(self._tokens_path(), data)
|
|
except OSError as exc: # non-fatal — worst case we stamp next time
|
|
logger.debug("Could not stamp issuer on tokens for %s: %s", self._server_name, exc)
|
|
return
|
|
self.loaded_issuer = str(issuer)
|
|
|
|
def strip_refresh_token(self) -> None:
|
|
"""Drop the refresh token (and its issuer record) from disk, keeping the access token: the
|
|
unexpired access token may still be used, but a refresh token must never go to a different
|
|
issuer than the one that granted it."""
|
|
data = _read_json(self._tokens_path())
|
|
if data is None or not data.get("refresh_token"):
|
|
return
|
|
data.pop("refresh_token", None)
|
|
data.pop("hermes_issuer", None)
|
|
self.loaded_issuer = None
|
|
try:
|
|
_write_json(self._tokens_path(), data)
|
|
except OSError as exc:
|
|
logger.warning("Could not strip refresh token for %s: %s", self._server_name, exc)
|
|
return
|
|
logger.info("Removed issuer-mismatched refresh token for %s (re-authorization will be required "
|
|
"when the access token expires)", self._server_name)
|
|
|
|
@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
|
|
(Supabase) omit ``token_endpoint_auth_method``, the SDK defaults it to ``none`` and the
|
|
exchange fails without the secret."""
|
|
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[0]:
|
|
_write_json(self._client_info_path(), _model_json(info)) # persist so later flows skip the coercion
|
|
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)
|
|
|
|
def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None:
|
|
"""Persist server metadata so a restarted process can refresh without re-discovery;
|
|
otherwise the SDK guesses ``{server_url}/token`` (404) and forces re-auth."""
|
|
_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")
|
|
|
|
def mark_cimd_rejected(self) -> None:
|
|
"""Durably record that this server refused our CIMD document so a restart does not re-present
|
|
the refused client_id. Cleared by ``remove()`` so a fixed document gets a retry."""
|
|
path = self._cimd_rejected_path()
|
|
try:
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
|
|
mkdir_under_hermes_home(path.parent)
|
|
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()
|
|
|
|
def remove(self, *, keep_metadata: bool = False) -> None:
|
|
"""Delete all stored OAuth state for this server; ``keep_metadata`` spares ``.meta.json`` so a
|
|
re-login can still announce the discovered ``authorization_endpoint`` when the authorization
|
|
server's metadata document cannot be re-fetched (#115329)."""
|
|
# The ``.refresh.lock`` sidecar is deliberately kept: flock is inode-bound, so unlinking it
|
|
# while a peer holds the fence would let the next acquirer lock a fresh inode (two holders).
|
|
for p in (self._tokens_path(), self._client_info_path(), self._cimd_rejected_path(),
|
|
*(() if keep_metadata else (self._meta_path(),))):
|
|
p.unlink(missing_ok=True)
|
|
|
|
def snapshot(self) -> dict[str, bytes]:
|
|
"""filename -> bytes of the existing state files; ``restore()`` it 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)
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
|
|
mkdir_under_hermes_home(token_dir)
|
|
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 DCR client (``invalid_client`` at the token endpoint) plus stale ``meta.json``
|
|
so the SDK re-registers next flow; tokens are kept (a valid refresh token survives if
|
|
re-registration never completes). Keeps one ``.bak``. True if a client file was 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: the HTTP listener and the stdin paste reader share one result dict.
|
|
def _authorization_code_result(code: str, state: "str | None", iss: "str | None" = None):
|
|
"""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]:
|
|
"""code/state/error/iss from a redirect query string. ``iss`` (RFC 9207 issuer) is kept: mcp 2.0
|
|
rejects a response omitting it when the server advertised ``authorization_response_iss_parameter_supported``."""
|
|
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 _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)
|
|
status = 200
|
|
if not parsed["code"] and not parsed["error"]:
|
|
# Browsers follow the real /callback with queryless fetches (/favicon.ico); an unconditional
|
|
# update here wrote four Nones over the code before the 500 ms waiter poll saw it (#116278).
|
|
status, body = 404, "<h2>Not Found</h2>"
|
|
elif _result_taken(result):
|
|
# First terminal result (HTTP or paste) wins; a duplicate or refreshed callback never replaces it.
|
|
body = "<h2>Authorization already received</h2><p>You can close this tab and return to Hermes.</p>"
|
|
else:
|
|
result.update(auth_code=parsed["code"], state=parsed["state"], error=parsed["error"], iss=parsed["iss"])
|
|
body = ("<h2>Authorization Successful</h2><p>You can close this tab and return to Hermes.</p>" if parsed["code"]
|
|
else f"<h2>Authorization Failed</h2><p>Error: {html.escape(parsed['error'] or 'unknown')}</p>")
|
|
self.send_response(status)
|
|
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_request(self, code: str = "-", size: str = "-") -> None: # noqa: N802
|
|
logger.debug("OAuth callback: %s %s", self.command, urlparse(self.path).path) # never the query (carries the code)
|
|
|
|
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 as an OAuth redirect (full URL, bare query, or a ``_SKIP_TOKENS`` word that
|
|
exits without auth) into *result*. Parse failures, EOF and interrupts are swallowed — best-effort
|
|
fallback racing the HTTP listener, which stays primary."""
|
|
try:
|
|
line = sys.stdin.readline()
|
|
except (KeyboardInterrupt, OSError, ValueError):
|
|
return
|
|
line = (line or "").strip()
|
|
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
|
|
result.update(auth_code=parsed["code"], state=parsed["state"], error=parsed["error"], iss=parsed["iss"])
|
|
if parsed["code"]:
|
|
print(" Got authorization code from paste — completing flow.", file=sys.stderr)
|
|
|
|
|
|
# Remote-session hints printed under the authorization URL: a proxy callback forwards the redirect
|
|
# here (no tunnel needed); on loopback it misses this machine, so the user pastes the URL back or SSH-forwards 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://{host}:{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, redirect_host: str | None = 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(host=redirect_host or "127.0.0.1", port=port), file=sys.stderr)
|
|
if not _can_open_browser():
|
|
note = "Headless environment detected — open the URL manually."
|
|
else:
|
|
opened = False
|
|
with contextlib.suppress(Exception):
|
|
opened = webbrowser.open(authorization_url)
|
|
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, redirect_host: str | None = None):
|
|
"""Redirect handler closing over this flow's port (a closure, not ``_oauth_port``, keeps concurrent
|
|
flows isolated). ``redirect_uri`` is a configured proxy callback (None for loopback) and only tailors the
|
|
hint; ``redirect_host`` is the loopback hostname the provider will actually redirect to (see
|
|
:func:`_resolve_redirect_uri`).
|
|
|
|
Using a closure instead of reading the module-level ``_oauth_port`` avoids cross-server state pollution
|
|
when multiple MCP servers run OAuth concurrently (fixes #44588).
|
|
"""
|
|
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 when non-interactive: a cached-but-unusable token makes the SDK fall through to the
|
|
# authorization-code flow past the token-file guard, and the waiter would block for the full timeout.
|
|
# Fail fast at the authorization boundary in non-interactive contexts (systemd gateway, cron,
|
|
# background MCP discovery). Without this check we would print a URL and launch a browser flow no
|
|
# operator can complete, then block in _wait_for_callback for the full timeout. Raise before
|
|
# launching so gateway adapters start promptly and the caller can skip this server with an
|
|
# actionable warning. This intentionally re-checks interactivity here rather than trusting the
|
|
# token-file existence guard alone. See #57836.
|
|
_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, redirect_host)
|
|
|
|
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 (concurrent flow / leftover listener / colliding redirect_port): 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 _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):
|
|
"""Callback waiter bound to one flow's port. ``timeout`` is where ``oauth.timeout`` applies (mcp 2.0
|
|
dropped the provider's own). ``cimd_url`` only tailors the timeout message: a server refusing the
|
|
document aborts at the authorization endpoint, so no redirect arrives and a bare "timed out" would
|
|
hide the cause. Raises ``OAuthNonInteractiveError`` on timeout or when non-interactive.
|
|
|
|
Closing over the port (instead of reading the module-level ``_oauth_port``) keeps concurrent OAuth flows
|
|
isolated: flow A's waiter listens on flow A's port even when flow B's ``_configure_callback_port``
|
|
overwrites the legacy global afterwards (#34260, the callback-side sibling of the #44588
|
|
redirect-handler fix).
|
|
"""
|
|
async def _wait():
|
|
dashboard_flow = get_dashboard_oauth_flow()
|
|
if dashboard_flow is not None and not port:
|
|
# Dashboard flow speaks the legacy tuple; normalize to one shape. A pinned loopback port
|
|
# (pre-registered client) listens locally instead; the dashboard only shows the URL.
|
|
return _authorization_code_result(*await dashboard_flow.wait_for_callback())
|
|
# 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.
|
|
# Reject before binding the callback listener in non-interactive contexts. Reaching here means the
|
|
# SDK entered the authorization-code flow (a valid or refreshable token would never call the
|
|
# callback handler), so a cached token file is present but unusable. Binding the listener here would
|
|
# block for the full 300s timeout and — on the next connection retry — collide with the
|
|
# still-bound/TIME_WAIT port, surfacing as ``OSError: [Errno 98] Address already in use``. Failing
|
|
# fast keeps gateway startup independent of an unusable optional MCP server. This guard holds
|
|
# "regardless of whether a token file exists" — the point the build_oauth_auth token-file guard
|
|
# cannot cover. See #57836.
|
|
_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)
|
|
# serve_forever/shutdown, not a bare handle_request thread: a thread parked in handle_request's
|
|
# select() keeps the listening socket alive past server_close() (the kernel holds the file for
|
|
# the duration of the poll), so a cancelled flow — e.g. the handshake timeout firing mid-login —
|
|
# left the port bound and the retry on the same pinned/cached port died with EADDRINUSE (#113771).
|
|
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.25}, daemon=True).start()
|
|
# Paste fallback races the HTTP listener; whichever fills result first wins (no stdin reader
|
|
# under a dashboard flow — the gateway's stdin is not the user's).
|
|
if _is_interactive() and dashboard_flow is None:
|
|
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()
|
|
elapsed = 0.0
|
|
try:
|
|
while elapsed < timeout and not _result_taken(result):
|
|
await asyncio.sleep(0.5)
|
|
elapsed += 0.5
|
|
finally:
|
|
server.shutdown() # returns once the serve loop exits (≤ poll_interval) — the port is free after close
|
|
server.server_close()
|
|
return _callback_outcome(result, cimd_url)
|
|
|
|
return _wait
|
|
|
|
|
|
# Legacy build_oauth_auth provider class, built lazily (SDK) and cached here.
|
|
HermesOAuthClientProvider: Any = None
|
|
|
|
|
|
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): the client_id IS an HTTPS URL the server fetches for our
|
|
# name/logo/redirect URIs, replacing per-install DCR. The SDK does the protocol; Hermes only decides
|
|
# eligibility. Published from ``website/static/oauth/client-metadata.json``; the github.io origin is
|
|
# deliberate — servers MUST NOT follow redirects when fetching it, and hermes-agent.nousresearch.com/docs/* 301s here.
|
|
_CIMD_CLIENT_METADATA_URL = "https://nousresearch.github.io/hermes-agent/docs/oauth/client-metadata.json"
|
|
# Loopback ports/hosts declared in that document (exact match, so no ephemeral port under CIMD);
|
|
# below Linux's 32768 ephemeral floor. tests/tools/test_mcp_cimd.py keeps them in sync.
|
|
_CIMD_PORTS = (27890, 27891, 27892, 27893, 27894)
|
|
_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 (ImportError = SDK predates
|
|
CIMD → DCR only). The SDK checks only https + non-root path; userinfo, fragments and dot segments
|
|
are rejected here because they fail mid-browser-flow as an opaque invalid-client page."""
|
|
try:
|
|
from mcp.client.auth.utils import is_valid_client_metadata_url
|
|
if not is_valid_client_metadata_url(url):
|
|
return False
|
|
parsed = urlparse(url)
|
|
has_userinfo = bool(parsed.username or parsed.password) # netloc parse can raise
|
|
except (ImportError, ValueError):
|
|
return False
|
|
return not (has_userinfo or parsed.fragment or any(seg in {".", ".."} for seg in parsed.path.split("/")))
|
|
|
|
|
|
# Pinned ports this process committed to (never released: a provider keeps its port for the process
|
|
# lifetime), including ports restored from a cached registration.
|
|
# Includes a port restored from a cached client registration, so a sibling server is never handed a port
|
|
# another one is already registered on (#34260).
|
|
_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 makes
|
|
contention cooperative: a sibling 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 by the waiter); DCR may be unsupported entirely.
|
|
|
|
Holding the bound socket until ``_wait_for_callback`` adopts it does the same job here as
|
|
``_reserve_callback_port`` does for ephemeral ports (#22161): a fixed port is just as stealable in the
|
|
minutes between selection and the browser redirect arriving.
|
|
"""
|
|
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 — so cached metadata closes the gap;
|
|
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":
|
|
"""``(client_id URL, pinned callback port)``, or None to use DCR. Each ineligibility case means the
|
|
redirect URI is not one the document declares, the client identity is already settled, or the
|
|
server is known not to want a document — a metadata URL would be rejected."""
|
|
url = cfg.get("client_metadata_url") or _CIMD_CLIENT_METADATA_URL
|
|
ineligible = (
|
|
cfg.get("cimd") is False
|
|
or not _is_valid_cimd_url(url)
|
|
# pinned client = explicit choice; a secret = confidential client, which the document forbids
|
|
or cfg.get("client_id") or cfg.get("client_secret")
|
|
# the document supplies name + auth method; setting either asks for an identity CIMD can't present
|
|
or cfg.get("client_name") or (cfg.get("token_endpoint_auth_method") or "none") != "none"
|
|
# dashboard/desktop flows redirect to a deployment-specific URL no static document declares
|
|
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 client_id would drop tokens
|
|
or _cached_client_info(storage) is not None
|
|
or (storage is not None and storage.cimd_rejected())
|
|
or _server_declined_cimd(storage))
|
|
port = None if ineligible else _pick_cimd_port()
|
|
return None if port is None else (url, port)
|
|
|
|
|
|
def cimd_provider_kwargs(cfg: dict) -> dict[str, Any]:
|
|
"""``client_metadata_url=`` kwargs for ``OAuthClientProvider`` when CIMD applies; omitted entirely
|
|
on a DCR flow because 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 (exchange + refresh only, never MCP
|
|
traffic or discovery), or None; a null/empty YAML value never sends a blank header. 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 login_connect_timeout(config: dict) -> float:
|
|
"""Connect bound for an interactive OAuth login probe (CLI ``hermes mcp login``, dashboard and
|
|
Desktop re-auth): the server's ``connect_timeout`` or its ``oauth.timeout`` callback window
|
|
(default 300 s) plus 15 s headroom for the token exchange, whichever is longer. A fixed 315 s
|
|
floor here made raising ``oauth.timeout`` alone a no-op — the probe timed out first (#116278)."""
|
|
def _seconds(value, default: float) -> float:
|
|
try:
|
|
return max(0.0, float(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
oauth_cfg = config.get("oauth") or {}
|
|
return max(_seconds(config.get("connect_timeout"), 0.0),
|
|
_seconds(oauth_cfg.get("timeout"), 300.0) + 15.0)
|
|
|
|
|
|
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 (sets ``cfg['_cimd_url']``) →
|
|
``oauth.redirect_port`` → cached registration port → fresh ephemeral port (the only parked one).
|
|
Also sets the legacy ``_oauth_port``.
|
|
|
|
NOTE: also sets the legacy module-level ``_oauth_port`` so existing calls to ``_wait_for_callback`` keep
|
|
working. The legacy global is the root cause of issue #5344 (port collision on concurrent OAuth flows);
|
|
replacing it with a ContextVar is out of scope for this consolidation PR.
|
|
"""
|
|
global _oauth_port
|
|
dashboard_flow = get_dashboard_oauth_flow()
|
|
# A pre-registered client with a pinned ``redirect_port`` (no DCR; the vendor matches the registered
|
|
# loopback URI exactly) keeps its loopback listener even under the dashboard/Desktop flow: the
|
|
# dashboard's own callback URL was never registered with the vendor, so that flow could not complete.
|
|
pinned_loopback = bool(cfg.get("client_id") and cfg.get("redirect_port"))
|
|
if dashboard_flow is not None and not pinned_loopback:
|
|
cfg["_resolved_port"] = 0
|
|
cfg["redirect_uri"] = cfg.get("redirect_uri") or dashboard_flow.redirect_uri
|
|
return 0
|
|
cached_uri, cached_port = _cached_redirect(storage)
|
|
if cached_uri and not cfg.get("redirect_uri") and not pinned_loopback:
|
|
cfg["redirect_uri"] = cached_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_port or _reserve_callback_port()
|
|
# A cached port may be a pinned CIMD port from an earlier login; claim it from siblings.
|
|
if port in _CIMD_PORTS and port not in _assigned_cimd_ports:
|
|
_assigned_cimd_ports.append(port)
|
|
# Precedence: explicit config port → cached client-registration port → fresh ephemeral port. The cached
|
|
# port keeps re-auth consistent with the redirect URI pinned at dynamic client registration (providers
|
|
# reject a mismatched URI). Only a truly fresh ephemeral pick goes through _reserve_callback_port(),
|
|
# which keeps the socket bound until _wait_for_callback adopts it — closing the select→bind TOCTOU race
|
|
# (#22161). Explicit and cached ports are fixed, known values and bind via the reuse_address path
|
|
# instead.
|
|
cfg["_resolved_port"] = port
|
|
_oauth_port = port
|
|
return port
|
|
|
|
|
|
def _resolve_redirect_uri(cfg: dict, port: int) -> str:
|
|
"""Configured ``redirect_uri`` (proxy) or ``http://<redirect_host>:<port>/callback``; the single
|
|
derivation so client metadata and pre-registered info stay identical. ``redirect_host`` only changes
|
|
the hostname (some WAFs reject a literal ``127.0.0.1``); 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 allowlists DCR by client_name ("Claude Code"/"Codex" register, others 403);
|
|
# register under an allowlisted name so the 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."""
|
|
from utils import base_url_host_matches, base_url_hostname
|
|
url = (server_url or "").lower()
|
|
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 (server_name or "").lower() 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 (before building client metadata /
|
|
pre-registering); returns *cfg*. Only fills keys the user left unset — explicit values 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 auth_method=none yet demands the returned client_secret at the token
|
|
# endpoint; request a confidential registration so the SDK posts it.
|
|
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 (Figma) needing 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: OIDC-strict servers need application_type to accept loopback redirects; "native"
|
|
# for a CLI/desktop app, overridable for a hosted https dashboard.
|
|
"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 client identity changes: tokens minted under the old
|
|
``client_id`` fail refresh with ``invalid_client``, and pre-registered clients are exempt from
|
|
auto-poison, so stale tokens would wedge every request until a manual wipe. Compares on-disk
|
|
``client.json`` BEFORE it is overwritten; a matching identity is a no-op.
|
|
|
|
Matching identity is a no-op so live sessions and valid tokens are preserved. Port of
|
|
cline/cline#12983's "invalidate tokens when OAuth client changes" invariant.
|
|
"""
|
|
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 or (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 DCR 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`` (auto-set to ``Claude Code``), so this fires
|
|
when the user overrode it or an older Hermes is running."""
|
|
msg = str(exc)
|
|
lowered = msg.lower()
|
|
from tools.mcp_oauth_provider import _DISCOVERY_CONTEXT_LEAD
|
|
if msg.startswith(_DISCOVERY_CONTEXT_LEAD): # the 403 there is the metadata fetch, not a DCR refusal
|
|
return None
|
|
looks_like_registration = ("403" in msg or "forbidden" in lowered) and (
|
|
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":
|
|
"""``httpx.Auth`` OAuth handler for an MCP server; None if the SDK lacks OAuth. Legacy API — new code
|
|
uses :func:`tools.mcp_oauth_manager.get_manager` so state is shared across config-time, runtime and reconnect paths."""
|
|
global HermesOAuthClientProvider
|
|
if not _OAUTH_AVAILABLE or _sdk_class("OAuthClientProvider") is None:
|
|
logger.warning("MCP OAuth requested for '%s' but SDK auth types are not available. Run: "
|
|
f"{install_hint('mcp')}", 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)
|
|
if HermesOAuthClientProvider is None:
|
|
from tools.mcp_oauth_provider import HermesProviderMixin
|
|
|
|
HermesOAuthClientProvider = type("HermesOAuthClientProvider", (HermesProviderMixin, _sdk_class("OAuthClientProvider")), {
|
|
"__doc__": "SDK provider plus Hermes' token-endpoint fixes (see ``HermesProviderMixin``).",
|
|
"__module__": __name__, "_hermes_logger": logger})
|
|
return HermesOAuthClientProvider(server_url=server_url, **kwargs)
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from contextlib import contextmanager # noqa: F401,E402
|
|
|
|
OAuthClientInformationFull: Any = None
|
|
|
|
OAuthClientMetadata: Any = None
|
|
|
|
OAuthClientProvider: Any = None
|
|
|
|
OAuthMetadata: Any = None
|
|
|
|
OAuthToken: Any = None
|
|
# ---- END PLUGIN-COMPAT ----
|