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:
+147
-2
@@ -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):
|
||||
...
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user