-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathtest_oauth_clients.py
More file actions
130 lines (100 loc) · 6.36 KB
/
Copy pathtest_oauth_clients.py
File metadata and controls
130 lines (100 loc) · 6.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx2
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 (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
static_assertion_provider,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
# 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 httpx2, not into MCP."""
assert isinstance(tutorial001.oauth, httpx2.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 `httpx2.Auth`, minus the handlers."""
assert isinstance(tutorial002.oauth, OAuthClientProvider)
assert isinstance(tutorial002.oauth, httpx2.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"
@pytest.mark.parametrize("provider_class", [ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider])
async def test_issuer_is_an_optional_keyword_on_both_machine_to_machine_providers(provider_class: type) -> None:
"""tutorial002 passes `issuer=`; the page says leaving it out is allowed, on either provider."""
issuer = inspect.signature(provider_class.__init__).parameters["issuer"]
assert issuer.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD
assert issuer.default is None
async def test_the_two_remaining_keyword_arguments_have_defaults() -> None:
"""The page names `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 = ["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 `httpx2.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"),
issuer="http://localhost:9000",
)
assert isinstance(provider, OAuthClientProvider)
assert isinstance(provider, httpx2.Auth)
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"
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)