Skip to content

Commit 3900bb2

Browse files
committed
[v1.x] Deprecate constructing the pre-provisioned OAuth clients without an issuer
Backport of #3435. Omitting `issuer=` on ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider keeps working and now warns at construction that it will be required in 3.0, saying why (without it the MCP server decides which authorization server receives the credentials) and what to pass. Difference from main: 1.x has no MCPDeprecationWarning, so the category is DeprecationWarning like the other deprecations on this branch, and there is no deprecations docs page to list it on; the docstrings carry the note and the module's examples pass `issuer=`.
1 parent e5b6719 commit 3900bb2

2 files changed

Lines changed: 87 additions & 5 deletions

File tree

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010

1111
import time
12+
import warnings
1213
from collections.abc import Awaitable, Callable
1314
from typing import Any, Literal
1415
from urllib.parse import urlparse
@@ -25,7 +26,16 @@
2526

2627

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

@@ -99,7 +109,8 @@ def __init__(
99109
issuer: The issuer identifier of the authorization server that issued
100110
`client_id` and `client_secret`. When set, token requests are only built from
101111
discovered authorization server metadata whose `issuer` is exactly this string;
102-
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
112+
otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
113+
(`DeprecationWarning`) and it will be required in 3.0; until then, whichever
103114
authorization server discovery yields is used.
104115
"""
105116
# Build minimal client_metadata for the base class
@@ -170,6 +181,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
170181
storage=my_token_storage,
171182
client_id="my-client-id",
172183
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
184+
issuer="https://auth.example.com",
173185
)
174186
```
175187
@@ -204,6 +216,7 @@ class SignedJWTParameters(BaseModel):
204216
storage=my_token_storage,
205217
client_id="my-client-id",
206218
assertion_provider=jwt_params.create_assertion_provider(),
219+
issuer="https://auth.example.com",
207220
)
208221
```
209222
"""
@@ -269,6 +282,7 @@ async def get_workload_identity_token(audience: str) -> str:
269282
storage=my_token_storage,
270283
client_id="my-client-id",
271284
assertion_provider=get_workload_identity_token,
285+
issuer="https://auth.example.com",
272286
)
273287
```
274288
@@ -282,6 +296,7 @@ async def get_workload_identity_token(audience: str) -> str:
282296
storage=my_token_storage,
283297
client_id="my-client-id",
284298
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
299+
issuer="https://auth.example.com",
285300
)
286301
```
287302
@@ -300,6 +315,7 @@ async def get_workload_identity_token(audience: str) -> str:
300315
storage=my_token_storage,
301316
client_id="my-client-id",
302317
assertion_provider=jwt_params.create_assertion_provider(),
318+
issuer="https://auth.example.com",
303319
)
304320
```
305321
"""
@@ -329,7 +345,8 @@ def __init__(
329345
registered with. When set, an assertion is only minted, and token requests
330346
are only built, once authorization server metadata whose `issuer` is exactly this
331347
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
332-
When omitted, whichever authorization server discovery yields is used.
348+
Omitting it is deprecated (`DeprecationWarning`) and it will be required in
349+
3.0; until then, whichever authorization server discovery yields is used.
333350
"""
334351
# Build minimal client_metadata for the base class
335352
client_metadata = OAuthClientMetadata(
@@ -474,8 +491,6 @@ def __init__(
474491
timeout: float = 300.0,
475492
jwt_parameters: JWTParameters | None = None,
476493
) -> None:
477-
import warnings
478-
479494
warnings.warn(
480495
"RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
481496
"or PrivateKeyJWTOAuthProvider instead.",

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
189189
storage=mock_storage,
190190
client_id="test-client-id",
191191
client_secret="test-client-secret",
192+
issuer="https://api.example.com",
192193
)
193194

194195
# client_info is set during _initialize
@@ -209,6 +210,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
209210
client_id="test-client-id",
210211
client_secret="test-client-secret",
211212
scopes="read write",
213+
issuer="https://api.example.com",
212214
)
213215

214216
await provider._initialize()
@@ -224,6 +226,7 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage
224226
client_id="test-client-id",
225227
client_secret="test-client-secret",
226228
token_endpoint_auth_method="client_secret_post",
229+
issuer="https://api.example.com",
227230
)
228231

229232
await provider._initialize()
@@ -239,6 +242,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
239242
client_id="test-client-id",
240243
client_secret="test-client-secret",
241244
scopes="read write",
245+
issuer="https://api.example.com",
242246
)
243247
provider.context.oauth_metadata = OAuthMetadata(
244248
issuer=AnyHttpUrl("https://api.example.com"),
@@ -265,6 +269,7 @@ async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorag
265269
storage=mock_storage,
266270
client_id="test-client-id",
267271
client_secret="test-client-secret",
272+
issuer="https://api.example.com",
268273
)
269274
provider.context.oauth_metadata = OAuthMetadata(
270275
issuer=AnyHttpUrl("https://api.example.com"),
@@ -296,6 +301,7 @@ async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
296301
storage=mock_storage,
297302
client_id="test-client-id",
298303
assertion_provider=mock_assertion_provider,
304+
issuer="https://api.example.com",
299305
)
300306

301307
# client_info is set during _initialize
@@ -319,6 +325,7 @@ async def mock_assertion_provider(audience: str) -> str:
319325
client_id="test-client-id",
320326
assertion_provider=mock_assertion_provider,
321327
scopes="read write",
328+
issuer="https://auth.example.com",
322329
)
323330
provider.context.oauth_metadata = OAuthMetadata(
324331
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -350,6 +357,7 @@ async def mock_assertion_provider(audience: str) -> str:
350357
storage=mock_storage,
351358
client_id="test-client-id",
352359
assertion_provider=mock_assertion_provider,
360+
issuer="https://auth.example.com",
353361
)
354362
provider.context.oauth_metadata = OAuthMetadata(
355363
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -541,6 +549,65 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser
541549
await flow.aclose()
542550

543551

552+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
553+
def test_constructing_without_issuer_is_deprecated(mock_storage: MockTokenStorage, kind: str) -> None:
554+
"""SDK-defined: leaving `issuer` out is allowed but deprecated, and the provider says so at
555+
construction."""
556+
557+
async def assertion_provider(audience: str) -> str:
558+
raise NotImplementedError
559+
560+
with pytest.warns(DeprecationWarning) as recorded:
561+
if kind == "secret":
562+
ClientCredentialsOAuthProvider(
563+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
564+
)
565+
else:
566+
PrivateKeyJWTOAuthProvider(
567+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
568+
)
569+
570+
[warning] = recorded
571+
assert warning.filename == __file__
572+
assert str(warning.message) == (
573+
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
574+
"decides which authorization server receives this client's credentials; pass "
575+
"issuer=<your authorization server's issuer URL> so they are only ever sent there."
576+
)
577+
578+
579+
@pytest.mark.anyio
580+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
581+
async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered(
582+
mock_storage: MockTokenStorage, kind: str
583+
) -> None:
584+
"""SDK-defined: with no `issuer` configured the token request is built from whatever metadata
585+
discovery produced, as before."""
586+
587+
async def assertion_provider(audience: str) -> str:
588+
return "jwt"
589+
590+
with pytest.warns(DeprecationWarning, match="Omitting `issuer` is deprecated"):
591+
if kind == "secret":
592+
provider: OAuthClientProvider = ClientCredentialsOAuthProvider(
593+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
594+
)
595+
else:
596+
provider = PrivateKeyJWTOAuthProvider(
597+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
598+
)
599+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
600+
601+
token_request = await _answer_discovery(
602+
flow,
603+
authorization_server="https://elsewhere.example.com",
604+
metadata=_metadata_for("https://elsewhere.example.com"),
605+
)
606+
607+
assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token")
608+
await flow.aclose()
609+
610+
544611
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
545612
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
546613
error on both machine-to-machine providers."""

0 commit comments

Comments
 (0)