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):
...
```
@@ -0,0 +1,45 @@
"""Minimal bearer-token authentication example.
Demonstrates the simplest possible MCP client authentication: a bearer token
from an environment variable. `BearerAuth` is an `httpx.Auth` implementation
that calls `token()` before every request and optionally `on_unauthorized()`
on 401 before retrying once.
For full OAuth flows (authorization code, PKCE, dynamic client registration),
see `oauth_client.py` and use `OAuthClientProvider` instead — both plug into
the same `auth` parameter.
Run against any MCP server that accepts bearer tokens:
MCP_TOKEN=your-token MCP_SERVER_URL=http://localhost:8001/mcp uv run bearer-auth-client
"""
import asyncio
import os
from mcp.client import Client
from mcp.client.auth import BearerAuth
async def main() -> None:
server_url = os.environ.get("MCP_SERVER_URL", "http://localhost:8001/mcp")
token = os.environ.get("MCP_TOKEN")
if not token:
raise SystemExit("Set MCP_TOKEN to your bearer token")
# token() is called before every request. With no on_unauthorized handler,
# a 401 raises UnauthorizedError immediately — no retry.
auth = BearerAuth(token)
async with Client(server_url, auth=auth) as client:
tools = await client.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
def run() -> None:
asyncio.run(main())
if __name__ == "__main__":
run()
+1
View File
@@ -21,4 +21,5 @@ completion-client = "clients.completion_client:main"
direct-execution-server = "servers.direct_execution:main"
display-utilities-client = "clients.display_utilities:main"
oauth-client = "clients.oauth_client:run"
bearer-auth-client = "clients.bearer_auth_client:run"
elicitation-client = "clients.url_elicitation_client:run"
+18 -3
View File
@@ -1,9 +1,19 @@
"""OAuth2 Authentication implementation for HTTPX.
"""Client-side authentication for MCP HTTP transports.
Implements authorization code flow with PKCE and automatic token refresh.
Two `httpx.Auth` implementations are provided:
- `BearerAuth` — minimal two-method provider (`token()` + optional
`on_unauthorized()`) 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 and plug into the same `auth` parameter.
"""
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.bearer import BearerAuth, TokenSource, UnauthorizedContext, UnauthorizedHandler
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError, UnauthorizedError
from mcp.client.auth.oauth2 import (
OAuthClientProvider,
PKCEParameters,
@@ -11,10 +21,15 @@ from mcp.client.auth.oauth2 import (
)
__all__ = [
"BearerAuth",
"OAuthClientProvider",
"OAuthFlowError",
"OAuthRegistrationError",
"OAuthTokenError",
"PKCEParameters",
"TokenSource",
"TokenStorage",
"UnauthorizedContext",
"UnauthorizedError",
"UnauthorizedHandler",
]
+175
View File
@@ -0,0 +1,175 @@
"""Minimal bearer-token authentication for MCP HTTP transports.
Provides `BearerAuth`, a lightweight `httpx.Auth` implementation with a two-method
contract (`token()` and `on_unauthorized()`). Use this when you have a token from
an external source — API keys, gateway-managed tokens, service accounts, enterprise
SSO pipelines — and don't need the full OAuth authorization-code flow.
For OAuth flows (authorization code with PKCE, dynamic client registration, token
refresh), use `OAuthClientProvider` instead. Both are `httpx.Auth` subclasses and
plug into the same `auth` parameter.
"""
from __future__ import annotations
import inspect
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
from dataclasses import dataclass
import httpx
from mcp.client.auth.exceptions import UnauthorizedError
TokenSource = str | Callable[[], str | None] | Callable[[], Awaitable[str | None]]
"""A bearer-token source: a static string, or a sync/async callable returning one."""
UnauthorizedHandler = Callable[["UnauthorizedContext"], Awaitable[None]]
"""Async handler invoked when the server responds with 401."""
@dataclass
class UnauthorizedContext:
"""Context passed to `on_unauthorized` when the server responds with 401.
Handlers can inspect `response.headers["WWW-Authenticate"]` for resource metadata
URLs and scope hints per RFC 6750 §3 and RFC 9728, then refresh credentials before
the single retry.
"""
response: httpx.Response
"""The 401 response. Body has been read — `response.text` / `response.json()` are safe."""
request: httpx.Request
"""The request that was rejected. `request.url` is the MCP server URL."""
class BearerAuth(httpx.Auth):
"""Minimal bearer-token authentication for MCP HTTP transports.
Implements `httpx.Auth` with a two-method contract:
- `token()` — called before every request to obtain the current bearer token.
- `on_unauthorized()` — called when the server responds with 401, giving the
provider a chance to refresh credentials before the transport retries once.
For static tokens (API keys, pre-provisioned credentials)::
auth = BearerAuth("my-api-key")
For dynamic tokens (read from environment, cache, or external service)::
auth = BearerAuth(lambda: os.environ.get("MCP_TOKEN"))
auth = BearerAuth(get_token_async) # async callable
For custom 401 handling (token refresh, re-authentication signal)::
async def refresh(ctx: UnauthorizedContext) -> None:
await my_token_cache.invalidate()
auth = BearerAuth(get_token, on_unauthorized=refresh)
Subclass and override `token()` / `on_unauthorized()` for more complex providers.
For full OAuth 2.1 flows (authorization code with PKCE, discovery, registration),
use `OAuthClientProvider` — both are `httpx.Auth` subclasses and accepted by the
same `auth` parameter on transports.
"""
def __init__(
self,
token: TokenSource | None = None,
on_unauthorized: UnauthorizedHandler | None = None,
) -> None:
"""Initialize bearer-token authentication.
Args:
token: The bearer token source. A static string, a sync callable
returning `str | None`, or an async callable returning `str | None`.
Called before every request. If `None`, subclasses must override
`token()`.
on_unauthorized: Optional async handler called when the server responds
with 401. After the handler returns, `token()` is called again and
the request retried once. If not provided, 401 raises
`UnauthorizedError` immediately. If the retry also gets 401,
`UnauthorizedError` is raised.
"""
self._token = token
self._on_unauthorized = on_unauthorized
async def token(self) -> str | None:
"""Return the current bearer token, or `None` if unavailable.
Called before every request. The default implementation resolves the
`token` argument passed to `__init__` (string, sync callable, or async
callable). Override for custom retrieval logic.
Implementations should be fast — return a cached value and refresh in the
background rather than blocking on network calls here.
"""
src = self._token
if src is None or isinstance(src, str):
return src
result = src()
if inspect.isawaitable(result):
return await result
return result
async def on_unauthorized(self, context: UnauthorizedContext) -> None:
"""Handle a 401 response. Called once before the single retry.
The default implementation delegates to the `on_unauthorized` callable
passed to `__init__`, or raises `UnauthorizedError` if none was provided.
Override to implement custom refresh logic.
Implementations should refresh tokens, clear caches, or signal the host
application — whatever is needed so the next `token()` call returns a
valid token. Raise an exception to abort without retrying (e.g., when
interactive user action is required before a retry could succeed).
"""
if self._on_unauthorized is None:
www_auth = context.response.headers.get("WWW-Authenticate", "")
hint = f" (WWW-Authenticate: {www_auth})" if www_auth else ""
raise UnauthorizedError(
f"Server at {context.request.url} returned 401 Unauthorized{hint}; "
"no on_unauthorized handler configured"
)
await self._on_unauthorized(context)
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
"""Not supported — `BearerAuth` is async-only.
Raises:
RuntimeError: Always. Use `httpx.AsyncClient`, not `httpx.Client`.
"""
raise RuntimeError(
"BearerAuth is async-only because token() and on_unauthorized() are "
"coroutines; use httpx.AsyncClient, not httpx.Client"
)
yield request # pragma: no cover — unreachable; makes this a generator for type compat
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
"""httpx auth-flow integration.
Each request gets a fresh generator instance, so retry state is naturally
scoped per-operation — there is no shared retry counter to reset or leak
across concurrent requests.
"""
await self._apply_token(request)
response = yield request
if response.status_code == 401:
await response.aread()
await self.on_unauthorized(UnauthorizedContext(response=response, request=request))
await self._apply_token(request)
response = yield request
if response.status_code == 401:
raise UnauthorizedError(f"Server at {request.url} returned 401 Unauthorized after re-authentication")
async def _apply_token(self, request: httpx.Request) -> None:
token = await self.token()
if token:
request.headers["Authorization"] = f"Bearer {token}"
else:
request.headers.pop("Authorization", None)
+9
View File
@@ -1,3 +1,12 @@
class UnauthorizedError(Exception):
"""Raised when the server responds with 401 and the auth provider cannot recover.
Raised by `BearerAuth` when no `on_unauthorized` handler is configured, or when
the single retry after `on_unauthorized` also receives 401. Callers can catch
this to trigger an interactive re-authentication flow or surface a login prompt.
"""
class OAuthFlowError(Exception):
"""Base exception for OAuth flow errors."""
+9 -1
View File
@@ -6,6 +6,8 @@ from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any
import httpx
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import Transport
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
@@ -95,6 +97,12 @@ class Client:
elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests."""
auth: httpx.Auth | None = None
"""Optional HTTP authentication provider (e.g., `BearerAuth` or `OAuthClientProvider`).
Only used when `server` is a URL string. Ignored for in-memory and custom transports.
"""
_session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
_transport: Transport = field(init=False)
@@ -103,7 +111,7 @@ class Client:
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server)
self._transport = streamable_http_client(self.server, auth=self.auth)
else:
self._transport = self.server
+17 -2
View File
@@ -511,6 +511,7 @@ async def streamable_http_client(
url: str,
*,
http_client: httpx.AsyncClient | None = None,
auth: httpx.Auth | None = None,
terminate_on_close: bool = True,
) -> AsyncGenerator[TransportStreams, None]:
"""Client transport for StreamableHTTP.
@@ -519,7 +520,12 @@ async def streamable_http_client(
url: The MCP server endpoint URL.
http_client: Optional pre-configured httpx.AsyncClient. If None, a default
client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
timeouts, or other HTTP settings, create an httpx.AsyncClient and pass it here.
Mutually exclusive with `auth`.
auth: Optional httpx.Auth provider (e.g., `BearerAuth` or `OAuthClientProvider`).
Shortcut for creating a default http_client with this auth configured.
Mutually exclusive with `http_client` — to combine auth with custom HTTP
settings, pass `http_client=httpx.AsyncClient(auth=..., ...)` instead.
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
Yields:
@@ -527,16 +533,25 @@ async def streamable_http_client(
- read_stream: Stream for reading messages from the server
- write_stream: Stream for sending messages to the server
Raises:
ValueError: If both `http_client` and `auth` are provided.
Example:
See examples/snippets/clients/ for usage patterns.
"""
if http_client is not None and auth is not None:
raise ValueError(
"Pass either `http_client` or `auth`, not both. "
"To combine auth with custom HTTP settings, set auth on the httpx.AsyncClient."
)
# Determine if we need to create and manage the client
client_provided = http_client is not None
client = http_client
if client is None:
# Create default client with recommended MCP timeouts
client = create_mcp_http_client()
client = create_mcp_http_client(auth=auth)
transport = StreamableHTTPTransport(url)
+1 -1
View File
@@ -91,7 +91,7 @@ def create_mcp_http_client(
kwargs["headers"] = headers
# Handle authentication
if auth is not None: # pragma: no cover
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
+392
View File
@@ -0,0 +1,392 @@
"""Tests for BearerAuth — the minimal two-method bearer-token provider."""
from __future__ import annotations
import httpx
import pytest
from mcp.client.auth import BearerAuth, UnauthorizedContext, UnauthorizedError
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import create_mcp_http_client
pytestmark = pytest.mark.anyio
def make_request(url: str = "https://api.example.com/mcp") -> httpx.Request:
return httpx.Request("POST", url)
def make_response(status: int, *, request: httpx.Request, www_auth: str | None = None) -> httpx.Response:
headers = {"WWW-Authenticate": www_auth} if www_auth else {}
return httpx.Response(status, headers=headers, request=request)
# --- token() resolution ------------------------------------------------------
async def test_static_string_token_sets_authorization_header():
auth = BearerAuth("my-api-key")
request = make_request()
flow = auth.async_auth_flow(request)
sent = await flow.__anext__()
assert sent.headers["Authorization"] == "Bearer my-api-key"
with pytest.raises(StopAsyncIteration):
await flow.asend(make_response(200, request=request))
async def test_sync_callable_token_resolved_per_request():
calls = 0
def get_token() -> str:
nonlocal calls
calls += 1
return f"token-{calls}"
auth = BearerAuth(get_token)
for expected in ("token-1", "token-2"):
request = make_request()
flow = auth.async_auth_flow(request)
sent = await flow.__anext__()
assert sent.headers["Authorization"] == f"Bearer {expected}"
with pytest.raises(StopAsyncIteration):
await flow.asend(make_response(200, request=request))
assert calls == 2
async def test_async_callable_token_awaited():
async def get_token() -> str:
return "async-token"
auth = BearerAuth(get_token)
request = make_request()
flow = auth.async_auth_flow(request)
sent = await flow.__anext__()
assert sent.headers["Authorization"] == "Bearer async-token"
async def test_none_token_omits_authorization_header():
auth = BearerAuth(lambda: None)
request = make_request()
flow = auth.async_auth_flow(request)
sent = await flow.__anext__()
assert "Authorization" not in sent.headers
async def test_no_token_source_omits_authorization_header():
auth = BearerAuth()
request = make_request()
flow = auth.async_auth_flow(request)
sent = await flow.__anext__()
assert "Authorization" not in sent.headers
# --- 401 handling: no on_unauthorized handler --------------------------------
async def test_401_without_handler_raises_unauthorized_error():
auth = BearerAuth("rejected-token")
request = make_request()
flow = auth.async_auth_flow(request)
await flow.__anext__()
with pytest.raises(UnauthorizedError, match="401 Unauthorized"):
await flow.asend(make_response(401, request=request))
async def test_401_without_handler_includes_www_authenticate_in_error():
auth = BearerAuth("rejected-token")
request = make_request()
flow = auth.async_auth_flow(request)
await flow.__anext__()
www_auth = 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"'
with pytest.raises(UnauthorizedError, match="WWW-Authenticate"):
await flow.asend(make_response(401, request=request, www_auth=www_auth))
# --- 401 handling: with on_unauthorized handler ------------------------------
async def test_401_with_handler_retries_once_with_fresh_token():
current = "old-token"
token_calls = 0
handler_calls = 0
def get_token() -> str:
nonlocal token_calls
token_calls += 1
return current
async def refresh(ctx: UnauthorizedContext) -> None:
nonlocal current, handler_calls
handler_calls += 1
assert ctx.response.status_code == 401
assert ctx.request.url == "https://api.example.com/mcp"
current = "new-token"
auth = BearerAuth(get_token, on_unauthorized=refresh)
request = make_request()
flow = auth.async_auth_flow(request)
first = await flow.__anext__()
assert first.headers["Authorization"] == "Bearer old-token"
retry = await flow.asend(make_response(401, request=request))
assert retry.headers["Authorization"] == "Bearer new-token"
with pytest.raises(StopAsyncIteration):
await flow.asend(make_response(200, request=request))
assert token_calls == 2
assert handler_calls == 1
async def test_401_on_retry_raises_unauthorized_error():
async def noop(ctx: UnauthorizedContext) -> None:
pass
auth = BearerAuth("still-bad", on_unauthorized=noop)
request = make_request()
flow = auth.async_auth_flow(request)
await flow.__anext__()
await flow.asend(make_response(401, request=request))
with pytest.raises(UnauthorizedError, match="after re-authentication"):
await flow.asend(make_response(401, request=request))
async def test_handler_exception_propagates_without_retry():
token_calls = 0
def get_token() -> str:
nonlocal token_calls
token_calls += 1
return "token"
async def signal_and_abort(ctx: UnauthorizedContext) -> None:
raise RuntimeError("user action required")
auth = BearerAuth(get_token, on_unauthorized=signal_and_abort)
request = make_request()
flow = auth.async_auth_flow(request)
await flow.__anext__()
with pytest.raises(RuntimeError, match="user action required"):
await flow.asend(make_response(401, request=request))
assert token_calls == 1 # no retry attempted
async def test_retry_state_is_per_operation_not_shared():
"""Each request gets a fresh generator, so a failed retry on one request
doesn't prevent retry on the next. This is the httpx.Auth generator pattern's
natural per-operation isolation — no instance state to reset or leak."""
attempts: list[str] = []
async def track(ctx: UnauthorizedContext) -> None:
attempts.append("refresh")
auth = BearerAuth("token", on_unauthorized=track)
# First request: 401 → retry → 401 → UnauthorizedError
request1 = make_request()
flow1 = auth.async_auth_flow(request1)
await flow1.__anext__()
await flow1.asend(make_response(401, request=request1))
with pytest.raises(UnauthorizedError):
await flow1.asend(make_response(401, request=request1))
# Second request: fresh generator, retry allowed again
request2 = make_request()
flow2 = auth.async_auth_flow(request2)
await flow2.__anext__()
retry = await flow2.asend(make_response(401, request=request2))
assert retry.headers["Authorization"] == "Bearer token"
with pytest.raises(StopAsyncIteration):
await flow2.asend(make_response(200, request=request2))
assert attempts == ["refresh", "refresh"]
async def test_retry_clears_stale_header_when_token_becomes_none():
"""If token() returns None on retry, the stale Authorization header from the
first attempt must be cleared — not silently re-sent."""
tokens = iter(["first", None])
async def refresh(ctx: UnauthorizedContext) -> None:
pass
auth = BearerAuth(lambda: next(tokens), on_unauthorized=refresh)
request = make_request()
flow = auth.async_auth_flow(request)
first = await flow.__anext__()
assert first.headers["Authorization"] == "Bearer first"
retry = await flow.asend(make_response(401, request=request))
assert "Authorization" not in retry.headers
async def test_handler_can_read_response_body():
"""Response body is read before on_unauthorized, so handlers can inspect it
even when the transport uses streaming (httpx stream() mode)."""
captured: list[str] = []
async def inspect_body(ctx: UnauthorizedContext) -> None:
captured.append(ctx.response.text)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "invalid_token"})
auth = BearerAuth("bad", on_unauthorized=inspect_body)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
with pytest.raises(UnauthorizedError):
async with client.stream("POST", "https://api.example.com/mcp"):
pass # pragma: no cover — auth flow raises before stream body opens
assert captured == ['{"error":"invalid_token"}']
async def test_handler_receives_www_authenticate_header():
captured: list[str] = []
async def inspect(ctx: UnauthorizedContext) -> None:
captured.append(ctx.response.headers.get("WWW-Authenticate", ""))
auth = BearerAuth("token", on_unauthorized=inspect)
request = make_request()
flow = auth.async_auth_flow(request)
await flow.__anext__()
www_auth = 'Bearer scope="read write", resource_metadata="https://example.com/prm"'
await flow.asend(make_response(401, request=request, www_auth=www_auth))
assert captured == [www_auth]
# --- subclassing -------------------------------------------------------------
async def test_subclass_override_token_and_on_unauthorized():
class RefreshingAuth(BearerAuth):
def __init__(self) -> None:
super().__init__()
self.current = "initial"
self.refreshed = False
async def token(self) -> str | None:
return self.current
async def on_unauthorized(self, context: UnauthorizedContext) -> None:
self.current = "refreshed"
self.refreshed = True
auth = RefreshingAuth()
request = make_request()
flow = auth.async_auth_flow(request)
first = await flow.__anext__()
assert first.headers["Authorization"] == "Bearer initial"
retry = await flow.asend(make_response(401, request=request))
assert retry.headers["Authorization"] == "Bearer refreshed"
assert auth.refreshed is True
# --- httpx integration (wire-level) ------------------------------------------
async def test_e2e_with_mock_transport_sets_header():
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
return httpx.Response(200, json={"ok": True})
auth = BearerAuth("wire-token")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
response = await client.post("https://api.example.com/mcp", json={})
assert response.status_code == 200
assert captured[0].headers["Authorization"] == "Bearer wire-token"
async def test_e2e_with_mock_transport_retries_on_401():
seen_tokens: list[str | None] = []
def handler(request: httpx.Request) -> httpx.Response:
token = request.headers.get("Authorization")
seen_tokens.append(token)
if token == "Bearer old":
return httpx.Response(401, headers={"WWW-Authenticate": "Bearer"})
return httpx.Response(200, json={"ok": True})
current = "old"
async def refresh(ctx: UnauthorizedContext) -> None:
nonlocal current
current = "new"
auth = BearerAuth(lambda: current, on_unauthorized=refresh)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
response = await client.post("https://api.example.com/mcp", json={})
assert response.status_code == 200
assert seen_tokens == ["Bearer old", "Bearer new"]
async def test_e2e_unauthorized_error_propagates():
def always_401(request: httpx.Request) -> httpx.Response:
return httpx.Response(401)
auth = BearerAuth("rejected")
async with httpx.AsyncClient(transport=httpx.MockTransport(always_401), auth=auth) as client:
with pytest.raises(UnauthorizedError):
await client.post("https://api.example.com/mcp", json={})
# --- sync client guard -------------------------------------------------------
def test_sync_client_raises_clear_error():
auth = BearerAuth("token")
with pytest.raises(RuntimeError, match="async-only"):
with httpx.Client(auth=auth) as client:
client.get("https://api.example.com/mcp")
# --- streamable_http_client integration --------------------------------------
async def test_streamable_http_client_rejects_both_auth_and_http_client():
auth = BearerAuth("token")
http_client = httpx.AsyncClient()
with pytest.raises(ValueError, match="either `http_client` or `auth`"):
async with streamable_http_client("https://example.com/mcp", auth=auth, http_client=http_client):
pass # pragma: no cover
await http_client.aclose()
async def test_create_mcp_http_client_passes_auth():
auth = BearerAuth("factory-token")
async with create_mcp_http_client(auth=auth) as client:
assert client.auth is auth
+13 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from unittest.mock import patch
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
@@ -307,7 +308,18 @@ async def test_complete_with_prompt_reference(simple_server: Server):
def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp", auth=None)
def test_client_with_url_passes_auth_to_transport():
class FakeAuth(httpx.Auth):
def auth_flow(self, request: httpx.Request):
yield request # pragma: no cover
auth = FakeAuth()
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp", auth=auth)
mock.assert_called_once_with("http://localhost:8000/mcp", auth=auth)
async def test_client_uses_transport_directly(app: MCPServer):