From 8031fe6649f7c6c5431e46c1a9eb9330878918f3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:41:42 +0000 Subject: [PATCH 1/5] Warn when a pre-provisioned OAuth client is created without an issuer ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider send fixed credentials to whichever authorization server discovery yields unless `issuer=` names the one they belong to. Leaving it out stays allowed, but the provider now says so at construction with a UserWarning that names the server URL and the keyword to pass, so the choice is visible rather than silent. Nothing else changes: with `issuer=` set there is no warning, and a value that is not an http(s) URL is still a ValueError. The example story and the interaction tests pass `issuer=` (their authorization server is known); the extension tests that exercise the no-issuer path opt in to the warning explicitly. --- docs/client/oauth-clients.md | 2 +- .../oauth_client_credentials/client.py | 5 +- .../auth/extensions/client_credentials.py | 22 ++++-- .../extensions/test_client_credentials.py | 71 +++++++++++++++++++ tests/docs_src/test_oauth_clients.py | 1 + tests/interaction/auth/test_lifecycle.py | 2 + 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 3954fd539b..7e85fbc929 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. Leave it out and the provider uses whichever authorization server discovery finds, and says so with a `UserWarning` when it is constructed. * `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/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 78dc7c7c3c..3e7dd0cab3 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 — six 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..28ce1332dd 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 @@ -22,8 +23,16 @@ from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata -def _checked_issuer(issuer: str | None) -> str | None: - if issuer is not None and urlparse(issuer).scheme not in ("http", "https"): +def _checked_issuer(issuer: str | None, provider: str, server_url: str) -> str | None: + if issuer is None: + warnings.warn( + f"{provider} created without `issuer`: the client credentials will be sent to whichever " + f"authorization server {server_url} advertises. Pass issuer= so that token requests are only ever built for that server.", + 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 @@ -98,7 +107,7 @@ def __init__( `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 - authorization server discovery yields is used. + authorization server discovery yields is used, and a `UserWarning` says so. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -108,7 +117,7 @@ def __init__( scope=scope, ) super().__init__(server_url, client_metadata, storage, None, None) - self._issuer = _checked_issuer(issuer) + self._issuer = _checked_issuer(issuer, type(self).__name__, server_url) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, @@ -327,7 +336,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. + When omitted, whichever authorization server discovery yields is used, and a + `UserWarning` says so. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -338,7 +348,7 @@ def __init__( ) super().__init__(server_url, client_metadata, storage, None, None) self._assertion_provider = assertion_provider - self._issuer = _checked_issuer(issuer) + self._issuer = _checked_issuer(issuer, type(self).__name__, server_url) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 5933604eeb..6914aa5583 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -57,6 +57,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 +78,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 +94,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 +110,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 +139,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 +166,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 +198,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 +222,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 +254,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 +445,68 @@ 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_warns_where_the_credentials_will_go( + 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(UserWarning) 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__ + provider = "ClientCredentialsOAuthProvider" if kind == "secret" else "PrivateKeyJWTOAuthProvider" + assert str(warning.message) == ( + f"{provider} created without `issuer`: the client credentials will be sent to whichever " + "authorization server https://api.example.com/v1/mcp advertises. Pass issuer= so that token requests are only ever built for that server." + ) + + +@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(UserWarning, match="created without `issuer`"): + 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): From 91d18b4b4ba51a9255373243fba02f721cedad7c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:46:25 +0000 Subject: [PATCH 2/5] Keep the missing-issuer warning text static The warning is already attributed to the caller's constructor line, so it does not need the provider name or server URL; _checked_issuer keeps its single argument. --- src/mcp/client/auth/extensions/client_credentials.py | 11 +++++------ .../client/auth/extensions/test_client_credentials.py | 8 +++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 28ce1332dd..e85f724b61 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -23,12 +23,11 @@ from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata -def _checked_issuer(issuer: str | None, provider: str, server_url: str) -> str | None: +def _checked_issuer(issuer: str | None) -> str | None: if issuer is None: warnings.warn( - f"{provider} created without `issuer`: the client credentials will be sent to whichever " - f"authorization server {server_url} advertises. Pass issuer= so that token requests are only ever built for that server.", + "No `issuer` given: client credentials will be sent to whichever authorization server the MCP " + "server advertises. Pass issuer= to send them only there.", stacklevel=3, ) return None @@ -117,7 +116,7 @@ def __init__( scope=scope, ) super().__init__(server_url, client_metadata, storage, None, None) - self._issuer = _checked_issuer(issuer, type(self).__name__, server_url) + self._issuer = _checked_issuer(issuer) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, @@ -348,7 +347,7 @@ def __init__( ) super().__init__(server_url, client_metadata, storage, None, None) self._assertion_provider = assertion_provider - self._issuer = _checked_issuer(issuer, type(self).__name__, server_url) + self._issuer = _checked_issuer(issuer) # Store client_info to be set during _initialize - no dynamic registration needed self._fixed_client_info = OAuthClientInformationFull( redirect_uris=None, diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 6914aa5583..518465dfad 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -467,11 +467,9 @@ async def assertion_provider(audience: str) -> str: [warning] = recorded assert warning.filename == __file__ - provider = "ClientCredentialsOAuthProvider" if kind == "secret" else "PrivateKeyJWTOAuthProvider" assert str(warning.message) == ( - f"{provider} created without `issuer`: the client credentials will be sent to whichever " - "authorization server https://api.example.com/v1/mcp advertises. Pass issuer= so that token requests are only ever built for that server." + "No `issuer` given: client credentials will be sent to whichever authorization server the MCP " + "server advertises. Pass issuer= to send them only there." ) @@ -486,7 +484,7 @@ async def test_without_issuer_the_exchange_follows_whichever_server_was_discover async def assertion_provider(audience: str) -> str: return "jwt" - with pytest.warns(UserWarning, match="created without `issuer`"): + with pytest.warns(UserWarning, match="No `issuer` given"): if kind == "secret": provider: OAuthClientProvider = ClientCredentialsOAuthProvider( server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" From 04dafcbd24dc1c8f5d199dca0920a04d218b71fa Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:39:03 +0000 Subject: [PATCH 3/5] Deprecate constructing the pre-provisioned OAuth clients without an issuer The warning becomes an MCPDeprecationWarning: omitting `issuer=` on ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider keeps working in 2.x and will be required in 3.0. The message says why (without it the MCP server decides which authorization server receives the credentials) and what to pass. docs/deprecated.md lists it next to the other SDK-level deprecation, and the OAuth clients page points there. --- docs/client/oauth-clients.md | 2 +- docs/deprecated.md | 7 ++++--- .../client/auth/extensions/client_credentials.py | 16 ++++++++++------ .../auth/extensions/test_client_credentials.py | 14 +++++++------- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 7e85fbc929..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, and says so with a `UserWarning` when it is constructed. +* `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..48ba6a94f2 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**. Two 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. +* Two SDK-level deprecations ride along: `FuncMetadata.call_fn_with_arg_validation()` is removed in 3.0, and constructing `ClientCredentialsOAuthProvider` / `PrivateKeyJWTOAuthProvider` without `issuer=` stops being allowed in 3.0. * New code should not be built on any of these. Every other page in these docs teaches the current API. diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index e85f724b61..7894774bcb 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -21,13 +21,16 @@ 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 None: warnings.warn( - "No `issuer` given: client credentials will be sent to whichever authorization server the MCP " - "server advertises. Pass issuer= to send them only there.", + "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 @@ -105,8 +108,9 @@ 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 - authorization server discovery yields is used, and a `UserWarning` says so. + 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 client_metadata = OAuthClientMetadata( @@ -335,8 +339,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, and a - `UserWarning` says so. + 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 518465dfad..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, @@ -446,16 +447,14 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser @pytest.mark.parametrize("kind", ["secret", "jwt"]) -def test_constructing_without_issuer_warns_where_the_credentials_will_go( - mock_storage: MockTokenStorage, kind: str -) -> None: +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(UserWarning) as recorded: + with pytest.warns(MCPDeprecationWarning) as recorded: if kind == "secret": ClientCredentialsOAuthProvider( server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s" @@ -468,8 +467,9 @@ async def assertion_provider(audience: str) -> str: [warning] = recorded assert warning.filename == __file__ assert str(warning.message) == ( - "No `issuer` given: client credentials will be sent to whichever authorization server the MCP " - "server advertises. Pass issuer= to send them only there." + "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." ) @@ -484,7 +484,7 @@ async def test_without_issuer_the_exchange_follows_whichever_server_was_discover async def assertion_provider(audience: str) -> str: return "jwt" - with pytest.warns(UserWarning, match="No `issuer` given"): + 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" From 4af15fe1a22a9be16f19c91e7e9ccced7acd2a9a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:09:49 +0000 Subject: [PATCH 4/5] Pass issuer= in the remaining docstring examples and the story README The PrivateKeyJWTOAuthProvider, static_assertion_provider and SignedJWTParameters examples now show the non-deprecated form, and the client-credentials story stops counting its config lines. --- examples/stories/oauth_client_credentials/README.md | 4 ++-- examples/stories/oauth_client_credentials/client.py | 2 +- src/mcp/client/auth/extensions/client_credentials.py | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) 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 3e7dd0cab3..72e1086896 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -14,7 +14,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: - """The ``httpx2.Auth`` for the ``client_credentials`` grant — six 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 diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 7894774bcb..091f9e39ee 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -180,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", ) ``` @@ -214,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", ) ``` """ @@ -279,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", ) ``` @@ -292,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", ) ``` @@ -310,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", ) ``` """ From 4c1f45656e537c05d555489f906e3927c449f13c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:26:35 +0000 Subject: [PATCH 5/5] Stop counting the SDK-level deprecations in docs/deprecated.md --- docs/deprecated.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deprecated.md b/docs/deprecated.md index 48ba6a94f2..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**. Two SDK-level deprecations stand on their own account and are 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. @@ -145,7 +145,7 @@ These are not spec changes, only SDK usage with a better replacement. They warn * 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. -* Two SDK-level deprecations ride along: `FuncMetadata.call_fn_with_arg_validation()` is removed in 3.0, and constructing `ClientCredentialsOAuthProvider` / `PrivateKeyJWTOAuthProvider` without `issuer=` stops being allowed 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.