mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 21:14:17 +02:00
Add authorization_server parameter to OAuth client
🤖 Generated with Claude Code https://claude.ai/code/session_01RvrgfBGaKwaPuC3BqozvL5
This commit is contained in:
parent
3ec7cfa0d5
commit
c33776459b
3 changed files with 125 additions and 1 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue