Add authorization_server parameter to OAuth client

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvrgfBGaKwaPuC3BqozvL5
This commit is contained in:
Claude 2026-02-22 13:30:44 +00:00
commit c33776459b
No known key found for this signature in database
3 changed files with 125 additions and 1 deletions

View file

@ -60,6 +60,7 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` —
- **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details
- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options
- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration
- **`authorization_server`** (`str`, optional): Direct URL of the OAuth authorization server. When provided, the client uses this URL instead of discovering it via Protected Resource Metadata (RFC 9728). See [Direct Authorization Server](#direct-authorization-server)
- **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port
- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients
@ -73,7 +74,7 @@ The OAuth flow is triggered when you use a FastMCP `Client` configured to use OA
The client first checks the configured `token_storage` backend for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client.
</Step>
<Step title="OAuth Server Discovery">
If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`.
If no valid tokens exist, the client discovers the authorization server via [Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728) (RFC 9728) at `/.well-known/oauth-protected-resource`, then fetches OAuth server metadata from the discovered AS URL. If `authorization_server` is configured, it is used as a fallback when PRM discovery fails.
</Step>
<Step title="Client Registration">
If a `client_id` is provided, the client uses those pre-registered credentials directly and skips this step entirely. Otherwise, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity. As a fallback, the client performs Dynamic Client Registration (RFC 7591) if the server supports it.
@ -155,6 +156,43 @@ async with Client(
See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.
## Direct Authorization Server
<VersionBadge version="3.0.2" />
By default, the client discovers the authorization server through [Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728) (RFC 9728) — a well-known endpoint on the MCP server that declares which authorization server it trusts. This is the spec-compliant flow that cryptographically binds the server to its AS via TLS.
Some MCP servers don't serve PRM endpoints. For these cases, you can provide the authorization server URL directly:
```python
from fastmcp import Client
from fastmcp.client.auth import OAuth
async with Client(
"https://mcp-server.example.com/mcp",
auth=OAuth(
authorization_server="https://auth.example.com",
),
) as client:
await client.ping()
```
The configured URL acts as a fallback — if the MCP server does serve PRM, the PRM-discovered authorization server takes precedence. This means you can safely set `authorization_server` even if you're unsure whether the server supports PRM.
<Warning>
When you provide `authorization_server` directly, you're bypassing the trust assertion that PRM provides. Only use this with authorization servers you trust and have verified are correct for the target MCP server.
</Warning>
This is commonly combined with [pre-registered credentials](#pre-registered-clients) for environments where both PRM and Dynamic Client Registration are unavailable:
```python
oauth = OAuth(
authorization_server="https://auth.example.com",
client_id="my-registered-client-id",
client_secret="my-client-secret",
)
```
## Pre-Registered Clients
<VersionBadge version="3.0.0" />

View file

@ -160,6 +160,8 @@ class OAuth(OAuthClientProvider):
# --- OR clients provide full client information ---
client_id: str | None = None,
client_secret: str | None = None,
# Direct authorization server URL (bypasses PRM discovery):
authorization_server: str | None = None,
):
"""
Initialize OAuth client provider for an MCP server.
@ -181,6 +183,11 @@ class OAuth(OAuthClientProvider):
client_id: Pre-registered OAuth client ID. When provided, skips dynamic
client registration and uses these static credentials instead.
client_secret: OAuth client secret (optional, used with client_id)
authorization_server: Direct URL of the OAuth authorization server. When
provided, the client uses this URL instead of discovering it via
Protected Resource Metadata (RFC 9728). Useful when the MCP server
does not serve PRM endpoints. If the server does serve PRM, the
PRM-discovered authorization server takes precedence.
"""
# Store config for deferred binding if mcp_url not yet known
self._scopes = scopes
@ -192,6 +199,7 @@ class OAuth(OAuthClientProvider):
self._client_id = client_id
self._client_secret = client_secret
self._static_client_info = None
self._authorization_server = authorization_server
self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
self._bound = False
@ -274,6 +282,13 @@ class OAuth(OAuthClientProvider):
client_metadata_url=self._client_metadata_url,
)
# Pre-set the authorization server URL on the SDK context. This is used
# as a fallback when Protected Resource Metadata discovery fails (e.g.,
# the MCP server doesn't serve PRM endpoints). If PRM discovery succeeds,
# the PRM-discovered AS URL takes precedence over this value.
if self._authorization_server:
self.context.auth_server_url = self._authorization_server.rstrip("/")
self._bound = True
async def _initialize(self) -> None:

View file

@ -176,6 +176,77 @@ class TestOAuthClientUrlHandling:
assert oauth.token_storage_adapter._server_url == mcp_url
class TestAuthorizationServer:
"""Tests for the authorization_server parameter on OAuth."""
def test_authorization_server_sets_context(self):
"""When authorization_server is provided, it should be set on the SDK context."""
oauth = OAuth(
mcp_url="https://mcp.example.com/mcp",
authorization_server="https://auth.example.com",
)
assert oauth.context.auth_server_url == "https://auth.example.com"
def test_authorization_server_strips_trailing_slash(self):
"""Trailing slashes should be normalized."""
oauth = OAuth(
mcp_url="https://mcp.example.com",
authorization_server="https://auth.example.com/",
)
assert oauth.context.auth_server_url == "https://auth.example.com"
def test_no_authorization_server_leaves_context_default(self):
"""Without authorization_server, auth_server_url should remain None."""
oauth = OAuth(mcp_url="https://mcp.example.com")
assert oauth.context.auth_server_url is None
def test_authorization_server_deferred_binding(self):
"""authorization_server should work with deferred URL binding."""
oauth = OAuth(authorization_server="https://auth.example.com")
assert not oauth._bound
# Simulate what transport._set_auth does
oauth._bind("https://mcp.example.com/mcp")
assert oauth._bound
assert oauth.context.auth_server_url == "https://auth.example.com"
def test_authorization_server_with_client_id(self):
"""authorization_server should work alongside pre-registered client credentials."""
oauth = OAuth(
mcp_url="https://mcp.example.com",
authorization_server="https://auth.example.com",
client_id="my-client",
client_secret="my-secret",
)
assert oauth.context.auth_server_url == "https://auth.example.com"
assert oauth._static_client_info is not None
assert oauth._static_client_info.client_id == "my-client"
class TestAuthorizationServerIntegration:
"""Integration test: authorization_server with a live server that has PRM."""
async def test_oauth_flow_succeeds_with_authorization_server(
self, streamable_http_server: str
):
"""The OAuth flow should succeed when authorization_server is provided
alongside a server that serves PRM. PRM discovery should override the
configured value."""
parsed_url = urlparse(streamable_http_server)
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
client = Client(
transport=StreamableHttpTransport(streamable_http_server),
auth=HeadlessOAuth(
mcp_url=streamable_http_server,
scopes=["read", "write"],
authorization_server=server_base_url,
),
)
async with client:
assert await client.ping()
class TestOAuthGeneratorCleanup:
"""Tests for OAuth async generator cleanup (issue #2643).