feat(auth): add BearerAuth for minimal bearer-token authentication

Adds BearerAuth, a lightweight httpx.Auth implementation with a two-method
contract (token() + optional on_unauthorized()). This covers the many deployments
that don't fit the OAuth authorization-code flow: gateway/proxy patterns, service
accounts with pre-provisioned tokens, enterprise SSO where tokens come from a
separate pipeline.

For simple cases, it's a one-liner:

    auth = BearerAuth("my-api-key")
    async with Client(url, auth=auth) as client: ...

For token rotation, pass a callable (sync or async):

    auth = BearerAuth(lambda: os.environ.get("MCP_TOKEN"))

For custom 401 handling, pass or override on_unauthorized(). The handler receives
the 401 response (body pre-read, WWW-Authenticate available), refreshes
credentials, and the request retries once. Retry state is naturally per-operation
via httpx's generator-per-request pattern — no shared counter to reset or leak.

OAuthClientProvider is unchanged. Both are httpx.Auth subclasses and plug into
the same auth parameter — no adapter or type guard needed.

Also adds:
- auth= convenience parameter on streamable_http_client() and Client (mutually
  exclusive with http_client=, raises ValueError if both given)
- UnauthorizedError exception for unrecoverable 401s
- sync_auth_flow override that raises a clear error instead of silently no-oping
- docs/authorization.md with bearer-token and OAuth sections
- examples/snippets/clients/bearer_auth_client.py
- 21 tests covering generator-driven unit tests and httpx wire-level integration
This commit is contained in:
Max Isbey
2026-03-24 13:46:08 +00:00
parent 92c693bb73
commit c85501ac65
11 changed files with 827 additions and 10 deletions
+147 -2
View File
@@ -1,5 +1,150 @@
# Authorization
!!! warning "Under Construction"
MCP HTTP transports authenticate via `httpx.Auth`. The SDK provides two
implementations that plug into the same `auth` parameter:
This page is currently being written. Check back soon for complete documentation.
- **`BearerAuth`** — a minimal two-method provider for API keys, gateway-managed
tokens, service accounts, or any scenario where the token comes from an
external pipeline.
- **`OAuthClientProvider`** — full OAuth 2.1 authorization-code flow with PKCE,
Protected Resource Metadata discovery (RFC 9728), dynamic client registration,
and automatic token refresh.
Both are `httpx.Auth` subclasses. Pass either to `Client(url, auth=...)`,
`streamable_http_client(url, auth=...)`, or directly to
`httpx.AsyncClient(auth=...)`.
## Bearer tokens
For a static token (API key, pre-provisioned credential):
```python
from mcp.client import Client
from mcp.client.auth import BearerAuth
async with Client("https://api.example.com/mcp", auth=BearerAuth("my-api-key")) as client:
tools = await client.list_tools()
```
For a dynamic token (environment variable, cache, external service), pass a
callable — sync or async:
```python
import os
from mcp.client.auth import BearerAuth
auth = BearerAuth(lambda: os.environ.get("MCP_TOKEN"))
```
`token()` is called before every request, so the callable can return a freshly
rotated value each time. Keep it fast — return a cached value and refresh in the
background rather than blocking on network calls.
### Handling 401
By default, `BearerAuth` raises `UnauthorizedError` immediately on 401. To
refresh credentials and retry once, pass an `on_unauthorized` handler:
```python
from mcp.client.auth import BearerAuth, UnauthorizedContext
token_cache = TokenCache()
async def refresh(ctx: UnauthorizedContext) -> None:
# ctx.response.headers["WWW-Authenticate"] has scope/resource_metadata hints
await token_cache.invalidate()
auth = BearerAuth(token_cache.get, on_unauthorized=refresh)
```
After `on_unauthorized` returns, `token()` is called again and the request is
retried once. If the retry also gets 401, `UnauthorizedError` is raised. Retry
state is scoped per-request — a failed retry on one request does not block
retries on subsequent requests.
To abort without retrying (for example, when interactive user action is
required), raise from the handler:
```python
async def signal_host(ctx: UnauthorizedContext) -> None:
ui.show_reauth_prompt()
raise UnauthorizedError("User action required before retry")
```
### Subclassing
For more complex providers, subclass `BearerAuth` and override `token()` and
`on_unauthorized()`:
```python
from mcp.client.auth import BearerAuth, UnauthorizedContext
class MyAuth(BearerAuth):
async def token(self) -> str | None:
return await self._store.get_access_token()
async def on_unauthorized(self, context: UnauthorizedContext) -> None:
await self._store.refresh()
```
## OAuth 2.1
For the full OAuth authorization-code flow with PKCE — including Protected
Resource Metadata discovery, authorization server metadata discovery, dynamic
client registration, and automatic token refresh — use `OAuthClientProvider`:
```python
import httpx
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientMetadata
auth = OAuthClientProvider(
server_url="https://api.example.com",
client_metadata=OAuthClientMetadata(
client_name="My MCP Client",
redirect_uris=["http://localhost:3000/callback"],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
),
storage=my_token_storage,
redirect_handler=open_browser,
callback_handler=wait_for_callback,
)
async with streamable_http_client("https://api.example.com/mcp", auth=auth) as (read, write):
...
```
See `examples/snippets/clients/oauth_client.py` for a complete working example.
### Non-interactive grants
For machine-to-machine authentication without a browser redirect, use the
extensions in `mcp.client.auth.extensions`:
- `ClientCredentialsOAuthProvider``client_credentials` grant with client ID
and secret
- `PrivateKeyJWTOAuthProvider``client_credentials` with `private_key_jwt`
client authentication (RFC 7523)
## Custom `httpx.Auth`
Any `httpx.Auth` implementation works. To combine authentication with custom
HTTP settings (headers, timeouts, proxies), configure an `httpx.AsyncClient`
directly:
```python
import httpx
from mcp.client.streamable_http import streamable_http_client
http_client = httpx.AsyncClient(
auth=my_auth,
headers={"X-Custom": "value"},
timeout=httpx.Timeout(60.0),
)
async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read, write):
...
```