99"""
1010
1111import time
12+ import warnings
1213from collections .abc import Awaitable , Callable
1314from typing import Any , Literal
15+ from urllib .parse import urlparse
1416from uuid import uuid4
1517
1618import httpx
1719import jwt
1820from pydantic import BaseModel , Field
1921
2022from mcp .client .auth import OAuthClientProvider , OAuthFlowError , OAuthTokenError , TokenStorage
23+ from mcp .client .auth .oauth2 import OAuthContext
24+ from mcp .client .auth .utils import issuers_match
2125from mcp .shared .auth import OAuthClientInformationFull , OAuthClientMetadata
2226
2327
28+ def _checked_issuer (issuer : str | None ) -> str | None :
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" ):
39+ raise ValueError (f"issuer must be the authorization server's http(s) issuer URL, got { issuer !r} " )
40+ return issuer
41+
42+
43+ def _preferred_authorization_server (advertised : list [str ], issuer : str | None ) -> str :
44+ """The advertised server matching the configured issuer if there is one, else the first."""
45+ return next (
46+ (server for server in advertised if issuer is not None and issuers_match (server , issuer )), advertised [0 ]
47+ )
48+
49+
50+ def _require_metadata_for_configured_issuer (context : OAuthContext , issuer : str | None ) -> None :
51+ """With an issuer configured, a token request is only built from metadata discovered for that issuer.
52+
53+ Anything else held is dropped along with the tokens, so the next request starts discovery afresh
54+ rather than refreshing against it.
55+ """
56+ if issuer is None :
57+ return
58+ metadata = context .oauth_metadata
59+ if metadata is not None and issuers_match (str (metadata .issuer ), issuer ):
60+ return
61+ context .oauth_metadata = None
62+ context .clear_tokens ()
63+ if metadata is None :
64+ raise OAuthFlowError (f"No authorization server metadata discovered for configured issuer { issuer } " )
65+ raise OAuthFlowError (f"Authorization server metadata issuer mismatch: { metadata .issuer } != { issuer } " )
66+
67+
2468class ClientCredentialsOAuthProvider (OAuthClientProvider ):
2569 """OAuth provider for client_credentials grant with client_id + client_secret.
2670
2771 This provider sets client_info directly, bypassing dynamic client registration.
2872 Use this when you already have client credentials (client_id and client_secret).
73+ Pass `issuer` to name the authorization server those credentials belong to: token
74+ requests are then only built from authorization server metadata for that issuer, and
75+ the flow stops if the MCP server leads anywhere else.
2976
3077 Example:
3178 ```python
@@ -34,6 +81,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3481 storage=my_token_storage,
3582 client_id="my-client-id",
3683 client_secret="my-client-secret",
84+ issuer="https://auth.example.com",
3785 )
3886 ```
3987 """
@@ -46,6 +94,7 @@ def __init__(
4694 client_secret : str ,
4795 token_endpoint_auth_method : Literal ["client_secret_basic" , "client_secret_post" ] = "client_secret_basic" ,
4896 scopes : str | None = None ,
97+ issuer : str | None = None ,
4998 ) -> None :
5099 """Initialize client_credentials OAuth provider.
51100
@@ -57,6 +106,12 @@ def __init__(
57106 token_endpoint_auth_method: Authentication method for token endpoint.
58107 Either "client_secret_basic" (default) or "client_secret_post".
59108 scopes: Optional space-separated list of scopes to request.
109+ issuer: The issuer identifier of the authorization server that issued
110+ `client_id` and `client_secret`. When set, token requests are only built from
111+ discovered authorization server metadata whose `issuer` is exactly this string;
112+ otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
113+ (`DeprecationWarning`) and it will be required in 3.0; until then, whichever
114+ authorization server discovery yields is used.
60115 """
61116 # Build minimal client_metadata for the base class
62117 client_metadata = OAuthClientMetadata (
@@ -66,6 +121,7 @@ def __init__(
66121 scope = scopes ,
67122 )
68123 super ().__init__ (server_url , client_metadata , storage , None , None , 300.0 )
124+ self ._issuer = _checked_issuer (issuer )
69125 # Store client_info to be set during _initialize - no dynamic registration needed
70126 self ._fixed_client_info = OAuthClientInformationFull (
71127 redirect_uris = None ,
@@ -82,12 +138,17 @@ async def _initialize(self) -> None:
82138 self .context .client_info = self ._fixed_client_info
83139 self ._initialized = True
84140
141+ def _select_authorization_server (self , advertised : list [str ]) -> str :
142+ return _preferred_authorization_server (advertised , self ._issuer )
143+
85144 async def _perform_authorization (self ) -> httpx .Request :
86145 """Perform client_credentials authorization."""
87146 return await self ._exchange_token_client_credentials ()
88147
89148 async def _exchange_token_client_credentials (self ) -> httpx .Request :
90149 """Build token exchange request for client_credentials grant."""
150+ _require_metadata_for_configured_issuer (self .context , self ._issuer )
151+
91152 token_data : dict [str , Any ] = {
92153 "grant_type" : "client_credentials" ,
93154 }
@@ -120,6 +181,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
120181 storage=my_token_storage,
121182 client_id="my-client-id",
122183 assertion_provider=static_assertion_provider(my_prebuilt_jwt),
184+ issuer="https://auth.example.com",
123185 )
124186 ```
125187
@@ -154,6 +216,7 @@ class SignedJWTParameters(BaseModel):
154216 storage=my_token_storage,
155217 client_id="my-client-id",
156218 assertion_provider=jwt_params.create_assertion_provider(),
219+ issuer="https://auth.example.com",
157220 )
158221 ```
159222 """
@@ -198,7 +261,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
198261
199262 The JWT assertion's audience MUST be the authorization server's issuer identifier
200263 (per RFC 7523bis security updates). The `assertion_provider` callback receives
201- this audience value and must return a JWT with that audience.
264+ this audience value and must return a JWT with that audience. Pass `issuer` to name
265+ the authorization server this client is registered with: an assertion is then only
266+ minted once metadata for that issuer has been discovered, and token requests are only
267+ built from that metadata.
202268
203269 **Option 1: Pre-built JWT via Workload Identity Federation**
204270
@@ -216,6 +282,7 @@ async def get_workload_identity_token(audience: str) -> str:
216282 storage=my_token_storage,
217283 client_id="my-client-id",
218284 assertion_provider=get_workload_identity_token,
285+ issuer="https://auth.example.com",
219286 )
220287 ```
221288
@@ -229,6 +296,7 @@ async def get_workload_identity_token(audience: str) -> str:
229296 storage=my_token_storage,
230297 client_id="my-client-id",
231298 assertion_provider=static_assertion_provider(my_prebuilt_jwt),
299+ issuer="https://auth.example.com",
232300 )
233301 ```
234302
@@ -247,6 +315,7 @@ async def get_workload_identity_token(audience: str) -> str:
247315 storage=my_token_storage,
248316 client_id="my-client-id",
249317 assertion_provider=jwt_params.create_assertion_provider(),
318+ issuer="https://auth.example.com",
250319 )
251320 ```
252321 """
@@ -258,6 +327,7 @@ def __init__(
258327 client_id : str ,
259328 assertion_provider : Callable [[str ], Awaitable [str ]],
260329 scopes : str | None = None ,
330+ issuer : str | None = None ,
261331 ) -> None :
262332 """Initialize private_key_jwt OAuth provider.
263333
@@ -271,6 +341,12 @@ def __init__(
271341 `static_assertion_provider()` for pre-built JWTs, or provide your own
272342 callback for workload identity federation.
273343 scopes: Optional space-separated list of scopes to request.
344+ issuer: The issuer identifier of the authorization server `client_id` is
345+ registered with. When set, an assertion is only minted, and token requests
346+ are only built, once authorization server metadata whose `issuer` is exactly this
347+ string has been discovered; otherwise the flow stops with `OAuthFlowError`.
348+ Omitting it is deprecated (`DeprecationWarning`) and it will be required in
349+ 3.0; until then, whichever authorization server discovery yields is used.
274350 """
275351 # Build minimal client_metadata for the base class
276352 client_metadata = OAuthClientMetadata (
@@ -281,6 +357,7 @@ def __init__(
281357 )
282358 super ().__init__ (server_url , client_metadata , storage , None , None , 300.0 )
283359 self ._assertion_provider = assertion_provider
360+ self ._issuer = _checked_issuer (issuer )
284361 # Store client_info to be set during _initialize - no dynamic registration needed
285362 self ._fixed_client_info = OAuthClientInformationFull (
286363 redirect_uris = None ,
@@ -296,6 +373,9 @@ async def _initialize(self) -> None:
296373 self .context .client_info = self ._fixed_client_info
297374 self ._initialized = True
298375
376+ def _select_authorization_server (self , advertised : list [str ]) -> str :
377+ return _preferred_authorization_server (advertised , self ._issuer )
378+
299379 async def _perform_authorization (self ) -> httpx .Request :
300380 """Perform client_credentials authorization with private_key_jwt."""
301381 return await self ._exchange_token_client_credentials ()
@@ -316,6 +396,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
316396
317397 async def _exchange_token_client_credentials (self ) -> httpx .Request :
318398 """Build token exchange request for client_credentials grant with private_key_jwt."""
399+ _require_metadata_for_configured_issuer (self .context , self ._issuer )
400+
319401 token_data : dict [str , Any ] = {
320402 "grant_type" : "client_credentials" ,
321403 }
@@ -409,8 +491,6 @@ def __init__(
409491 timeout : float = 300.0 ,
410492 jwt_parameters : JWTParameters | None = None ,
411493 ) -> None :
412- import warnings
413-
414494 warnings .warn (
415495 "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
416496 "or PrivateKeyJWTOAuthProvider instead." ,
0 commit comments