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:
* 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`.

View file

@ -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.

View file

@ -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.

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
# 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,
)

View file

@ -7,6 +7,7 @@ Provides OAuth providers for machine-to-machine authentication flows:
"""
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 import OAuthClientProvider, OAuthFlowError, TokenStorage
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=<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}")
return issuer
@ -97,7 +108,8 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
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 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=get_workload_identity_token,
issuer="https://auth.example.com",
)
```
@ -280,6 +295,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
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 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
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 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
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(

View file

@ -7,6 +7,7 @@ import pytest
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestClientCredentialsOAuthProvider:
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 @@ class TestPrivateKeyJWTOAuthProvider:
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 @@ class TestPrivateKeyJWTOAuthProvider:
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 @@ class TestPrivateKeyJWTOAuthProvider:
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=<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:
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
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(),
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)

View file

@ -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 test_private_key_jwt_provider_authenticates_the_token_request_with_an_
client_id="m2m-jwt-client",
assertion_provider=assertion_provider,
scope="mcp",
issuer=BASE_URL,
)
with anyio.fail_after(5):