Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs/clients/auth/oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` —

- **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings
- **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"`
- **`client_id`** (`str`, optional): Pre-registered OAuth client ID. When provided, skips Dynamic Client Registration entirely. See [Pre-Registered Clients](#pre-registered-clients)
- **`client_secret`** (`str`, optional): OAuth client secret for pre-registered clients. Optional — public clients that rely on PKCE can omit this
- **`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
Expand All @@ -74,7 +76,7 @@ The client first checks the configured `token_storage` backend for existing, val
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`.
</Step>
<Step title="Client Registration">
If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. Alternatively, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity instead of registering.
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.
</Step>
<Step title="Local Callback Server">
A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:<port>/callback`) acts as the `redirect_uri` for the OAuth flow.
Expand Down Expand Up @@ -152,3 +154,33 @@ async with Client(
```

See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.

## Pre-Registered Clients

<VersionBadge version="3.0.0" />

Some OAuth servers don't support Dynamic Client Registration — the MCP spec explicitly makes DCR optional. If your client has been pre-registered with the server (you already have a `client_id` and optionally a `client_secret`), you can provide them directly to skip DCR entirely.

```python
from fastmcp import Client
from fastmcp.client.auth import OAuth

async with Client(
"https://mcp-server.example.com/mcp",
auth=OAuth(
client_id="my-registered-client-id",
client_secret="my-client-secret",
),
) as client:
await client.ping()
```

Public clients that rely on PKCE for security can omit `client_secret`:

```python
oauth = OAuth(client_id="my-public-client-id")
```

<Note>
When using pre-registered credentials, the client will not attempt Dynamic Client Registration. If the server rejects the credentials, the error is surfaced immediately rather than falling back to DCR.
</Note>
44 changes: 42 additions & 2 deletions src/fastmcp/client/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ def __init__(
additional_client_metadata: dict[str, Any] | None = None,
callback_port: int | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
# Alternative to dynamic client registration:
# --- Clients host a static JSON document at an HTTPS URL ---
client_metadata_url: str | None = None,
# --- OR clients provide full client information ---
client_id: str | None = None,
client_secret: str | None = None,
):
"""
Initialize OAuth client provider for an MCP server.
Expand All @@ -173,6 +178,9 @@ def __init__(
provided, this URL is used as the client_id instead of performing
Dynamic Client Registration. Must be an HTTPS URL with a non-root
path (e.g. "https://myapp.example.com/oauth/client.json").
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)
"""
# Store config for deferred binding if mcp_url not yet known
self._scopes = scopes
Expand All @@ -181,6 +189,9 @@ def __init__(
self._additional_client_metadata = additional_client_metadata
self._callback_port = callback_port
self._client_metadata_url = client_metadata_url
self._client_id = client_id
self._client_secret = client_secret
self._static_client_info = None
self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
self._bound = False

Expand Down Expand Up @@ -218,6 +229,23 @@ def _bind(self, mcp_url: str) -> None:
**(self._additional_client_metadata or {}),
)

if self._client_id:
# Create the full static client info directly which will avoid DCR.
# Spread client_metadata so redirect_uris, grant_types, response_types,
# scope, etc. are included — servers may validate these fields.
metadata = client_metadata.model_dump(exclude_none=True)
# Default token_endpoint_auth_method based on whether a secret is
# provided, unless the caller already set it via additional_client_metadata.
if "token_endpoint_auth_method" not in metadata:
metadata["token_endpoint_auth_method"] = (
"client_secret_post" if self._client_secret else "none"
)
self._static_client_info = OAuthClientInformationFull(
client_id=self._client_id,
client_secret=self._client_secret,
**metadata,
)

token_storage = self._token_storage or MemoryStore()

if isinstance(token_storage, MemoryStore):
Expand All @@ -230,6 +258,7 @@ def _bind(self, mcp_url: str) -> None:
stacklevel=2,
)

# Use full URL for token storage to properly separate tokens per MCP endpoint
self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter(
async_key_value=token_storage, server_url=mcp_url
)
Expand All @@ -249,10 +278,12 @@ def _bind(self, mcp_url: str) -> None:

async def _initialize(self) -> None:
"""Load stored tokens and client info, properly setting token expiry."""
# Call parent's _initialize to load tokens and client info
await super()._initialize()

# If tokens were loaded and have expires_in, update the context's token_expiry_time
if self._static_client_info is not None:
Comment thread
martimfasantos marked this conversation as resolved.
self.context.client_info = self._static_client_info
await self.token_storage_adapter.set_client_info(self._static_client_info)

if self.context.current_tokens and self.context.current_tokens.expires_in:
self.context.update_token_expiry(self.context.current_tokens)

Expand Down Expand Up @@ -342,6 +373,15 @@ async def async_auth_flow(
break

except ClientNotFoundError:
# Static credentials are fixed — retrying won't help. Surface the
# error so the user can correct their client_id / client_secret.
if self._static_client_info is not None:
raise ClientNotFoundError(
"OAuth server rejected the static client credentials. "
"Verify that the client_id (and client_secret, if provided) "
"are correct and that the client is registered with the server."
) from None

logger.debug(
"OAuth client not found on server, clearing cache and retrying..."
)
Expand Down
Loading