Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435)
This commit is contained in:
parent
d060b36e1d
commit
979208c115
8 changed files with 103 additions and 11 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue