"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK.""" import inspect import httpx import pytest from pydantic import AnyUrl, ValidationError from docs_src.oauth_clients import tutorial001, tutorial002 from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage from mcp.client.auth.extensions.client_credentials import ( PrivateKeyJWTOAuthProvider, RFC7523OAuthClientProvider, static_assertion_provider, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from mcp.shared.exceptions import MCPDeprecationWarning # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] async def test_in_memory_storage_satisfies_the_token_storage_protocol() -> None: """tutorial001: `TokenStorage` is a Protocol: four async methods, no base class.""" storage: TokenStorage = tutorial001.InMemoryTokenStorage() assert await storage.get_tokens() is None assert await storage.get_client_info() is None async def test_storage_round_trips_tokens_and_client_info() -> None: """tutorial001: whatever the provider stores, it gets back: the whole persistence contract.""" storage = tutorial001.InMemoryTokenStorage() tokens = OAuthToken(access_token="at-123", refresh_token="rt-456", expires_in=3600, scope="user") client_info = OAuthClientInformationFull( client_id="generated-by-the-as", redirect_uris=[AnyUrl("http://localhost:3030/callback")], ) await storage.set_tokens(tokens) await storage.set_client_info(client_info) assert await storage.get_tokens() == tokens assert await storage.get_client_info() == client_info async def test_the_provider_is_an_httpx_auth() -> None: """tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP.""" assert isinstance(tutorial001.oauth, httpx.Auth) async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None: """tutorial001: `grant_types` and `response_types` default to code + refresh: nothing to set.""" metadata = tutorial001.oauth.context.client_metadata assert metadata.grant_types == ["authorization_code", "refresh_token"] assert metadata.response_types == ["code"] async def test_redirect_uris_is_required() -> None: """The `!!! check`: registration metadata is validated locally, before any network.""" with pytest.raises(ValidationError, match="redirect_uris\n Field required"): OAuthClientMetadata.model_validate({"client_name": "Bookshop Agent"}) async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytest.CaptureFixture[str]) -> None: """tutorial001: `redirect_handler` is the one place the authorization URL surfaces.""" await tutorial001.open_browser("https://auth.example.com/authorize?client_id=abc") assert capsys.readouterr().out == "Visit: https://auth.example.com/authorize?client_id=abc\n" async def test_client_credentials_provider_has_no_human_in_the_loop() -> None: """tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers.""" assert isinstance(tutorial002.oauth, OAuthClientProvider) assert isinstance(tutorial002.oauth, httpx.Auth) assert tutorial002.oauth.context.redirect_handler is None assert tutorial002.oauth.context.callback_handler is None async def test_client_credentials_provider_builds_its_own_metadata() -> None: """tutorial002: the grant is `client_credentials`, there is nothing to redirect to.""" metadata = tutorial002.oauth.context.client_metadata assert metadata.grant_types == ["client_credentials"] assert metadata.token_endpoint_auth_method == "client_secret_basic" assert metadata.redirect_uris is None assert metadata.scope == "user" async def test_the_three_remaining_keyword_arguments_have_defaults() -> None: """The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder.""" parameters = inspect.signature(OAuthClientProvider.__init__).parameters supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"] remainder = ["timeout", "client_metadata_url", "validate_resource_url"] assert list(parameters) == ["self", *supplied, *remainder] assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder) async def test_the_one_more_provider_is_private_key_jwt() -> None: """The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way.""" provider = PrivateKeyJWTOAuthProvider( server_url="http://localhost:8001/mcp", storage=tutorial002.InMemoryTokenStorage(), client_id="reporting-agent", assertion_provider=static_assertion_provider("a.prebuilt.jwt"), ) assert isinstance(provider, OAuthClientProvider) assert isinstance(provider, httpx.Auth) assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt" async def test_the_page_does_not_count_the_deprecated_provider() -> None: """Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction.""" with pytest.warns(MCPDeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"): RFC7523OAuthClientProvider( server_url="http://localhost:8001/mcp", client_metadata=tutorial001.oauth.context.client_metadata, storage=tutorial001.InMemoryTokenStorage(), ) async def test_every_oauth_error_is_an_oauth_flow_error() -> None: """Catch `OAuthFlowError` and you have caught registration and token failures too.""" assert issubclass(OAuthRegistrationError, OAuthFlowError) assert issubclass(OAuthTokenError, OAuthFlowError) async def test_not_everything_is_a_flow_error() -> None: """A bad argument is a `ValueError`, not an `OAuthFlowError`: the page says *OAuth* failures.""" with pytest.raises(ValueError, match="client_metadata_url must be a valid HTTPS URL") as exc_info: OAuthClientProvider( server_url="http://localhost:8001/mcp", client_metadata=tutorial001.oauth.context.client_metadata, storage=tutorial001.InMemoryTokenStorage(), client_metadata_url="http://not-https.example/client.json", ) assert not isinstance(exc_info.value, OAuthFlowError)