Skip to content

Allow OAuthClientProvider to accept a pre-configured auth server URL #2121

@martimfasantos

Description

@martimfasantos

Description

Problem

OAuthClientProvider.async_auth_flow in mcp/client/auth/oauth2.py only triggers OAuth discovery when it receives a 401 response. There's no way to tell the client "I already know where the auth server is — skip straight to OASM discovery and token exchange."

This forces an unnecessary round-trip in environments where the authorization server URL is already known (config file, env var, service registry, etc.).

Current behavior

In OAuthClientProvider.__init__:

OAuthClientProvider(
    server_url="http://mcp-server:3002/mcp",
    client_metadata=OAuthClientMetadata(...),
    storage=my_storage,
    redirect_handler=...,
    callback_handler=...,
)

Then inside async_auth_flow (line ~500):

response = yield request  # unauthenticated

if response.status_code == 401:
    # Only NOW does it start discovery:
    # 1. Extract WWW-Authenticate
    # 2. Discover PRM (/.well-known/oauth-protected-resource)
    # 3. Set self.context.auth_server_url from PRM
    # 4. Discover OASM (/.well-known/oauth-authorization-server)
    # 5. Register client (DCR or CIMD)
    # 6. Authorization code + PKCE
    # 7. Retry with token

Steps 1–3 exist purely to resolve auth_server_url. When the caller already has that URL, they're wasted work.

Proposed change

Add an optional authorization_server_url parameter to OAuthClientProvider.__init__:

OAuthClientProvider(
    server_url="http://mcp-server:3002/mcp",
    client_metadata=OAuthClientMetadata(...),
    storage=my_storage,
    redirect_handler=...,
    callback_handler=...,
    authorization_server_url="http://auth-server:9000",  # NEW
)

When provided:

  • Set self.context.auth_server_url at init time
  • In async_auth_flow, skip the initial unauthenticated request and jump directly to OASM fetch → client registration → token exchange → send authenticated request

When omitted, behavior stays identical to today.

Rough implementation sketch

In OAuthContext:

@dataclass
class OAuthContext:
    # ... existing fields ...
    auth_server_url: str | None = None  # already exists, just needs to be settable at init

In OAuthClientProvider.__init__:

def __init__(self, ..., authorization_server_url: str | None = None):
    self.context = OAuthContext(...)
    if authorization_server_url:
        self.context.auth_server_url = authorization_server_url

In async_auth_flow, before response = yield request:

if self.context.auth_server_url and not self.context.is_token_valid():
    # Auth server already known — go straight to OASM + token exchange
    # (skip sending unauthenticated request and waiting for 401)
    ...

Why this matters

  • Latency — One fewer round-trip before the client can do real work.
  • Controlled environments — Enterprise setups always know their auth server. Discovery via 401 adds no value there.
  • Complements existing featuresclient_metadata_url (CIMD) already lets you skip DCR. This is the same idea applied one step earlier in the chain.

Alternatives considered

Approach Limitation
CIMD (client_metadata_url) Skips DCR (step 5), but the 401 trigger + PRM discovery still happens
Pre-populating TokenStorage We don't necessarily manage the TokenStorage
Using httpx.Auth with a static Bearer token Works, but loses the full OAuth lifecycle (refresh, re-auth, etc.)

References

  • OAuthClientProvidermcp/client/auth/oauth2.py line 217
  • OAuthContext.auth_server_url — already exists as a field (line ~107), just populated from PRM today
  • async_auth_flow — the 401 branch starts at line ~504
  • MCP spec — Authorization
  • RFC 9728 — Protected Resource Metadata
  • RFC 8414 — OAuth Authorization Server Metadata

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions