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).