diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 3954fd539b..fe9f8111be 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. -* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leave it out and the provider uses whichever authorization server discovery finds. +* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leaving it out is deprecated and it becomes required in 3.0 (see **[Deprecated features](../deprecated.md#deprecated-sdk-helpers)**); until then the provider warns and uses whichever authorization server discovery finds. * `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`. diff --git a/docs/deprecated.md b/docs/deprecated.md index d1d724faea..40de550f9d 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -1,6 +1,6 @@ # Deprecated features -The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers). +The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. A few SDK-level deprecations stand on their own account and are listed [at the end](#deprecated-sdk-helpers). The table below names each deprecated feature, why it is going away, and the replacement to build on. @@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th ## Deprecated SDK helpers -These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0. +These are not spec changes, only SDK usage with a better replacement. They warn with the same `MCPDeprecationWarning`, and 3.0 removes the old form. | Deprecated | What you do instead | |---|---| | `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. | +| `ClientCredentialsOAuthProvider(...)` or `PrivateKeyJWTOAuthProvider(...)` without `issuer=` | Pass `issuer=` naming the authorization server that issued the credentials (see **[Writing OAuth clients](client/oauth-clients.md#machine-to-machine)**). Without it the MCP server decides which authorization server receives them; 3.0 makes the keyword required. | ## Recap @@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w * Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). * Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. -* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0. +* The [SDK-level deprecations](#deprecated-sdk-helpers) follow the same rule: they warn now, and 3.0 drops the old form. * New code should not be built on any of these. Every other page in these docs teaches the current API. diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index d58b004c90..82bc3c42f0 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -33,8 +33,8 @@ the client and server side. - `client.py` `main` — opens with `async with Client(target, mode=mode) as client:` and that's the whole program. `target` is a transport that already carries the OAuth `httpx2.Auth`; the body never touches a token. -- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` - config is all the caller writes; the SDK does RFC 9728 PRM → +- `client.py` `build_auth` — a few lines of `ClientCredentialsOAuthProvider` + config (credentials plus `issuer=`) is all the caller writes; the SDK does RFC 9728 PRM → RFC 8414 AS-metadata discovery and token exchange on the first 401. - `server.py` `token_endpoint` — the *entire* AS for this grant: validate HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON. diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 78dc7c7c3c..72e1086896 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -8,13 +8,13 @@ # MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from # the same constant — run the server on 8000 or the discovery chain points at the wrong origin. -from stories._shared.auth import MCP_URL, InMemoryTokenStorage +from stories._shared.auth import BASE_URL, MCP_URL, InMemoryTokenStorage from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: - """The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config. + """The ``httpx2.Auth`` for the ``client_credentials`` grant — a few lines of provider config. The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the @@ -27,6 +27,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: client_id=DEMO_CLIENT_ID, client_secret=DEMO_CLIENT_SECRET, scope=DEMO_SCOPE, + issuer=BASE_URL, ) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 5cdefad1f8..091f9e39ee 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -7,6 +7,7 @@ """ import time +import warnings from collections.abc import Awaitable, Callable from typing import Any, Literal from urllib.parse import urlparse @@ -20,10 +21,20 @@ from mcp.client.auth.oauth2 import OAuthContext from mcp.client.auth.utils import issuers_match from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata +from mcp.shared.exceptions import MCPDeprecationWarning def _checked_issuer(issuer: str | None) -> str | None: - if issuer is not None and urlparse(issuer).scheme not in ("http", "https"): + if issuer is None: + warnings.warn( + "Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server " + "decides which authorization server receives this client's credentials; pass " + "issuer= so they are only ever sent there.", + MCPDeprecationWarning, + stacklevel=3, + ) + return None + if urlparse(issuer).scheme not in ("http", "https"): raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}") return issuer @@ -97,7 +108,8 @@ def __init__( issuer: The issuer identifier of the authorization server that issued `client_id` and `client_secret`. When set, token requests are only built from discovered authorization server metadata whose `issuer` is exactly this string; - otherwise the flow stops with `OAuthFlowError`. When omitted, whichever + otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated + (`MCPDeprecationWarning`) and it will be required in 3.0; until then, whichever authorization server discovery yields is used. """ # Build minimal client_metadata for the base class @@ -168,6 +180,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: storage=my_token_storage, client_id="my-client-id", assertion_provider=static_assertion_provider(my_prebuilt_jwt), + issuer="https://auth.example.com", ) ``` @@ -202,6 +215,7 @@ class SignedJWTParameters(BaseModel): storage=my_token_storage, client_id="my-client-id", assertion_provider=jwt_params.create_assertion_provider(), + issuer="https://auth.example.com", ) ``` """ @@ -267,6 +281,7 @@ async def get_workload_identity_token(audience: str) -> str: storage=my_token_storage, client_id="my-client-id", assertion_provider=get_workload_identity_token, + issuer="https://auth.example.com", ) ``` @@ -280,6 +295,7 @@ async def get_workload_identity_token(audience: str) -> str: storage=my_token_storage, client_id="my-client-id", assertion_provider=static_assertion_provider(my_prebuilt_jwt), + issuer="https://auth.example.com", ) ``` @@ -298,6 +314,7 @@ async def get_workload_identity_token(audience: str) -> str: storage=my_token_storage, client_id="my-client-id", assertion_provider=jwt_params.create_assertion_provider(), + issuer="https://auth.example.com", ) ``` """ @@ -327,7 +344,8 @@ def __init__( registered with. When set, an assertion is only minted, and token requests are only built, once authorization server metadata whose `issuer` is exactly this string has been discovered; otherwise the flow stops with `OAuthFlowError`. - When omitted, whichever authorization server discovery yields is used. + Omitting it is deprecated (`MCPDeprecationWarning`) and it will be required in + 3.0; until then, whichever authorization server discovery yields is used. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 5933604eeb..3b320c5906 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -7,6 +7,7 @@ from inline_snapshot import snapshot from pydantic import AnyHttpUrl +from mcp import MCPDeprecationWarning from mcp.client.auth import OAuthClientProvider, OAuthFlowError from mcp.client.auth.extensions.client_credentials import ( ClientCredentialsOAuthProvider, @@ -57,6 +58,7 @@ async def test_init_sets_client_info(self, mock_storage: MockTokenStorage): storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", + issuer="https://api.example.com", ) # client_info is set during _initialize @@ -77,6 +79,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage): client_id="test-client-id", client_secret="test-client-secret", scope="read write", + issuer="https://api.example.com", ) await provider._initialize() @@ -92,6 +95,7 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage client_id="test-client-id", client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", + issuer="https://api.example.com", ) await provider._initialize() @@ -107,6 +111,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt client_id="test-client-id", client_secret="test-client-secret", scope="read write", + issuer="https://api.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://api.example.com"), @@ -135,6 +140,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s client_secret="test-client-secret", token_endpoint_auth_method="client_secret_post", scope="read write", + issuer="https://api.example.com", ) await provider._initialize() provider.context.oauth_metadata = OAuthMetadata( @@ -161,6 +167,7 @@ async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorag storage=mock_storage, client_id="test-client-id", client_secret="test-client-secret", + issuer="https://api.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://api.example.com"), @@ -192,6 +199,7 @@ async def mock_assertion_provider(audience: str) -> str: # pragma: no cover storage=mock_storage, client_id="test-client-id", assertion_provider=mock_assertion_provider, + issuer="https://api.example.com", ) # client_info is set during _initialize @@ -215,6 +223,7 @@ async def mock_assertion_provider(audience: str) -> str: client_id="test-client-id", assertion_provider=mock_assertion_provider, scope="read write", + issuer="https://auth.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://auth.example.com"), @@ -246,6 +255,7 @@ async def mock_assertion_provider(audience: str) -> str: storage=mock_storage, client_id="test-client-id", assertion_provider=mock_assertion_provider, + issuer="https://auth.example.com", ) provider.context.oauth_metadata = OAuthMetadata( issuer=AnyHttpUrl("https://auth.example.com"), @@ -436,6 +446,65 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser await flow.aclose() +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +def test_constructing_without_issuer_is_deprecated(mock_storage: MockTokenStorage, kind: str) -> None: + """SDK-defined: leaving `issuer` out is allowed, and the provider says at construction that + token requests will follow whichever authorization server the MCP server advertises.""" + + async def assertion_provider(audience: str) -> str: + raise NotImplementedError + + with pytest.warns(MCPDeprecationWarning) as recorded: + if kind == "secret": + ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" + ) + else: + PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider + ) + + [warning] = recorded + assert warning.filename == __file__ + assert str(warning.message) == ( + "Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server " + "decides which authorization server receives this client's credentials; pass " + "issuer= so they are only ever sent there." + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["secret", "jwt"]) +async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered( + mock_storage: MockTokenStorage, kind: str +) -> None: + """SDK-defined: with no `issuer` configured the token request is built from whatever metadata + discovery produced, as before.""" + + async def assertion_provider(audience: str) -> str: + return "jwt" + + with pytest.warns(MCPDeprecationWarning, match="Omitting `issuer` is deprecated"): + if kind == "secret": + provider: OAuthClientProvider = ClientCredentialsOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" + ) + else: + provider = PrivateKeyJWTOAuthProvider( + server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider + ) + flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL)) + + token_request = await _answer_discovery( + flow, + authorization_server="https://elsewhere.example.com", + metadata=_metadata_for("https://elsewhere.example.com"), + ) + + assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token") + await flow.aclose() + + def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None: """SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration error on both machine-to-machine providers.""" diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index a4ec05d9fe..db8761398a 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -105,6 +105,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None: 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) diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 8f45a01510..610db62e27 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -373,6 +373,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ client_id="m2m-client", client_secret="m2m-secret", scope="mcp", + issuer=BASE_URL, ) with anyio.fail_after(5): @@ -424,6 +425,7 @@ async def assertion_provider(audience: str) -> str: client_id="m2m-jwt-client", assertion_provider=assertion_provider, scope="mcp", + issuer=BASE_URL, ) with anyio.fail_after(5):