Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435)

This commit is contained in:
Max 2026-09-04 15:53:21 +01:00 • committed by GitHub
commit 979208c115
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 103 additions and 11 deletions

View file

@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
What changed: 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. * 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. * `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`. * Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.

View file

@ -1,6 +1,6 @@
# Deprecated features # 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. 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 ## 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 | | 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. | | `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 ## 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). * 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. * 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. * `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. * New code should not be built on any of these.
Every other page in these docs teaches the current API. Every other page in these docs teaches the current API.

View file

@ -33,8 +33,8 @@ the client and server side.
- `client.py` `main` — opens with `async with Client(target, mode=mode) as - `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 client:` and that's the whole program. `target` is a transport that already
carries the OAuth `httpx2.Auth`; the body never touches a token. carries the OAuth `httpx2.Auth`; the body never touches a token.
- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` - `client.py` `build_auth` — a few lines of `ClientCredentialsOAuthProvider`
config is all the caller writes; the SDK does RFC 9728 PRM → 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. RFC 8414 AS-metadata discovery and token exchange on the first 401.
- `server.py` `token_endpoint` — the *entire* AS for this grant: validate - `server.py` `token_endpoint` — the *entire* AS for this grant: validate
HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON. HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON.

View file

@ -8,13 +8,13 @@ from stories._harness import Target, run_client
# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from # 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. # 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 from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth: 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 → 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 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_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET, client_secret=DEMO_CLIENT_SECRET,
scope=DEMO_SCOPE, scope=DEMO_SCOPE,
issuer=BASE_URL,
) )

View file

@ -7,6 +7,7 @@ Provides OAuth providers for machine-to-machine authentication flows:
""" """
import time import time
import warnings
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import urlparse from urllib.parse import urlparse
@ -20,10 +21,20 @@ from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
from mcp.client.auth.oauth2 import OAuthContext from mcp.client.auth.oauth2 import OAuthContext
from mcp.client.auth.utils import issuers_match from mcp.client.auth.utils import issuers_match
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
from mcp.shared.exceptions import MCPDeprecationWarning
def _checked_issuer(issuer: str | None) -> str | None: 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=<your authorization server's issuer URL> 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}") raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
return issuer return issuer
@ -97,7 +108,8 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
issuer: The issuer identifier of the authorization server that issued issuer: The issuer identifier of the authorization server that issued
`client_id` and `client_secret`. When set, token requests are only built from `client_id` and `client_secret`. When set, token requests are only built from
discovered authorization server metadata whose `issuer` is exactly this string; 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. authorization server discovery yields is used.
""" """
# Build minimal client_metadata for the base class # 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, storage=my_token_storage,
client_id="my-client-id", client_id="my-client-id",
assertion_provider=static_assertion_provider(my_prebuilt_jwt), assertion_provider=static_assertion_provider(my_prebuilt_jwt),
issuer="https://auth.example.com",
) )
``` ```
@ -202,6 +215,7 @@ class SignedJWTParameters(BaseModel):
storage=my_token_storage, storage=my_token_storage,
client_id="my-client-id", client_id="my-client-id",
assertion_provider=jwt_params.create_assertion_provider(), assertion_provider=jwt_params.create_assertion_provider(),
issuer="https://auth.example.com",
) )
``` ```
""" """
@ -267,6 +281,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
storage=my_token_storage, storage=my_token_storage,
client_id="my-client-id", client_id="my-client-id",
assertion_provider=get_workload_identity_token, assertion_provider=get_workload_identity_token,
issuer="https://auth.example.com",
) )
``` ```
@ -280,6 +295,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
storage=my_token_storage, storage=my_token_storage,
client_id="my-client-id", client_id="my-client-id",
assertion_provider=static_assertion_provider(my_prebuilt_jwt), assertion_provider=static_assertion_provider(my_prebuilt_jwt),
issuer="https://auth.example.com",
) )
``` ```
@ -298,6 +314,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
storage=my_token_storage, storage=my_token_storage,
client_id="my-client-id", client_id="my-client-id",
assertion_provider=jwt_params.create_assertion_provider(), assertion_provider=jwt_params.create_assertion_provider(),
issuer="https://auth.example.com",
) )
``` ```
""" """
@ -327,7 +344,8 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
registered with. When set, an assertion is only minted, and token requests registered with. When set, an assertion is only minted, and token requests
are only built, once authorization server metadata whose `issuer` is exactly this are only built, once authorization server metadata whose `issuer` is exactly this
string has been discovered; otherwise the flow stops with `OAuthFlowError`. 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 # Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata( client_metadata = OAuthClientMetadata(

View file

@ -7,6 +7,7 @@ import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
from pydantic import AnyHttpUrl from pydantic import AnyHttpUrl
from mcp import MCPDeprecationWarning
from mcp.client.auth import OAuthClientProvider, OAuthFlowError from mcp.client.auth import OAuthClientProvider, OAuthFlowError
from mcp.client.auth.extensions.client_credentials import ( from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider, ClientCredentialsOAuthProvider,
@ -57,6 +58,7 @@ class TestClientCredentialsOAuthProvider:
storage=mock_storage, storage=mock_storage,
client_id="test-client-id", client_id="test-client-id",
client_secret="test-client-secret", client_secret="test-client-secret",
issuer="https://api.example.com",
) )
# client_info is set during _initialize # client_info is set during _initialize
@ -77,6 +79,7 @@ class TestClientCredentialsOAuthProvider:
client_id="test-client-id", client_id="test-client-id",
client_secret="test-client-secret", client_secret="test-client-secret",
scope="read write", scope="read write",
issuer="https://api.example.com",
) )
await provider._initialize() await provider._initialize()
@ -92,6 +95,7 @@ class TestClientCredentialsOAuthProvider:
client_id="test-client-id", client_id="test-client-id",
client_secret="test-client-secret", client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post", token_endpoint_auth_method="client_secret_post",
issuer="https://api.example.com",
) )
await provider._initialize() await provider._initialize()
@ -107,6 +111,7 @@ class TestClientCredentialsOAuthProvider:
client_id="test-client-id", client_id="test-client-id",
client_secret="test-client-secret", client_secret="test-client-secret",
scope="read write", scope="read write",
issuer="https://api.example.com",
) )
provider.context.oauth_metadata = OAuthMetadata( provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"), issuer=AnyHttpUrl("https://api.example.com"),
@ -135,6 +140,7 @@ class TestClientCredentialsOAuthProvider:
client_secret="test-client-secret", client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post", token_endpoint_auth_method="client_secret_post",
scope="read write", scope="read write",
issuer="https://api.example.com",
) )
await provider._initialize() await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata( provider.context.oauth_metadata = OAuthMetadata(
@ -161,6 +167,7 @@ class TestClientCredentialsOAuthProvider:
storage=mock_storage, storage=mock_storage,
client_id="test-client-id", client_id="test-client-id",
client_secret="test-client-secret", client_secret="test-client-secret",
issuer="https://api.example.com",
) )
provider.context.oauth_metadata = OAuthMetadata( provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"), issuer=AnyHttpUrl("https://api.example.com"),
@ -192,6 +199,7 @@ class TestPrivateKeyJWTOAuthProvider:
storage=mock_storage, storage=mock_storage,
client_id="test-client-id", client_id="test-client-id",
assertion_provider=mock_assertion_provider, assertion_provider=mock_assertion_provider,
issuer="https://api.example.com",
) )
# client_info is set during _initialize # client_info is set during _initialize
@ -215,6 +223,7 @@ class TestPrivateKeyJWTOAuthProvider:
client_id="test-client-id", client_id="test-client-id",
assertion_provider=mock_assertion_provider, assertion_provider=mock_assertion_provider,
scope="read write", scope="read write",
issuer="https://auth.example.com",
) )
provider.context.oauth_metadata = OAuthMetadata( provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"), issuer=AnyHttpUrl("https://auth.example.com"),
@ -246,6 +255,7 @@ class TestPrivateKeyJWTOAuthProvider:
storage=mock_storage, storage=mock_storage,
client_id="test-client-id", client_id="test-client-id",
assertion_provider=mock_assertion_provider, assertion_provider=mock_assertion_provider,
issuer="https://auth.example.com",
) )
provider.context.oauth_metadata = OAuthMetadata( provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"), 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() 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=<your authorization server's issuer URL> 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: 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 """SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
error on both machine-to-machine providers.""" error on both machine-to-machine providers."""

View file

@ -105,6 +105,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
storage=tutorial002.InMemoryTokenStorage(), storage=tutorial002.InMemoryTokenStorage(),
client_id="reporting-agent", client_id="reporting-agent",
assertion_provider=static_assertion_provider("a.prebuilt.jwt"), assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
issuer="http://localhost:9000",
) )
assert isinstance(provider, OAuthClientProvider) assert isinstance(provider, OAuthClientProvider)
assert isinstance(provider, httpx2.Auth) assert isinstance(provider, httpx2.Auth)

View file

@ -373,6 +373,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
client_id="m2m-client", client_id="m2m-client",
client_secret="m2m-secret", client_secret="m2m-secret",
scope="mcp", scope="mcp",
issuer=BASE_URL,
) )
with anyio.fail_after(5): with anyio.fail_after(5):
@ -424,6 +425,7 @@ async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_
client_id="m2m-jwt-client", client_id="m2m-jwt-client",
assertion_provider=assertion_provider, assertion_provider=assertion_provider,
scope="mcp", scope="mcp",
issuer=BASE_URL,
) )
with anyio.fail_after(5): with anyio.fail_after(5):