diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index e446eeb997..2ce67f815a 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli What changed: * No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. -* `scopes` is a space-separated string, the OAuth wire format. +* `scope` is a space-separated string, the OAuth wire format. * Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`. By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two. diff --git a/docs/migration.md b/docs/migration.md index afe0953631..80ffcb1144 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1954,6 +1954,22 @@ async def callback_handler() -> AuthorizationCodeResult: Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it. +### `scopes=` renamed to `scope=` on the client-credentials providers + +`ClientCredentialsOAuthProvider` and `PrivateKeyJWTOAuthProvider` took the requested scope as a keyword named `scopes`, even though the value is a single space-separated string, not a list. The parameter is now `scope`, matching the RFC 6749 wire parameter, `OAuthClientMetadata.scope`, and the newer `IdentityAssertionOAuthProvider`. + +**Before (v1):** + +```python +ClientCredentialsOAuthProvider(..., scopes="read write") +``` + +**After (v2):** + +```python +ClientCredentialsOAuthProvider(..., scope="read write") +``` + ### Client rejects authorization server metadata with a mismatched `issuer` During OAuth discovery, `OAuthClientProvider` now validates that the authorization server diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py index 99865c6aea..dd4105f937 100644 --- a/docs_src/oauth_clients/tutorial002.py +++ b/docs_src/oauth_clients/tutorial002.py @@ -29,7 +29,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None storage=InMemoryTokenStorage(), client_id="reporting-agent", client_secret="...", - scopes="user", + scope="user", ) diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 86d4057dc1..78dc7c7c3c 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -26,7 +26,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: storage=InMemoryTokenStorage(), client_id=DEMO_CLIENT_ID, client_secret=DEMO_CLIENT_SECRET, - scopes=DEMO_SCOPE, + scope=DEMO_SCOPE, ) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index c05cc55b36..d7da71c8f5 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -46,7 +46,7 @@ def __init__( client_id: str, client_secret: str, token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", - scopes: str | None = None, + scope: str | None = None, ) -> None: """Initialize client_credentials OAuth provider. @@ -57,14 +57,14 @@ def __init__( client_secret: The OAuth client secret. token_endpoint_auth_method: Authentication method for token endpoint. Either "client_secret_basic" (default) or "client_secret_post". - scopes: Optional space-separated list of scopes to request. + scope: Optional space-separated list of scopes to request. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( redirect_uris=None, grant_types=["client_credentials"], token_endpoint_auth_method=token_endpoint_auth_method, - scope=scopes, + scope=scope, ) super().__init__(server_url, client_metadata, storage, None, None, 300.0) # Store client_info to be set during _initialize - no dynamic registration needed @@ -74,7 +74,7 @@ def __init__( client_secret=client_secret, grant_types=["client_credentials"], token_endpoint_auth_method=token_endpoint_auth_method, - scope=scopes, + scope=scope, ) async def _initialize(self) -> None: @@ -258,7 +258,7 @@ def __init__( storage: TokenStorage, client_id: str, assertion_provider: Callable[[str], Awaitable[str]], - scopes: str | None = None, + scope: str | None = None, ) -> None: """Initialize private_key_jwt OAuth provider. @@ -271,14 +271,14 @@ def __init__( `SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs, `static_assertion_provider()` for pre-built JWTs, or provide your own callback for workload identity federation. - scopes: Optional space-separated list of scopes to request. + scope: Optional space-separated list of scopes to request. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( redirect_uris=None, grant_types=["client_credentials"], token_endpoint_auth_method="private_key_jwt", - scope=scopes, + scope=scope, ) super().__init__(server_url, client_metadata, storage, None, None, 300.0) self._assertion_provider = assertion_provider @@ -288,7 +288,7 @@ def __init__( client_id=client_id, grant_types=["client_credentials"], token_endpoint_auth_method="private_key_jwt", - scope=scopes, + scope=scope, ) async def _initialize(self) -> None: diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 3ad649d1f2..77f4fc7479 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -210,7 +210,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage): storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", - scopes="read write", + scope="read write", ) await provider._initialize() @@ -240,7 +240,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", - scopes="read write", + scope="read write", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://api.example.com"), @@ -268,7 +268,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s client_id="test-client-id", client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", - scopes="read write", + scope="read write", ) await provider._initialize() provider.context.oauth_metadata = OAuthMetadata( @@ -296,7 +296,7 @@ async def test_exchange_token_client_secret_post_without_client_id(self, mock_st client_id="placeholder", client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", - scopes="read write", + scope="read write", ) await provider._initialize() provider.context.oauth_metadata = OAuthMetadata( @@ -386,7 +386,7 @@ async def mock_assertion_provider(audience: str) -> str: storage=mock_storage, client_id="test-client-id", assertion_provider=mock_assertion_provider, - scopes="read write", + scope="read write", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://auth.example.com"), diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index c810f8c449..8f45a01510 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -372,7 +372,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ storage=InMemoryTokenStorage(), client_id="m2m-client", client_secret="m2m-secret", - scopes="mcp", + scope="mcp", ) with anyio.fail_after(5): @@ -423,7 +423,7 @@ async def assertion_provider(audience: str) -> str: storage=InMemoryTokenStorage(), client_id="m2m-jwt-client", assertion_provider=assertion_provider, - scopes="mcp", + scope="mcp", ) with anyio.fail_after(5):