Skip to content

Commit 979208c

Browse files
authored
Deprecate constructing the pre-provisioned OAuth clients without an issuer (#3435)
1 parent d060b36 commit 979208c

8 files changed

Lines changed: 103 additions & 11 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
112112
What changed:
113113

114114
* 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.
115-
* `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.
115+
* `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.
116116
* `scope` is a space-separated string, the OAuth wire format.
117117
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
118118

docs/deprecated.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Deprecated features
22

3-
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).
3+
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).
44

55
The table below names each deprecated feature, why it is going away, and the replacement to build on.
66

@@ -131,11 +131,12 @@ That is the whole API. There is no per-method switch, and you don't want one: th
131131

132132
## Deprecated SDK helpers
133133

134-
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.
134+
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.
135135

136136
| Deprecated | What you do instead |
137137
|---|---|
138138
| `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. |
139+
| `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. |
139140

140141
## Recap
141142

@@ -144,7 +145,7 @@ These are not spec changes, only SDK internals with a better replacement. They w
144145
* 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).
145146
* 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.
146147
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
147-
* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0.
148+
* The [SDK-level deprecations](#deprecated-sdk-helpers) follow the same rule: they warn now, and 3.0 drops the old form.
148149
* New code should not be built on any of these.
149150

150151
Every other page in these docs teaches the current API.

examples/stories/oauth_client_credentials/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ the client and server side.
3333
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
3434
client:` and that's the whole program. `target` is a transport that already
3535
carries the OAuth `httpx2.Auth`; the body never touches a token.
36-
- `client.py` `build_auth`five lines of `ClientCredentialsOAuthProvider`
37-
config is all the caller writes; the SDK does RFC 9728 PRM →
36+
- `client.py` `build_auth`a few lines of `ClientCredentialsOAuthProvider`
37+
config (credentials plus `issuer=`) is all the caller writes; the SDK does RFC 9728 PRM →
3838
RFC 8414 AS-metadata discovery and token exchange on the first 401.
3939
- `server.py` `token_endpoint` — the *entire* AS for this grant: validate
4040
HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON.

examples/stories/oauth_client_credentials/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from
1010
# the same constant — run the server on 8000 or the discovery chain points at the wrong origin.
11-
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
11+
from stories._shared.auth import BASE_URL, MCP_URL, InMemoryTokenStorage
1212

1313
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
1414

1515

1616
def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
17-
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — five lines of provider config.
17+
"""The ``httpx2.Auth`` for the ``client_credentials`` grant — a few lines of provider config.
1818
1919
The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
2020
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
@@ -27,6 +27,7 @@ def build_auth(_http: httpx2.AsyncClient) -> httpx2.Auth:
2727
client_id=DEMO_CLIENT_ID,
2828
client_secret=DEMO_CLIENT_SECRET,
2929
scope=DEMO_SCOPE,
30+
issuer=BASE_URL,
3031
)
3132

3233

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import time
10+
import warnings
1011
from collections.abc import Awaitable, Callable
1112
from typing import Any, Literal
1213
from urllib.parse import urlparse
@@ -20,10 +21,20 @@
2021
from mcp.client.auth.oauth2 import OAuthContext
2122
from mcp.client.auth.utils import issuers_match
2223
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
24+
from mcp.shared.exceptions import MCPDeprecationWarning
2325

2426

2527
def _checked_issuer(issuer: str | None) -> str | None:
26-
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
28+
if issuer is None:
29+
warnings.warn(
30+
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
31+
"decides which authorization server receives this client's credentials; pass "
32+
"issuer=<your authorization server's issuer URL> so they are only ever sent there.",
33+
MCPDeprecationWarning,
34+
stacklevel=3,
35+
)
36+
return None
37+
if urlparse(issuer).scheme not in ("http", "https"):
2738
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
2839
return issuer
2940

@@ -97,7 +108,8 @@ def __init__(
97108
issuer: The issuer identifier of the authorization server that issued
98109
`client_id` and `client_secret`. When set, token requests are only built from
99110
discovered authorization server metadata whose `issuer` is exactly this string;
100-
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
111+
otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
112+
(`MCPDeprecationWarning`) and it will be required in 3.0; until then, whichever
101113
authorization server discovery yields is used.
102114
"""
103115
# Build minimal client_metadata for the base class
@@ -168,6 +180,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
168180
storage=my_token_storage,
169181
client_id="my-client-id",
170182
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
183+
issuer="https://auth.example.com",
171184
)
172185
```
173186
@@ -202,6 +215,7 @@ class SignedJWTParameters(BaseModel):
202215
storage=my_token_storage,
203216
client_id="my-client-id",
204217
assertion_provider=jwt_params.create_assertion_provider(),
218+
issuer="https://auth.example.com",
205219
)
206220
```
207221
"""
@@ -267,6 +281,7 @@ async def get_workload_identity_token(audience: str) -> str:
267281
storage=my_token_storage,
268282
client_id="my-client-id",
269283
assertion_provider=get_workload_identity_token,
284+
issuer="https://auth.example.com",
270285
)
271286
```
272287
@@ -280,6 +295,7 @@ async def get_workload_identity_token(audience: str) -> str:
280295
storage=my_token_storage,
281296
client_id="my-client-id",
282297
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
298+
issuer="https://auth.example.com",
283299
)
284300
```
285301
@@ -298,6 +314,7 @@ async def get_workload_identity_token(audience: str) -> str:
298314
storage=my_token_storage,
299315
client_id="my-client-id",
300316
assertion_provider=jwt_params.create_assertion_provider(),
317+
issuer="https://auth.example.com",
301318
)
302319
```
303320
"""
@@ -327,7 +344,8 @@ def __init__(
327344
registered with. When set, an assertion is only minted, and token requests
328345
are only built, once authorization server metadata whose `issuer` is exactly this
329346
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
330-
When omitted, whichever authorization server discovery yields is used.
347+
Omitting it is deprecated (`MCPDeprecationWarning`) and it will be required in
348+
3.0; until then, whichever authorization server discovery yields is used.
331349
"""
332350
# Build minimal client_metadata for the base class
333351
client_metadata = OAuthClientMetadata(

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from inline_snapshot import snapshot
88
from pydantic import AnyHttpUrl
99

10+
from mcp import MCPDeprecationWarning
1011
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
1112
from mcp.client.auth.extensions.client_credentials import (
1213
ClientCredentialsOAuthProvider,
@@ -57,6 +58,7 @@ async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
5758
storage=mock_storage,
5859
client_id="test-client-id",
5960
client_secret="test-client-secret",
61+
issuer="https://api.example.com",
6062
)
6163

6264
# client_info is set during _initialize
@@ -77,6 +79,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
7779
client_id="test-client-id",
7880
client_secret="test-client-secret",
7981
scope="read write",
82+
issuer="https://api.example.com",
8083
)
8184

8285
await provider._initialize()
@@ -92,6 +95,7 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage
9295
client_id="test-client-id",
9396
client_secret="test-client-secret",
9497
token_endpoint_auth_method="client_secret_post",
98+
issuer="https://api.example.com",
9599
)
96100

97101
await provider._initialize()
@@ -107,6 +111,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
107111
client_id="test-client-id",
108112
client_secret="test-client-secret",
109113
scope="read write",
114+
issuer="https://api.example.com",
110115
)
111116
provider.context.oauth_metadata = OAuthMetadata(
112117
issuer=AnyHttpUrl("https://api.example.com"),
@@ -135,6 +140,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
135140
client_secret="test-client-secret",
136141
token_endpoint_auth_method="client_secret_post",
137142
scope="read write",
143+
issuer="https://api.example.com",
138144
)
139145
await provider._initialize()
140146
provider.context.oauth_metadata = OAuthMetadata(
@@ -161,6 +167,7 @@ async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorag
161167
storage=mock_storage,
162168
client_id="test-client-id",
163169
client_secret="test-client-secret",
170+
issuer="https://api.example.com",
164171
)
165172
provider.context.oauth_metadata = OAuthMetadata(
166173
issuer=AnyHttpUrl("https://api.example.com"),
@@ -192,6 +199,7 @@ async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
192199
storage=mock_storage,
193200
client_id="test-client-id",
194201
assertion_provider=mock_assertion_provider,
202+
issuer="https://api.example.com",
195203
)
196204

197205
# client_info is set during _initialize
@@ -215,6 +223,7 @@ async def mock_assertion_provider(audience: str) -> str:
215223
client_id="test-client-id",
216224
assertion_provider=mock_assertion_provider,
217225
scope="read write",
226+
issuer="https://auth.example.com",
218227
)
219228
provider.context.oauth_metadata = OAuthMetadata(
220229
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -246,6 +255,7 @@ async def mock_assertion_provider(audience: str) -> str:
246255
storage=mock_storage,
247256
client_id="test-client-id",
248257
assertion_provider=mock_assertion_provider,
258+
issuer="https://auth.example.com",
249259
)
250260
provider.context.oauth_metadata = OAuthMetadata(
251261
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -436,6 +446,65 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser
436446
await flow.aclose()
437447

438448

449+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
450+
def test_constructing_without_issuer_is_deprecated(mock_storage: MockTokenStorage, kind: str) -> None:
451+
"""SDK-defined: leaving `issuer` out is allowed, and the provider says at construction that
452+
token requests will follow whichever authorization server the MCP server advertises."""
453+
454+
async def assertion_provider(audience: str) -> str:
455+
raise NotImplementedError
456+
457+
with pytest.warns(MCPDeprecationWarning) as recorded:
458+
if kind == "secret":
459+
ClientCredentialsOAuthProvider(
460+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
461+
)
462+
else:
463+
PrivateKeyJWTOAuthProvider(
464+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
465+
)
466+
467+
[warning] = recorded
468+
assert warning.filename == __file__
469+
assert str(warning.message) == (
470+
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
471+
"decides which authorization server receives this client's credentials; pass "
472+
"issuer=<your authorization server's issuer URL> so they are only ever sent there."
473+
)
474+
475+
476+
@pytest.mark.anyio
477+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
478+
async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered(
479+
mock_storage: MockTokenStorage, kind: str
480+
) -> None:
481+
"""SDK-defined: with no `issuer` configured the token request is built from whatever metadata
482+
discovery produced, as before."""
483+
484+
async def assertion_provider(audience: str) -> str:
485+
return "jwt"
486+
487+
with pytest.warns(MCPDeprecationWarning, match="Omitting `issuer` is deprecated"):
488+
if kind == "secret":
489+
provider: OAuthClientProvider = ClientCredentialsOAuthProvider(
490+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
491+
)
492+
else:
493+
provider = PrivateKeyJWTOAuthProvider(
494+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
495+
)
496+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
497+
498+
token_request = await _answer_discovery(
499+
flow,
500+
authorization_server="https://elsewhere.example.com",
501+
metadata=_metadata_for("https://elsewhere.example.com"),
502+
)
503+
504+
assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token")
505+
await flow.aclose()
506+
507+
439508
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
440509
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
441510
error on both machine-to-machine providers."""

tests/docs_src/test_oauth_clients.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None:
105105
storage=tutorial002.InMemoryTokenStorage(),
106106
client_id="reporting-agent",
107107
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
108+
issuer="http://localhost:9000",
108109
)
109110
assert isinstance(provider, OAuthClientProvider)
110111
assert isinstance(provider, httpx2.Auth)

tests/interaction/auth/test_lifecycle.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
373373
client_id="m2m-client",
374374
client_secret="m2m-secret",
375375
scope="mcp",
376+
issuer=BASE_URL,
376377
)
377378

378379
with anyio.fail_after(5):
@@ -424,6 +425,7 @@ async def assertion_provider(audience: str) -> str:
424425
client_id="m2m-jwt-client",
425426
assertion_provider=assertion_provider,
426427
scope="mcp",
428+
issuer=BASE_URL,
427429
)
428430

429431
with anyio.fail_after(5):

0 commit comments

Comments
 (0)