diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 84fbe2164..27b00acfb 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -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. -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. 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 + + + +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. + + +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. + + +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 diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index a4d1e9c77..331cb89bd 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -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: diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py index 20a335e8d..8546eccd4 100644 --- a/tests/client/auth/test_oauth_client.py +++ b/tests/client/auth/test_oauth_client.py @@ -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).