49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
133 lines
6.4 KiB
Python
133 lines
6.4 KiB
Python
"""`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)
|