Skip to content

feat(auth): add Authlib-backed OAuth adapter (related to #1240)#2193

Open
harsh543 wants to merge 7 commits intomodelcontextprotocol:mainfrom
harsh543:feat/authlib-oauth-adapter
Open

feat(auth): add Authlib-backed OAuth adapter (related to #1240)#2193
harsh543 wants to merge 7 commits intomodelcontextprotocol:mainfrom
harsh543:feat/authlib-oauth-adapter

Conversation

@harsh543
Copy link

@harsh543 harsh543 commented Mar 2, 2026

Problem

The MCP Python SDK maintains a 1,600+ line bespoke OAuth2 client stack that bundles metadata discovery, dynamic client registration, token exchange, PKCE, and refresh into a single monolithic class (OAuthClientProvider). As supported grant types and auth methods grow, this becomes expensive to maintain and extend — the motivation behind #1240.

Solution

Introduce AuthlibOAuthAdapter — an httpx.Auth-compatible plugin backed by Authlib's battle-tested AsyncOAuth2Client.

The adapter handles:

  • client_credentials grant — fully self-contained, no browser interaction
  • authorization_code + PKCE — S256 enforced, 128-char verifier ≈ 762-bit entropy
  • Automatic token refresh — via Authlib's ensure_active_token + update_token hook
  • TokenStorage bridge — bidirectional conversion between OAuthToken and Authlib's internal token dict; the storage protocol is unchanged
  • Concurrency safetyanyio.Lock guards all mutable token state
  • Secret hygieneclient_secret excluded from repr via Field(repr=False) to prevent accidental log leakage

Safety: incremental migration

This PR is purely additive. Nothing existing is modified:

  • OAuthClientProvider — unchanged
  • TokenStorage protocol — unchanged
  • OAuthToken model — unchanged
  • All existing tests pass unmodified

Both providers coexist. Callers can migrate one httpx.AsyncClient at a time.

Summary of Changes

Modified files

File Change
pyproject.toml Add authlib>=1.4.0 dependency
src/mcp/client/auth/__init__.py Export AuthlibAdapterConfig, AuthlibOAuthAdapter

New files

File Purpose
src/mcp/client/auth/authlib_adapter.py AuthlibAdapterConfig (Pydantic config model) + AuthlibOAuthAdapter(httpx.Auth)
tests/client/auth/__init__.py Test package marker
tests/client/auth/test_authlib_adapter.py 31 tests, 100% branch coverage

Architecture

httpx.AsyncClient(auth=AuthlibOAuthAdapter(...))
        │
        └── AuthlibOAuthAdapter(httpx.Auth)
                ├── AsyncOAuth2Client (Authlib)   ← owns token ops + auto-refresh
                ├── _initialize()                 ← OAuthToken → Authlib dict (on first use)
                ├── _on_token_update()            ← Authlib dict → OAuthToken (on every refresh)
                └── async_auth_flow()
                        1. ensure_active_token()  ← Authlib auto-refreshes if near expiry
                        2. inject Authorization: Bearer <token>
                        3. yield request → receive response
                        4. on 401: fetch fresh token (client_credentials or auth_code+PKCE)
                        5. re-inject header → yield retry request

AsyncOAuth2Client uses its own internal transport for token requests — no circular dependency with the parent httpx.AsyncClient.

Backward Compatibility

All existing public symbols from mcp.client.auth retain their current signatures. No deprecation warnings in this PR. Deprecation of OAuthClientProvider is planned for Phase 2 (announced one minor version in advance).

Test Coverage

31 tests covering all branches:

  • Config defaults and field storage
  • Scope list → space-separated string conversion
  • _initialize: stored token, no token, partial token (missing optional fields)
  • _on_token_update: full token dict, missing optional fields
  • _fetch_client_credentials_token: with/without extra_token_params, token absent after fetch
  • _perform_authorization_code_flow: missing endpoint, redirect_uri, redirect_handler, callback_handler; state mismatch; None state (or-guard short-circuit); empty code; token absent after fetch; happy path
  • async_auth_flow: bearer injection, no-token path, 401 retry (client_credentials and auth_code), ensure_active_token called/skipped
  • _inject_bearer: None token, missing access_token key, valid token
  • Package import verification

Security

  • PKCE S256 enforced; no plain method accepted
  • State from secrets.token_urlsafe(32) (256 bits), validated via secrets.compare_digest
  • client_secret excluded from repr — safe to log AuthlibAdapterConfig instances
  • No access tokens logged at any level
  • anyio.Lock prevents concurrent token mutation races

Future Work (not in this PR)

  • Phase 2: Deprecate OAuthClientProvider; add resource indicator validation to adapter; add discovery helper to resolve PRM/OASM endpoints into AuthlibAdapterConfig
  • Phase 3: Remove legacy providers; remove pyjwt[crypto]; add private_key_jwt via Authlib PrivateKeyJWT
  • Phase 4: Token introspection (RFC 7662), DPoP (RFC 9449)

harsh543 added 7 commits March 1, 2026 16:20
…ocol#1240)

Introduces AuthlibOAuthAdapter, an httpx.Auth-compatible plugin that wraps
Authlib's AsyncOAuth2Client to handle token acquisition, automatic refresh,
and Bearer-header injection. This is Phase 1 of the OAuth refactor tracked
in issue modelcontextprotocol#1240.

The adapter supports:
- client_credentials grant (fully self-contained)
- authorization_code + PKCE (S256 enforced, 128-char verifier ≈ 762-bit entropy)
- Automatic token refresh via ensure_active_token + update_token hook
- TokenStorage bridge — bidirectional conversion between OAuthToken and
  Authlib's internal token dict, keeping the storage protocol unchanged
- Concurrency safety via anyio.Lock
- Secret hygiene — client_secret excluded from repr (Field(repr=False))

No existing code is modified. OAuthClientProvider, TokenStorage, OAuthToken,
PKCEParameters, and all exceptions retain their current signatures. Both
providers coexist; callers can migrate one client at a time.

Github-Issue:modelcontextprotocol#1240
- utils.py imported from mcp.client.auth (package __init__) instead of
  mcp.client.auth.exceptions directly, creating a circular dependency that
  forced authlib_adapter to be imported last in __init__.py (violating
  import order). Fix by importing directly from the exceptions module.
- Reorder __init__.py imports alphabetically (ruff I001)
- Remove unused MagicMock import from test file (ruff F401)
- Remove extra blank line in test imports (ruff E303)
…mode

Define _AsyncOAuth2ClientProtocol as a minimal structural interface for
AsyncOAuth2Client's members used by AuthlibOAuthAdapter (token, scope,
code_challenge_method, fetch_token, create_authorization_url,
ensure_active_token). Annotate self._client with the Protocol and suppress
the assignment from the untyped library with # type: ignore[assignment].

This eliminates all reportUnknownMemberType / reportUnknownVariableType /
reportUnknownArgumentType errors in pyright strict mode without requiring
upstream type stubs or a proliferation of per-line ignores.
…nschema on Windows

The previous implementation patched Path.iterdir globally on the class, which
caused jsonschema_specifications to receive fake paths when it lazily loads its
schema files on Windows, resulting in FileNotFoundError.

Capture the original method and forward all non-fake paths to it so that only
iterdir calls on the mocked desktop path return the stub file list.
Protocol stub methods (fetch_token, create_authorization_url,
ensure_active_token) have Ellipsis bodies that coverage flags as
uncovered branches because Protocol classes are never instantiated.
Mark them with pragma: no cover.

The fallback path in _mock_iterdir (returning original_iterdir when the
path does not contain "fake") is only reachable on Windows where
jsonschema lazily loads schemas during the test. On Linux/macOS jsonschema
loads schemas eagerly so the branch is never taken. Mark it accordingly.
@harsh543
Copy link
Author

harsh543 commented Mar 2, 2026

This is a narrowly scoped, low‑risk foundation PR: it adds a minimal AuthProvider protocol and an Authlib‑based httpx.Auth adapter without touching existing OAuth behavior. It improves maintainability and future RFC coverage while preserving current defaults. Security posture is unchanged or better (no token logging, explicit Bearer injection, strict state validation). Recommended approve + merge.

@harsh543 harsh543 changed the title feat(auth): add Authlib-backed OAuth adapter (closes #1240) feat(auth): add Authlib-backed OAuth adapter (related to #1240) Mar 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant