Files
Vincenzo Reina aca5f9c040 feat(python): Adapt OAuth to 2.1 version from new MCP spec (#518)
* feat: Add PRM in the metadata discovery

* fix: fallback anyway to PRM

* feat: Implement complete discovery logic

* feat: Final implementation of CIMD

* feat: Implement PKCE

* feat: Add scope from WWW-Authenticate

* feat: Add all URLs possible styles

* feat: Update tests and example

* docs: Update docs

* fix: linting

* docs: update API documentation

* fix: correct typos in OAuth metadata and error messages

- Updated the comment in ProtectedResourceMetadata to use the correct abbreviation (PRM).
- Fixed a typo in the OAuthAuthenticationError message regarding code challenge support.
- Corrected the comment for legacy client paths to use "pre-registered" instead of "pre-reggistered".

* feat: restructure authentication documentation and add new methods

- Updated the authentication section in  to group authentication methods under a new Authentication category with an icon.
- Removed the old  file and replaced it with new documentation files for Bearer Token, Custom Auth, and OAuth 2.1.
- Introduced an overview page for authentication methods, detailing usage and configuration for each method.
- Enhanced security best practices and examples for each authentication type to improve clarity and usability.

* refactor: improve debug logging for OAuth metadata discovery

- Updated debug messages to provide clearer context by specifying the issuer or host being queried during OAuth and OpenID Connect metadata discovery.
- Enhanced error logging to remove redundant information, focusing on the failure reason without repeating the URL.

* fix: correct abbreviation in ProtectedResourceMetadata documentation

- Updated the abbreviation from PRC to PRM in the ProtectedResourceMetadata section to ensure accuracy in the documentation.

* fix(oauth): correct RFC 8414 well-known URL construction for path-based issuers

  The OAuth metadata discovery was incorrectly constructing .well-known URLs
  by appending to the issuer instead of inserting between host and path.

  RFC 8414 specifies that for an issuer like https://github.com/login/oauth,
  the .well-known URL should be:
    https://github.com/.well-known/oauth-authorization-server/login/oauth

  We were incorrectly building:
    https://github.com/login/oauth/.well-known/oauth-authorization-server

  This fix:
  - Implements correct RFC 8414 URL construction (insert between host/path)
  - Supports OpenID Connect style as fallback (append to issuer)
  - Removes GitHub-specific hardcode since discovery now works correctly
  - Adds documentation comments explaining the two standards

  Ref: https://www.rfc-editor.org/rfc/rfc8414.html#section-3

* chore: oauth examples

* docs: enhance OAuth endpoint discovery documentation

- Updated the OAuth documentation to clarify how `mcp-use` discovers OAuth endpoints using both OAuth 2.0 and OpenID Connect standards.
- Added a table detailing the URL construction methods for each standard.
- Included examples for better understanding and a note on fallback behavior when the issuer has no path.

* response to bs codeql

* fix: update API URL Updated the GitHub API URL in the configuration to the correct endpoint for OAuth operations.

---------

Co-authored-by: Enrico Toniato <2827496+tonxxd@users.noreply.github.com>
Co-authored-by: pietrozullo <pietro.zullo@gmail.com>
2026-01-11 18:09:34 +01:00

806 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OAuth authentication support for MCP clients."""
import base64
import hashlib
import json
import re
import secrets
import webbrowser
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from authlib.integrations.httpx_client import AsyncOAuth2Client
from authlib.oauth2 import OAuth2Error
from pydantic import BaseModel, Field, HttpUrl, SecretStr
from mcp_use.client.auth.bearer import BearerAuth
from mcp_use.client.auth.oauth_callback import OAuthCallbackServer
from mcp_use.client.exceptions import OAuthAuthenticationError, OAuthDiscoveryError
from mcp_use.logging import logger
from mcp_use.telemetry.telemetry import telemetry
class ServerOAuthMetadata(BaseModel):
"""OAuth metadata from MCP server with flexible field support.
It is essentially a configuration that tells MCP client:
- Where to send users for authorization
- Where to exchange the codes for tokens
- Which OAuth features are supported
- Where to register new users with DCR"""
issuer: HttpUrl # The OAuth server's identity
authorization_endpoint: HttpUrl # URL with endpoint for client auth
token_endpoint: HttpUrl # URL with endpoint for tokens' exchange
userinfo_endpoint: HttpUrl | None = None
revocation_endpoint: HttpUrl | None = None
introspection_endpoint: HttpUrl | None = None
registration_endpoint: HttpUrl | None = None # Endpoint for DCR
jwks_uri: HttpUrl | None = None
response_types_supported: list[str] = Field(default_factory=lambda: ["code"])
subject_types_supported: list[str] = Field(default_factory=lambda: ["public"])
id_token_signing_alg_values_supported: list[str] = Field(default_factory=lambda: ["RS256"])
scopes_supported: list[str] | None = None # Which permissions are supported
token_endpoint_auth_methods_supported: list[str] = Field(default_factory=lambda: ["client_secret_basic"])
claims_supported: list[str] | None = None
code_challenge_methods_supported: list[str] | None = None
client_id_metadata_document_supported: bool | None = None
class Config:
extra = "allow" # Allow additional fields
class ProtectedResourceMetadata(BaseModel):
"""
PRM (Protected Resource Metadata) can have metadata
describing their configuration. It could contain information
about the OAuth metadata.
"""
resource: str
authorization_servers: list[str]
scopes_supported: list[str] | None = None
class OAuthClientProvider(BaseModel):
"""OAuth client provider configuration for a specific server.
This contains all the information needed to authenticate with an OAuth server
without needing to discover metadata or register clients dynamically."""
id: str # Unique identifier
display_name: str
metadata: ServerOAuthMetadata | dict[str, Any]
@property
def oauth_metadata(self) -> ServerOAuthMetadata:
"""Get OAuth metadata as ServerOAuthMetadata instance."""
if isinstance(self.metadata, dict):
return ServerOAuthMetadata(**self.metadata)
return self.metadata
class TokenData(BaseModel):
"""OAuth token data.
This is the information received after
successfull authentication"""
access_token: str # Actual credential used for requests
token_type: str = "Bearer"
expires_at: float | None = None
refresh_token: str | None = None
scope: str | None = None
class ClientRegistrationResponse(BaseModel):
"""Dynamic Client Registration response.
It represents the response from an OAuth server
when you dinamically register a new OAuth client."""
client_id: str
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
redirect_uris: list[str] | None = None # Where auth server should redirect after auth
grant_types: list[str] | None = None # Which oauth flows it uses
response_types: list[str] | None = None
client_name: str | None = None
token_endpoint_auth_method: str | None = None
class Config:
extra = "allow" # Allow additional fields from server
class FileTokenStorage:
"""File-based token storage.
It's responsible for:
- Saving OAuth tokens to disk after auth
- Loading saved tokens when the app restarts
- Deleting tokens when they're revoked
- Organizing tokens by server URL"""
def __init__(self, base_dir: Path | None = None):
"""Initialize token storage.
Args:
base_dir: Base directory for token storage. Defaults to ~/.mcp_use/tokens
"""
self.base_dir = base_dir or Path.home() / ".mcp_use" / "tokens"
logger.debug(f"FileTokenStorage initialized with base_dir: {self.base_dir}")
self.base_dir.mkdir(parents=True, exist_ok=True)
def _get_token_path(self, server_url: str) -> Path:
"""Get token file path for a server."""
# Create a safe filename from the URL
parsed = urlparse(server_url)
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}.json"
path = self.base_dir / filename
logger.debug(f"Token path for server '{server_url}' is '{path}'")
return path
async def save_tokens(self, server_url: str, tokens: dict[str, Any]) -> None:
"""Save tokens to file."""
token_path = self._get_token_path(server_url)
logger.debug(f"Saving tokens for '{server_url}' to '{token_path}'")
token_data = TokenData(**tokens)
token_path.write_text(token_data.model_dump_json())
logger.debug(f"Tokens saved successfully for '{server_url}'")
async def load_tokens(self, server_url: str) -> TokenData | None:
"""Load tokens from file."""
token_path = self._get_token_path(server_url)
logger.debug(f"Attempting to load tokens for '{server_url}' from '{token_path}'")
if not token_path.exists():
logger.debug(f"Token file not found: '{token_path}'")
return None
try:
data = json.loads(token_path.read_text())
token_data = TokenData(**data)
logger.debug(f"Successfully loaded tokens for '{server_url}'")
return token_data
except (json.JSONDecodeError, ValueError) as e:
logger.debug(f"Failed to load or parse token file '{token_path}': {e}")
return None
async def delete_tokens(self, server_url: str) -> None:
"""Delete tokens for a server."""
token_path = self._get_token_path(server_url)
logger.debug(f"Deleting tokens for '{server_url}' at '{token_path}'")
if token_path.exists():
token_path.unlink()
logger.debug(f"Token file '{token_path}' deleted.")
else:
logger.debug(f"Token file '{token_path}' not found, nothing to delete.")
class OAuth:
"""OAuth authentication handler for MCP clients.
This is the main class that handles all the authentication
It has several features:
- Discovers OAuth server capabilities automatically
- Registers client dynamically when possible
- Manages token storage and refresh automaticlly"""
def __init__(
self,
server_url: str,
token_storage: FileTokenStorage | None = None,
scope: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
callback_port: int | None = None,
oauth_provider: OAuthClientProvider | None = None,
client_metadata_url: str | None = None,
):
"""Initialize OAuth handler.
Args:
server_url: The MCP server URL
token_storage: Token storage implementation. Defaults to FileTokenStorage
scope: OAuth scopes to request
client_id: OAuth client ID. If not provided, will attempt dynamic registration
client_secret: OAuth client secret (for confidential clients)
callback_port: Port for local callback server, if empty, 8080 is used
oauth_provider: OAuth client provider to prevent metadata discovery
client_metadata_url: Field used to authenticate with CIMD
"""
logger.debug(f"Initializing OAuth for server: {urlparse(server_url).netloc}")
self.server_url = server_url
self.token_storage = token_storage or FileTokenStorage()
self.scope = scope
self.client_id = client_id
self.client_secret = client_secret
self.client_metadata_url = client_metadata_url
if callback_port:
self.callback_port = callback_port
logger.info(f"Using custom callback port {self.callback_port} provided in config")
else:
self.callback_port = 8080
logger.info(f"Using default callback port {self.callback_port}")
# Set the default redirect uri
self.redirect_uri = f"http://localhost:{self.callback_port}/callback"
self._oauth_provider = oauth_provider
self._metadata: ServerOAuthMetadata | None = None
self._resource_metadata: ProtectedResourceMetadata | None = None
if self._oauth_provider:
self._metadata = self._oauth_provider.oauth_metadata
logger.debug(f"Using OAuth provider {self._oauth_provider.id} with metadata")
self._client: AsyncOAuth2Client | None = None
self._bearer_auth: BearerAuth | None = None
logger.debug(f"OAuth initialized with scope='{self.scope}', has_client_id={self.client_id is not None}")
@telemetry("oauth_initialize")
async def initialize(self, client: httpx.AsyncClient) -> BearerAuth | None:
"""Initialize OAuth and return bearer auth if tokens exist."""
logger.debug(f"OAuth.initialize called for {self.server_url}")
# Try to load existing tokens
logger.debug("Attempting to load existing tokens")
token_data = await self.token_storage.load_tokens(self.server_url)
if token_data:
logger.debug("Found existing tokens, checking validity")
if self._is_token_valid(token_data):
logger.debug("Existing token is valid, creating BearerAuth")
self._bearer_auth = BearerAuth(token=SecretStr(token_data.access_token))
logger.debug("OAuth.initialize returning existing valid BearerAuth")
return self._bearer_auth
else:
logger.debug("Existing token is expired")
else:
logger.debug("No existing tokens found")
# Discover OAuth metadata
if not self._metadata:
logger.debug("No valid token, proceeding to discover OAuth metadata")
await self._discover_metadata(client)
else:
logger.debug("Using provided OAuth metadata, skipping discovery")
logger.debug("OAuth.initialize finished, no valid token available yet")
return None
@telemetry("oauth_authenticate")
async def authenticate(self) -> BearerAuth:
"""Perform OAuth authentication flow."""
logger.debug("OAuth.authenticate called")
if not self._metadata:
logger.error("OAuth.authenticate called before metadata was discovered.")
raise OAuthAuthenticationError("OAuth metadata not discovered")
# The port check should be done now. OAuth servers
# register client_id with also redirect_uri, so we
# have to ensure port is available before DCR
try:
import socket
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", self.callback_port))
sock.close()
logger.debug(f"Using registered port {self.callback_port} for callback")
except (ValueError, OSError) as exception:
logger.error(f"The port {self.callback_port} is not available! Try using a different port!")
raise exception
# Check if code challenge exists with S256
if (
not self._metadata.code_challenge_methods_supported
or "S256" not in self._metadata.code_challenge_methods_supported
):
raise OAuthAuthenticationError("The auth must support code challenge S256. Can't complete auth without it.")
# Check if it supports CIMD
supports_cimd = (
self._metadata.client_id_metadata_document_supported
if self._metadata.client_id_metadata_document_supported
else False
)
client_id = self.client_id
client_secret = self.client_secret
# 1) CIMD path (preferred when configured as specified in MCP spec)
if self.client_metadata_url and supports_cimd:
logger.debug(f"Using Client ID Metadata Document (CIMD) as client_id: {self.client_metadata_url}")
client_id = self.client_metadata_url
client_secret = None # public client
else:
# 2) Legacy paths: pre-registered clients or DCR
registration = None # Track if we used DCR
if not client_id:
logger.debug("No client_id provided, attempting dynamic client registration")
# Try to load previously registered client
registration = await self._load_client_registration()
if registration:
logger.debug("Using previously registered client")
client_id = registration.client_id
client_secret = registration.client_secret
else:
# Attempt dynamic registration
registration = await self._try_dynamic_registration()
if registration:
logger.debug("Dynamic registration successful")
client_id = registration.client_id
client_secret = registration.client_secret
# Store for future use
await self._store_client_registration(registration)
else:
if supports_cimd and not self._metadata.registration_endpoint:
raise OAuthAuthenticationError(
"OAuth server only supports Client ID Metadata Documents. "
"Please provide 'client_metadata_url' in the auth configuration "
"pointing to your CIMD JSON document."
)
logger.error("Dynamic client registration failed or not supported")
raise OAuthAuthenticationError(
"OAuth requires a client_id. Server does not support dynamic registration. "
"Please provide one in the auth configuration. "
"Example: {'auth': {'client_id': 'your-registered-client-id'}}"
)
logger.debug(f"Using client_id: {client_id}")
# Generate PKCE code_verifier/challenge
code_verifier, code_challenge = self._generate_pkce_pair()
# Create OAuth client
logger.debug("Creating AsyncOAuth2Client")
self._client = AsyncOAuth2Client(
client_id=client_id,
client_secret=client_secret,
redirect_uri=self.redirect_uri,
scope=self.scope,
)
# Start callback server
logger.debug("Starting OAuth callback server")
callback_server = OAuthCallbackServer(port=self.callback_port)
redirect_uri = await callback_server.start()
self._client.redirect_uri = redirect_uri
logger.debug(f"Callback server started, redirect_uri: {redirect_uri}")
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
logger.debug(f"Generated state for CSRF protection: {state}")
# Get resource as required in the MCP spec
resource = self._resource_metadata.resource if self._resource_metadata else self.server_url
# Build authorization URL
logger.debug("Creating authorization URL")
auth_url, _ = self._client.create_authorization_url(
str(self._metadata.authorization_endpoint),
resource=resource,
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
)
logger.debug("OAuth flow started:")
logger.debug(f" Client ID: {client_id}")
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
logger.debug(f" Redirect URI: {redirect_uri}")
logger.debug(f" Scope: {self.scope}")
# Open browser for authorization
print(f"Opening browser for authorization: {auth_url}")
webbrowser.open(auth_url)
# Wait for callback
logger.debug("Waiting for authorization code from callback server")
try:
response = await callback_server.wait_for_code()
logger.debug("Received response from callback server")
except TimeoutError as e:
logger.error(f"OAuth callback timed out: {e}")
raise OAuthAuthenticationError(f"OAuth timeout: {e}") from e
if response.error:
logger.error("OAuth authorization failed:")
logger.error(f" Error: {response.error}")
logger.error(f" Description: {response.error_description}")
logger.error(" The OAuth server returned this error, likely because:")
logger.error(f" 1. The client_id '{client_id}' is not registered with the OAuth server")
logger.error(" 2. The redirect_uri doesn't match the registered one")
logger.error(" 3. The requested scopes are invalid")
raise OAuthAuthenticationError(f"{response.error}: {response.error_description}")
if not response.code:
logger.error("Callback response did not contain an authorization code")
raise OAuthAuthenticationError("No authorization code received")
logger.debug(f"Received authorization code: {response.code[:10]}...")
# Verify state
logger.debug(f"Verifying state. Expected: {state}, Got: {response.state}")
if response.state != state:
logger.error("State mismatch in OAuth callback. Possible CSRF attack.")
raise OAuthAuthenticationError("Invalid state parameter - possible CSRF attack")
logger.debug("State verified successfully")
# Exchange code for tokens
logger.debug("Exchanging authorization code for tokens")
try:
token_response = await self._client.fetch_token(
str(self._metadata.token_endpoint),
authorization_response=f"{redirect_uri}?code={response.code}&state={response.state}",
grant_type="authorization_code",
code_verifier=code_verifier,
)
logger.debug("Successfully fetched tokens")
except OAuth2Error as e:
logger.error(f"Token exchange failed: {e}")
raise OAuthAuthenticationError(f"Token exchange failed: {e}") from e
# Save tokens
logger.debug("Saving fetched tokens")
await self.token_storage.save_tokens(self.server_url, token_response)
# Create bearer auth
logger.debug("Creating BearerAuth with new access token")
self._bearer_auth = BearerAuth(token=SecretStr(token_response["access_token"]))
return self._bearer_auth
def _generate_pkce_pair(self) -> tuple[str, str]:
"""Generate PKCE code_verifier and S256 code_challenge"""
code_verifier = secrets.token_urlsafe(64)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
.rstrip(b"=")
.decode("ascii")
)
return code_verifier, code_challenge
async def _discover_metadata(self, client: httpx.AsyncClient) -> None:
"""Discover OAuth metadata from server."""
logger.debug(f"Discovering OAuth metadata for {self.server_url}")
# Try well-known endpoint first
parsed = urlparse(self.server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
prm_url: str | None = None
# 1) Try to get PRM URL from 401 + WWW-Authenticate
try:
init_resp = await client.get(self.server_url, headers={"Accept": "application/json"})
if init_resp.status_code == 401:
# Parse the resource_metadata
prm_url = self._extract_prm(init_resp)
except httpx.HTTPError as e:
logger.debug(f"Failed probing server for PRM via 401: {e}")
# 1b) If WWW-Authenticate didn’t give us a URL, try well-known PRM paths
if not prm_url:
path = (parsed.path or "").rstrip("/")
candidate_prm_urls: list[str] = []
# Path-specific form: /.well-known/oauth-protected-resource{path}
if path:
candidate_prm_urls.append(f"{base_url}/.well-known/oauth-protected-resource{path}")
# Root form: /.well-known/oauth-protected-resource
candidate_prm_urls.append(f"{base_url}/.well-known/oauth-protected-resource")
for candidate in candidate_prm_urls:
try:
logger.debug(f"Trying OAuth PRM endpoint at: {candidate}")
prm_response = await client.get(candidate)
prm_response.raise_for_status()
prm = prm_response.json()
self._resource_metadata = ProtectedResourceMetadata(**prm)
logger.debug("Successfully got the PRM data")
logger.debug(f"Authorization servers: {self._resource_metadata.authorization_servers}")
prm_url = candidate
break
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OAuth PRM at {candidate}: {e}")
# 2) If we have PRM URL but _resource_metadata is still None
if prm_url and not self._resource_metadata:
try:
logger.debug(f"Trying OAuth PRM endpoint at: {prm_url}")
prm_response = await client.get(prm_url)
prm_response.raise_for_status()
prm = prm_response.json()
self._resource_metadata = ProtectedResourceMetadata(**prm)
logger.debug("Successfully got the PRM data")
logger.debug(f"Authorization servers: {self._resource_metadata.authorization_servers}")
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OAuth PRM at {prm_url}: {e}")
pass
# 3) For each authorization server, try AS metadata and stop on first success
#
# Well-known URL construction differs between OAuth 2.0 and OpenID Connect:
# - OAuth 2.0 (RFC 8414): Insert .well-known between host and path
# - OpenID Connect: Append .well-known to issuer
#
# Example for issuer https://github.com/login/oauth:
# - OAuth 2.0: https://github.com/.well-known/oauth-authorization-server/login/oauth
# - OIDC: https://github.com/login/oauth/.well-known/openid-configuration
#
# See: https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1
auth_servers = self._resource_metadata.authorization_servers if self._resource_metadata else []
for auth_server in auth_servers:
parsed_issuer = urlparse(auth_server)
issuer_base = f"{parsed_issuer.scheme}://{parsed_issuer.netloc}"
issuer_path = (parsed_issuer.path or "").rstrip("/")
# OAuth 2.0 (RFC 8414): Insert .well-known between host and path
oauth_candidates: list[str] = []
if issuer_path:
# e.g., https://github.com/.well-known/oauth-authorization-server/login/oauth
oauth_candidates.append(f"{issuer_base}/.well-known/oauth-authorization-server{issuer_path}")
else:
oauth_candidates.append(f"{issuer_base}/.well-known/oauth-authorization-server")
# OpenID Connect: Append .well-known to issuer
oidc_candidates: list[str] = []
# e.g., https://github.com/login/oauth/.well-known/openid-configuration
oidc_candidates.append(f"{auth_server.rstrip('/')}/.well-known/openid-configuration")
# Try OAuth 2.0 Authorization Server Metadata endpoints first
for well_known_url in oauth_candidates:
try:
logger.debug(f"Trying OAuth metadata discovery for issuer: {parsed_issuer.netloc}")
response = await client.get(well_known_url)
response.raise_for_status()
metadata = response.json()
self._metadata = ServerOAuthMetadata(**metadata)
logger.debug("Successfully discovered OAuth metadata")
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
logger.debug(f" Token endpoint: {self._metadata.token_endpoint}")
return
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OAuth metadata: {e}")
# Then try OpenID Connect Discovery endpoints
for oidc_url in oidc_candidates:
logger.debug(f"Trying OpenID Connect discovery for issuer: {parsed_issuer.netloc}")
try:
response = await client.get(oidc_url)
response.raise_for_status()
metadata = response.json()
self._metadata = ServerOAuthMetadata(**metadata)
logger.debug("Successfully discovered OIDC metadata")
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
logger.debug(f" Token endpoint: {self._metadata.token_endpoint}")
return
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OIDC metadata: {e}")
# 4) If PRM path didn't yield anything, fall back to old host-level discovery
if not self._metadata:
well_known_url = f"{base_url}/.well-known/oauth-authorization-server"
try:
logger.debug(f"Trying OAuth metadata discovery for host: {parsed.netloc}")
resp = await client.get(well_known_url)
resp.raise_for_status()
self._metadata = ServerOAuthMetadata(**resp.json())
logger.debug("Successfully discovered OAuth metadata (host-level fallback)")
return
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OAuth metadata: {e}")
oidc_url = f"{base_url}/.well-known/openid-configuration"
logger.debug(f"Trying OpenID Connect discovery for host: {parsed.netloc}")
try:
resp = await client.get(oidc_url)
resp.raise_for_status()
self._metadata = ServerOAuthMetadata(**resp.json())
logger.debug("Successfully discovered OIDC metadata (host-level fallback)")
return
except (httpx.HTTPError, ValueError) as e:
logger.debug(f"Failed to discover OIDC metadata: {e}")
logger.error(f"Failed to discover OAuth/OIDC metadata for {self.server_url}")
raise OAuthDiscoveryError(
f"Failed to discover OAuth metadata for {self.server_url}. "
"Server must support OAuth metadata discovery at "
"/.well-known/oauth-authorization-server or /.well-known/openid-configuration"
)
def _extract_prm(self, response: httpx.Response) -> str | None:
www_auth = response.headers.get("WWW-Authenticate")
if not www_auth:
return None
# If the server has a scope and the user didn't override it
scope_match = re.search(r'scope="([^"]+)"', www_auth)
if scope_match and not self.scope:
self.scope = scope_match.group(1)
logger.debug("Using scope from WWW-Authenticate header")
# Extract PRM url
match = re.search(r'resource_metadata="([^"]+)"', www_auth)
if match:
return match.group(1)
return None
def _is_token_valid(self, token_data: TokenData) -> bool:
"""Check if token is still valid."""
logger.debug("Checking token validity")
if not token_data.expires_at:
logger.debug("Token has no expiration time, assuming it's valid.")
return True # No expiration info, assume valid
# Check if token expires in more than 60 seconds
expires_at = datetime.fromtimestamp(token_data.expires_at, tz=UTC)
now = datetime.now(tz=UTC)
is_valid = expires_at > now + timedelta(seconds=60)
logger.debug(f"Token expires at {expires_at}, current time is {now}. Valid: {is_valid}")
return is_valid
async def _try_dynamic_registration(self) -> ClientRegistrationResponse | None:
"""Try Dynamic Client Registration if supported by the server."""
if not self._metadata or not self._metadata.registration_endpoint:
logger.debug("No registration endpoint available, skipping DCR")
return None
logger.info("Attempting Dynamic Client Registration")
logger.debug(f"DCR endpoint: {self._metadata.registration_endpoint}")
registration_data = {
"client_name": "mcp-use",
"redirect_uris": [self.redirect_uri],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none", # Public client
"application_type": "native",
}
# Add scope if specified
if self.scope:
registration_data["scope"] = self.scope
logger.debug(f"DCR request payload: {registration_data}")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
str(self._metadata.registration_endpoint),
json=registration_data,
headers={"Content-Type": "application/json"},
)
logger.debug(f"DCR response status: {response.status_code}")
response.raise_for_status()
# Parse registration response
reg_response_data = response.json()
logger.debug(f"DCR response body: {reg_response_data}")
reg_response = ClientRegistrationResponse(**reg_response_data)
# Update our credentials
self.client_id = reg_response.client_id
self.client_secret = reg_response.client_secret
logger.info(f"Dynamic Client Registration successful: {self.client_id}")
# Store the registered client info for future use
await self._store_client_registration(reg_response)
return reg_response
except httpx.HTTPError as e:
logger.warning(f"Dynamic Client Registration failed: {e}")
# Log the response if available
if hasattr(e, "response") and e.response:
logger.debug(f"DCR response: {e.response.status_code} - {e.response.text}")
return None
except Exception as e:
logger.warning(f"Unexpected error during DCR: {e}")
return None
async def _store_client_registration(self, registration: ClientRegistrationResponse) -> None:
"""Store client registration data for future use."""
logger.debug("Storing client registration data")
# Store alongside tokens in a separate file
storage_path = self.token_storage.base_dir / "registrations"
storage_path.mkdir(parents=True, exist_ok=True)
# Create a safe filename from the server URL
parsed = urlparse(self.server_url)
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}_registration.json"
reg_path = storage_path / filename
logger.debug(f"Storing client registration to '{reg_path}'")
# Store registration data
reg_path.write_text(registration.model_dump_json())
logger.debug("Client registration data stored successfully")
async def _load_client_registration(self) -> ClientRegistrationResponse | None:
"""Load previously registered client credentials if available."""
logger.debug("Attempting to load client registration data")
storage_path = self.token_storage.base_dir / "registrations"
# Create a safe filename from the server URL
parsed = urlparse(self.server_url)
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}_registration.json"
reg_path = storage_path / filename
logger.debug(f"Checking for client registration file at '{reg_path}'")
if reg_path.exists():
logger.debug("Client registration file found")
try:
data = json.loads(reg_path.read_text())
reg_response = ClientRegistrationResponse(**data)
# Check if registration is still valid (if expiry info provided)
if reg_response.client_secret_expires_at:
expires_at = datetime.fromtimestamp(reg_response.client_secret_expires_at, tz=UTC)
now = datetime.now(tz=UTC)
logger.debug(f"Checking client registration expiry. Expires at: {expires_at}, Now: {now}")
if expires_at <= now:
logger.debug("Stored client registration has expired")
return None
self.client_id = reg_response.client_id
self.client_secret = reg_response.client_secret
logger.debug(f"Loaded stored client registration: {self.client_id}")
return reg_response
except Exception as e:
logger.debug(f"Failed to load client registration: {e}")
else:
logger.debug("Client registration file not found")
return None
@telemetry("oauth_refresh_token")
async def refresh_token(self) -> BearerAuth | None:
"""Refresh the access token if possible."""
logger.debug("Attempting to refresh token")
token_data = await self.token_storage.load_tokens(self.server_url)
if not token_data or not token_data.refresh_token:
logger.debug("No token data or refresh token found, cannot refresh.")
return None
if not self._metadata:
logger.debug("No OAuth metadata available, cannot refresh token.")
return None
if not self._client:
if not self.client_id:
logger.debug("Cannot refresh token without client_id")
return None
logger.debug("Creating temporary AsyncOAuth2Client for token refresh")
self._client = AsyncOAuth2Client(client_id=self.client_id, client_secret=self.client_secret)
logger.debug("Calling client.refresh_token")
try:
token_response = await self._client.refresh_token(
str(self._metadata.token_endpoint),
refresh_token=token_data.refresh_token,
)
logger.debug("Token refresh successful")
# Save new tokens
logger.debug("Saving new tokens after refresh")
await self.token_storage.save_tokens(self.server_url, token_response)
# Update bearer auth
logger.debug("Updating BearerAuth with new access token")
self._bearer_auth = BearerAuth(token=SecretStr(token_response["access_token"]))
return self._bearer_auth
except OAuth2Error as e:
logger.warning(f"Token refresh failed: {e}. Re-authentication is required.")
# Refresh failed, need to re-authenticate
return None