mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* feat(mcp-oauth): scaffold MCPOAuthManager
Central manager for per-server MCP OAuth state. Provides
get_or_build_provider (cached), remove (evicts cache + deletes
disk), invalidate_if_disk_changed (mtime watch, core fix for
external-refresh workflow), and handle_401 (dedup'd recovery).
No behavior change yet — existing call sites still use
build_oauth_auth directly. Task 1 of 8 in the MCP OAuth
consolidation (fixes Cthulhu's BetterStack reliability issues).
* feat(mcp-oauth): add HermesMCPOAuthProvider with pre-flow disk watch
Subclasses the MCP SDK's OAuthClientProvider to inject a disk
mtime check before every async_auth_flow, via the central
manager. When a subclass instance is used, external token
refreshes (cron, another CLI instance) are picked up before
the next API call.
Still dead code: the manager's _build_provider still delegates
to build_oauth_auth and returns the plain OAuthClientProvider.
Task 4 wires this subclass in. Task 2 of 8.
* refactor(mcp-oauth): extract build_oauth_auth helpers
Decomposes build_oauth_auth into _configure_callback_port,
_build_client_metadata, _maybe_preregister_client, and
_parse_base_url. Public API preserved. These helpers let
MCPOAuthManager._build_provider reuse the same logic in Task 4
instead of duplicating the construction dance.
Also updates the SDK version hint in the warning from 1.10.0 to
1.26.0 (which is what we actually require for the OAuth types
used here). Task 3 of 8.
* feat(mcp-oauth): manager now builds HermesMCPOAuthProvider directly
_build_provider constructs the disk-watching subclass using the
helpers from Task 3, instead of delegating to the plain
build_oauth_auth factory. Any consumer using the manager now gets
pre-flow disk-freshness checks automatically.
build_oauth_auth is preserved as the public API for backwards
compatibility. The code path is now:
MCPOAuthManager.get_or_build_provider ->
_build_provider ->
_configure_callback_port
_build_client_metadata
_maybe_preregister_client
_parse_base_url
HermesMCPOAuthProvider(...)
Task 4 of 8.
* feat(mcp): wire OAuth manager + add _reconnect_event
MCPServerTask gains _reconnect_event alongside _shutdown_event.
When set, _run_http / _run_stdio exit their async-with blocks
cleanly (no exception), and the outer run() loop re-enters the
transport to rebuild the MCP session with fresh credentials.
This is the recovery path for OAuth failures that the SDK's
in-place httpx.Auth cannot handle (e.g. cron externally consumed
the refresh_token, or server-side session invalidation).
_run_http now asks MCPOAuthManager for the OAuth provider
instead of calling build_oauth_auth directly. Config-time,
runtime, and reconnect paths all share one provider instance
with pre-flow disk-watch active.
shutdown() defensively sets both events so there is no race
between reconnect and shutdown signalling.
Task 5 of 8.
* feat(mcp): detect auth failures in tool handlers, trigger reconnect
All 5 MCP tool handlers (tool call, list_resources, read_resource,
list_prompts, get_prompt) now detect auth failures and route
through MCPOAuthManager.handle_401:
1. If the manager says recovery is viable (disk has fresh tokens,
or SDK can refresh in-place), signal MCPServerTask._reconnect_event
to tear down and rebuild the MCP session with fresh credentials,
then retry the tool call once.
2. If no recovery path exists, return a structured needs_reauth
JSON error so the model stops hallucinating manual refresh
attempts (the 'let me curl the token endpoint' loop Cthulhu
pasted from Discord).
_is_auth_error catches OAuthFlowError, OAuthTokenError,
OAuthNonInteractiveError, and httpx.HTTPStatusError(401). Non-auth
exceptions still surface via the generic error path unchanged.
Task 6 of 8.
* feat(mcp-cli): route add/remove through manager, add 'hermes mcp login'
cmd_mcp_add and cmd_mcp_remove now go through MCPOAuthManager
instead of calling build_oauth_auth / remove_oauth_tokens
directly. This means CLI config-time state and runtime MCP
session state are backed by the same provider cache — removing
a server evicts the live provider, adding a server populates
the same cache the MCP session will read from.
New 'hermes mcp login <name>' command:
- Wipes both the on-disk tokens file and the in-memory
MCPOAuthManager cache
- Triggers a fresh OAuth browser flow via the existing probe
path
- Intended target for the needs_reauth error Task 6 returns
to the model
Task 7 of 8.
* test(mcp-oauth): end-to-end integration tests
Five new tests exercising the full consolidation with real file
I/O and real imports (no transport mocks):
1. external_refresh_picked_up_without_restart — Cthulhu's cron
workflow. External process writes fresh tokens to disk;
on the next auth flow the manager's mtime-watch flips
_initialized and the SDK re-reads from storage.
2. handle_401_deduplicates_concurrent_callers — 10 concurrent
handlers for the same failed token fire exactly ONE recovery
attempt (thundering-herd protection).
3. handle_401_returns_false_when_no_provider — defensive path
for unknown servers.
4. invalidate_if_disk_changed_handles_missing_file — pre-auth
state returns False cleanly.
5. provider_is_reused_across_reconnects — cache stickiness so
reconnects preserve the disk-watch baseline mtime.
Task 8 of 8 — consolidation complete.
526 lines
19 KiB
Python
526 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MCP OAuth 2.1 Client Support
|
|
|
|
Implements the browser-based OAuth 2.1 authorization code flow with PKCE
|
|
for MCP servers that require OAuth authentication instead of static bearer
|
|
tokens.
|
|
|
|
Uses the MCP Python SDK's ``OAuthClientProvider`` (an ``httpx.Auth`` subclass)
|
|
which handles discovery, dynamic client registration, PKCE, token exchange,
|
|
refresh, and step-up authorization automatically.
|
|
|
|
This module provides the glue:
|
|
- ``HermesTokenStorage``: persists tokens/client-info to disk so they
|
|
survive across process restarts.
|
|
- Callback server: ephemeral localhost HTTP server to capture the OAuth
|
|
redirect with the authorization code.
|
|
- ``build_oauth_auth()``: entry point called by ``mcp_tool.py`` that wires
|
|
everything together and returns the ``httpx.Auth`` object.
|
|
|
|
Configuration in config.yaml::
|
|
|
|
mcp_servers:
|
|
my_server:
|
|
url: "https://mcp.example.com/mcp"
|
|
auth: oauth
|
|
oauth: # all fields optional
|
|
client_id: "pre-registered-id" # skip dynamic registration
|
|
client_secret: "secret" # confidential clients only
|
|
scope: "read write" # default: server-provided
|
|
redirect_port: 0 # 0 = auto-pick free port
|
|
client_name: "My Custom Client" # default: "Hermes Agent"
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import webbrowser
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lazy imports -- MCP SDK with OAuth support is optional
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_OAUTH_AVAILABLE = False
|
|
try:
|
|
from mcp.client.auth import OAuthClientProvider
|
|
from mcp.shared.auth import (
|
|
OAuthClientInformationFull,
|
|
OAuthClientMetadata,
|
|
OAuthToken,
|
|
)
|
|
from pydantic import AnyUrl
|
|
|
|
_OAUTH_AVAILABLE = True
|
|
except ImportError:
|
|
logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exceptions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class OAuthNonInteractiveError(RuntimeError):
|
|
"""Raised when OAuth requires browser interaction in a non-interactive env."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Port used by the most recent build_oauth_auth() call. Exposed so that
|
|
# tests can verify the callback server and the redirect_uri share a port.
|
|
_oauth_port: int | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_token_dir() -> Path:
|
|
"""Return the directory for MCP OAuth token files.
|
|
|
|
Uses HERMES_HOME so each profile gets its own OAuth tokens.
|
|
Layout: ``HERMES_HOME/mcp-tokens/``
|
|
"""
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
base = Path(get_hermes_home())
|
|
except ImportError:
|
|
base = Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")))
|
|
return base / "mcp-tokens"
|
|
|
|
|
|
def _safe_filename(name: str) -> str:
|
|
"""Sanitize a server name for use as a filename (no path separators)."""
|
|
return re.sub(r"[^\w\-]", "_", name).strip("_")[:128] or "default"
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Find an available TCP port on localhost."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def _is_interactive() -> bool:
|
|
"""Return True if we can reasonably expect to interact with a user."""
|
|
try:
|
|
return sys.stdin.isatty()
|
|
except (AttributeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _can_open_browser() -> bool:
|
|
"""Return True if opening a browser is likely to work."""
|
|
# Explicit SSH session → no local display
|
|
if os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"):
|
|
return False
|
|
# macOS and Windows usually have a display
|
|
if os.name == "nt":
|
|
return True
|
|
try:
|
|
if os.uname().sysname == "Darwin":
|
|
return True
|
|
except AttributeError:
|
|
pass
|
|
# Linux/other posix: need DISPLAY or WAYLAND_DISPLAY
|
|
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
|
return True
|
|
return False
|
|
|
|
|
|
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:
|
|
"""Write a dict as JSON with restricted permissions (0o600)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(".tmp")
|
|
try:
|
|
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
os.chmod(tmp, 0o600)
|
|
tmp.rename(path)
|
|
except OSError:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HermesTokenStorage -- persistent token/client-info on disk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class HermesTokenStorage:
|
|
"""Persist OAuth tokens and client registration to JSON files.
|
|
|
|
File layout::
|
|
|
|
HERMES_HOME/mcp-tokens/<server_name>.json -- tokens
|
|
HERMES_HOME/mcp-tokens/<server_name>.client.json -- client info
|
|
"""
|
|
|
|
def __init__(self, server_name: str):
|
|
self._server_name = _safe_filename(server_name)
|
|
|
|
def _tokens_path(self) -> Path:
|
|
return _get_token_dir() / f"{self._server_name}.json"
|
|
|
|
def _client_info_path(self) -> Path:
|
|
return _get_token_dir() / f"{self._server_name}.client.json"
|
|
|
|
# -- tokens ------------------------------------------------------------
|
|
|
|
async def get_tokens(self) -> "OAuthToken | None":
|
|
data = _read_json(self._tokens_path())
|
|
if data is None:
|
|
return None
|
|
try:
|
|
return OAuthToken.model_validate(data)
|
|
except (ValueError, TypeError, KeyError) as exc:
|
|
logger.warning("Corrupt tokens at %s -- ignoring: %s", self._tokens_path(), exc)
|
|
return None
|
|
|
|
async def set_tokens(self, tokens: "OAuthToken") -> None:
|
|
_write_json(self._tokens_path(), tokens.model_dump(exclude_none=True))
|
|
logger.debug("OAuth tokens saved for %s", self._server_name)
|
|
|
|
# -- client info -------------------------------------------------------
|
|
|
|
async def get_client_info(self) -> "OAuthClientInformationFull | None":
|
|
data = _read_json(self._client_info_path())
|
|
if data is None:
|
|
return None
|
|
try:
|
|
return OAuthClientInformationFull.model_validate(data)
|
|
except (ValueError, TypeError, KeyError) as exc:
|
|
logger.warning("Corrupt client info at %s -- ignoring: %s", self._client_info_path(), exc)
|
|
return None
|
|
|
|
async def set_client_info(self, client_info: "OAuthClientInformationFull") -> None:
|
|
_write_json(self._client_info_path(), client_info.model_dump(exclude_none=True))
|
|
logger.debug("OAuth client info saved for %s", self._server_name)
|
|
|
|
# -- cleanup -----------------------------------------------------------
|
|
|
|
def remove(self) -> None:
|
|
"""Delete all stored OAuth state for this server."""
|
|
for p in (self._tokens_path(), self._client_info_path()):
|
|
p.unlink(missing_ok=True)
|
|
|
|
def has_cached_tokens(self) -> bool:
|
|
"""Return True if we have tokens on disk (may be expired)."""
|
|
return self._tokens_path().exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Callback handler factory -- each invocation gets its own result dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_callback_handler() -> tuple[type, dict]:
|
|
"""Create a per-flow callback HTTP handler class with its own result dict.
|
|
|
|
Returns ``(HandlerClass, result_dict)`` where *result_dict* is a mutable
|
|
dict that the handler writes ``auth_code`` and ``state`` into when the
|
|
OAuth redirect arrives. Each call returns a fresh pair so concurrent
|
|
flows don't stomp on each other.
|
|
"""
|
|
result: dict[str, Any] = {"auth_code": None, "state": None, "error": None}
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self) -> None: # noqa: N802
|
|
params = parse_qs(urlparse(self.path).query)
|
|
code = params.get("code", [None])[0]
|
|
state = params.get("state", [None])[0]
|
|
error = params.get("error", [None])[0]
|
|
|
|
result["auth_code"] = code
|
|
result["state"] = state
|
|
result["error"] = error
|
|
|
|
body = (
|
|
"<html><body><h2>Authorization Successful</h2>"
|
|
"<p>You can close this tab and return to Hermes.</p></body></html>"
|
|
) if code else (
|
|
"<html><body><h2>Authorization Failed</h2>"
|
|
f"<p>Error: {error or 'unknown'}</p></body></html>"
|
|
)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.end_headers()
|
|
self.wfile.write(body.encode())
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
logger.debug("OAuth callback: %s", fmt % args)
|
|
|
|
return _Handler, result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async redirect + callback handlers for OAuthClientProvider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _redirect_handler(authorization_url: str) -> None:
|
|
"""Show the authorization URL to the user.
|
|
|
|
Opens the browser automatically when possible; always prints the URL
|
|
as a fallback for headless/SSH/gateway environments.
|
|
"""
|
|
msg = (
|
|
f"\n MCP OAuth: authorization required.\n"
|
|
f" Open this URL in your browser:\n\n"
|
|
f" {authorization_url}\n"
|
|
)
|
|
print(msg, file=sys.stderr)
|
|
|
|
if _can_open_browser():
|
|
try:
|
|
opened = webbrowser.open(authorization_url)
|
|
if opened:
|
|
print(" (Browser opened automatically.)\n", file=sys.stderr)
|
|
else:
|
|
print(" (Could not open browser — please open the URL manually.)\n", file=sys.stderr)
|
|
except Exception:
|
|
print(" (Could not open browser — please open the URL manually.)\n", file=sys.stderr)
|
|
else:
|
|
print(" (Headless environment detected — open the URL manually.)\n", file=sys.stderr)
|
|
|
|
|
|
async def _wait_for_callback() -> tuple[str, str | None]:
|
|
"""Wait for the OAuth callback to arrive on the local callback server.
|
|
|
|
Uses the module-level ``_oauth_port`` which is set by ``build_oauth_auth``
|
|
before this is ever called. Polls for the result without blocking the
|
|
event loop.
|
|
|
|
Raises:
|
|
OAuthNonInteractiveError: If the callback times out (no user present
|
|
to complete the browser auth).
|
|
"""
|
|
assert _oauth_port is not None, "OAuth callback port not set"
|
|
|
|
# The callback server is already running (started in build_oauth_auth).
|
|
# We just need to poll for the result.
|
|
handler_cls, result = _make_callback_handler()
|
|
|
|
# Start a temporary server on the known port
|
|
try:
|
|
server = HTTPServer(("127.0.0.1", _oauth_port), handler_cls)
|
|
except OSError:
|
|
# Port already in use — the server from build_oauth_auth is running.
|
|
# Fall back to polling the server started by build_oauth_auth.
|
|
raise OAuthNonInteractiveError(
|
|
"OAuth callback timed out — could not bind callback port. "
|
|
"Complete the authorization in a browser first, then retry."
|
|
)
|
|
|
|
server_thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
server_thread.start()
|
|
|
|
timeout = 300.0
|
|
poll_interval = 0.5
|
|
elapsed = 0.0
|
|
try:
|
|
while elapsed < timeout:
|
|
if result["auth_code"] is not None or result["error"] is not None:
|
|
break
|
|
await asyncio.sleep(poll_interval)
|
|
elapsed += poll_interval
|
|
finally:
|
|
server.server_close()
|
|
|
|
if result["error"]:
|
|
raise RuntimeError(f"OAuth authorization failed: {result['error']}")
|
|
if result["auth_code"] is None:
|
|
raise OAuthNonInteractiveError(
|
|
"OAuth callback timed out — no authorization code received. "
|
|
"Ensure you completed the browser authorization flow."
|
|
)
|
|
|
|
return result["auth_code"], result["state"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def remove_oauth_tokens(server_name: str) -> None:
|
|
"""Delete stored OAuth tokens and client info for a server."""
|
|
storage = HermesTokenStorage(server_name)
|
|
storage.remove()
|
|
logger.info("OAuth tokens removed for '%s'", server_name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extracted helpers (Task 3 of MCP OAuth consolidation)
|
|
#
|
|
# These compose into ``build_oauth_auth`` below, and are also used by
|
|
# ``tools.mcp_oauth_manager.MCPOAuthManager._build_provider`` so the two
|
|
# construction paths share one implementation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _configure_callback_port(cfg: dict) -> int:
|
|
"""Pick or validate the OAuth callback port.
|
|
|
|
Stores the resolved port into ``cfg['_resolved_port']`` so sibling
|
|
helpers (and the manager) can read it from the same dict. Returns the
|
|
resolved 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
|
|
requested = int(cfg.get("redirect_port", 0))
|
|
port = _find_free_port() if requested == 0 else requested
|
|
cfg["_resolved_port"] = port
|
|
_oauth_port = port # legacy consumer: _wait_for_callback reads this
|
|
return port
|
|
|
|
|
|
def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata":
|
|
"""Build OAuthClientMetadata from the oauth config dict.
|
|
|
|
Requires ``cfg['_resolved_port']`` to have been populated by
|
|
:func:`_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()"
|
|
)
|
|
client_name = cfg.get("client_name", "Hermes Agent")
|
|
scope = cfg.get("scope")
|
|
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
|
|
metadata_kwargs: dict[str, Any] = {
|
|
"client_name": client_name,
|
|
"redirect_uris": [AnyUrl(redirect_uri)],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
if scope:
|
|
metadata_kwargs["scope"] = scope
|
|
if cfg.get("client_secret"):
|
|
metadata_kwargs["token_endpoint_auth_method"] = "client_secret_post"
|
|
|
|
return OAuthClientMetadata.model_validate(metadata_kwargs)
|
|
|
|
|
|
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
|
|
port = cfg["_resolved_port"]
|
|
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
|
|
info_dict: dict[str, Any] = {
|
|
"client_id": client_id,
|
|
"redirect_uris": [redirect_uri],
|
|
"grant_types": client_metadata.grant_types,
|
|
"response_types": client_metadata.response_types,
|
|
"token_endpoint_auth_method": client_metadata.token_endpoint_auth_method,
|
|
}
|
|
if cfg.get("client_secret"):
|
|
info_dict["client_secret"] = cfg["client_secret"]
|
|
if cfg.get("client_name"):
|
|
info_dict["client_name"] = cfg["client_name"]
|
|
if cfg.get("scope"):
|
|
info_dict["scope"] = cfg["scope"]
|
|
|
|
client_info = OAuthClientInformationFull.model_validate(info_dict)
|
|
_write_json(storage._client_info_path(), client_info.model_dump(exclude_none=True))
|
|
logger.debug("Pre-registered client_id=%s for '%s'", client_id, storage._server_name)
|
|
|
|
|
|
def _parse_base_url(server_url: str) -> str:
|
|
"""Strip path component from server URL, returning the base origin."""
|
|
parsed = urlparse(server_url)
|
|
return f"{parsed.scheme}://{parsed.netloc}"
|
|
|
|
|
|
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.
|
|
|
|
Public API preserved for backwards compatibility. New code should use
|
|
:func:`tools.mcp_oauth_manager.get_manager` so OAuth state is shared
|
|
across config-time, runtime, and reconnect paths.
|
|
|
|
Args:
|
|
server_name: Server key in mcp_servers config (used for storage).
|
|
server_url: MCP server endpoint URL.
|
|
oauth_config: Optional dict from the ``oauth:`` block in config.yaml.
|
|
|
|
Returns:
|
|
An ``OAuthClientProvider`` instance, or None if the MCP SDK lacks
|
|
OAuth support.
|
|
"""
|
|
if not _OAUTH_AVAILABLE:
|
|
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
|
|
|
|
cfg = dict(oauth_config or {}) # copy — we mutate _resolved_port
|
|
storage = HermesTokenStorage(server_name)
|
|
|
|
if not _is_interactive() and not storage.has_cached_tokens():
|
|
logger.warning(
|
|
"MCP OAuth for '%s': non-interactive environment and no cached tokens "
|
|
"found. The OAuth flow requires browser authorization. Run "
|
|
"interactively first to complete the initial authorization, then "
|
|
"cached tokens will be reused.",
|
|
server_name,
|
|
)
|
|
|
|
_configure_callback_port(cfg)
|
|
client_metadata = _build_client_metadata(cfg)
|
|
_maybe_preregister_client(storage, cfg, client_metadata)
|
|
|
|
return OAuthClientProvider(
|
|
server_url=_parse_base_url(server_url),
|
|
client_metadata=client_metadata,
|
|
storage=storage,
|
|
redirect_handler=_redirect_handler,
|
|
callback_handler=_wait_for_callback,
|
|
timeout=float(cfg.get("timeout", 300)),
|
|
)
|