Skip to content

Commit 8031fe6

Browse files
committed
Warn when a pre-provisioned OAuth client is created without an issuer
ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider send fixed credentials to whichever authorization server discovery yields unless `issuer=` names the one they belong to. Leaving it out stays allowed, but the provider now says so at construction with a UserWarning that names the server URL and the keyword to pass, so the choice is visible rather than silent. Nothing else changes: with `issuer=` set there is no warning, and a value that is not an http(s) URL is still a ValueError. The example story and the interaction tests pass `issuer=` (their authorization server is known); the extension tests that exercise the no-issuer path opt in to the warning explicitly.
1 parent d060b36 commit 8031fe6

6 files changed

Lines changed: 94 additions & 9 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. Leave it out and the provider uses whichever authorization server discovery finds, and says so with a `UserWarning` when it is constructed.
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

‎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 — six 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: 16 additions & 6 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
@@ -22,8 +23,16 @@
2223
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2324

2425

25-
def _checked_issuer(issuer: str | None) -> str | None:
26-
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
26+
def _checked_issuer(issuer: str | None, provider: str, server_url: str) -> str | None:
27+
if issuer is None:
28+
warnings.warn(
29+
f"{provider} created without `issuer`: the client credentials will be sent to whichever "
30+
f"authorization server {server_url} advertises. Pass issuer=<the issuer URL your authorization "
31+
"server's metadata reports> so that token requests are only ever built for that server.",
32+
stacklevel=3,
33+
)
34+
return None
35+
if urlparse(issuer).scheme not in ("http", "https"):
2736
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
2837
return issuer
2938

@@ -98,7 +107,7 @@ def __init__(
98107
`client_id` and `client_secret`. When set, token requests are only built from
99108
discovered authorization server metadata whose `issuer` is exactly this string;
100109
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
101-
authorization server discovery yields is used.
110+
authorization server discovery yields is used, and a `UserWarning` says so.
102111
"""
103112
# Build minimal client_metadata for the base class
104113
client_metadata = OAuthClientMetadata(
@@ -108,7 +117,7 @@ def __init__(
108117
scope=scope,
109118
)
110119
super().__init__(server_url, client_metadata, storage, None, None)
111-
self._issuer = _checked_issuer(issuer)
120+
self._issuer = _checked_issuer(issuer, type(self).__name__, server_url)
112121
# Store client_info to be set during _initialize - no dynamic registration needed
113122
self._fixed_client_info = OAuthClientInformationFull(
114123
redirect_uris=None,
@@ -327,7 +336,8 @@ def __init__(
327336
registered with. When set, an assertion is only minted, and token requests
328337
are only built, once authorization server metadata whose `issuer` is exactly this
329338
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
330-
When omitted, whichever authorization server discovery yields is used.
339+
When omitted, whichever authorization server discovery yields is used, and a
340+
`UserWarning` says so.
331341
"""
332342
# Build minimal client_metadata for the base class
333343
client_metadata = OAuthClientMetadata(
@@ -338,7 +348,7 @@ def __init__(
338348
)
339349
super().__init__(server_url, client_metadata, storage, None, None)
340350
self._assertion_provider = assertion_provider
341-
self._issuer = _checked_issuer(issuer)
351+
self._issuer = _checked_issuer(issuer, type(self).__name__, server_url)
342352
# Store client_info to be set during _initialize - no dynamic registration needed
343353
self._fixed_client_info = OAuthClientInformationFull(
344354
redirect_uris=None,

‎tests/client/auth/extensions/test_client_credentials.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
5757
storage=mock_storage,
5858
client_id="test-client-id",
5959
client_secret="test-client-secret",
60+
issuer="https://api.example.com",
6061
)
6162

6263
# client_info is set during _initialize
@@ -77,6 +78,7 @@ async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
7778
client_id="test-client-id",
7879
client_secret="test-client-secret",
7980
scope="read write",
81+
issuer="https://api.example.com",
8082
)
8183

8284
await provider._initialize()
@@ -92,6 +94,7 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage
9294
client_id="test-client-id",
9395
client_secret="test-client-secret",
9496
token_endpoint_auth_method="client_secret_post",
97+
issuer="https://api.example.com",
9598
)
9699

97100
await provider._initialize()
@@ -107,6 +110,7 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
107110
client_id="test-client-id",
108111
client_secret="test-client-secret",
109112
scope="read write",
113+
issuer="https://api.example.com",
110114
)
111115
provider.context.oauth_metadata = OAuthMetadata(
112116
issuer=AnyHttpUrl("https://api.example.com"),
@@ -135,6 +139,7 @@ async def test_exchange_token_client_secret_post_includes_client_id(self, mock_s
135139
client_secret="test-client-secret",
136140
token_endpoint_auth_method="client_secret_post",
137141
scope="read write",
142+
issuer="https://api.example.com",
138143
)
139144
await provider._initialize()
140145
provider.context.oauth_metadata = OAuthMetadata(
@@ -161,6 +166,7 @@ async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorag
161166
storage=mock_storage,
162167
client_id="test-client-id",
163168
client_secret="test-client-secret",
169+
issuer="https://api.example.com",
164170
)
165171
provider.context.oauth_metadata = OAuthMetadata(
166172
issuer=AnyHttpUrl("https://api.example.com"),
@@ -192,6 +198,7 @@ async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
192198
storage=mock_storage,
193199
client_id="test-client-id",
194200
assertion_provider=mock_assertion_provider,
201+
issuer="https://api.example.com",
195202
)
196203

197204
# client_info is set during _initialize
@@ -215,6 +222,7 @@ async def mock_assertion_provider(audience: str) -> str:
215222
client_id="test-client-id",
216223
assertion_provider=mock_assertion_provider,
217224
scope="read write",
225+
issuer="https://auth.example.com",
218226
)
219227
provider.context.oauth_metadata = OAuthMetadata(
220228
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -246,6 +254,7 @@ async def mock_assertion_provider(audience: str) -> str:
246254
storage=mock_storage,
247255
client_id="test-client-id",
248256
assertion_provider=mock_assertion_provider,
257+
issuer="https://auth.example.com",
249258
)
250259
provider.context.oauth_metadata = OAuthMetadata(
251260
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -436,6 +445,68 @@ async def test_provider_picks_its_configured_issuer_among_several_advertised_ser
436445
await flow.aclose()
437446

438447

448+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
449+
def test_constructing_without_issuer_warns_where_the_credentials_will_go(
450+
mock_storage: MockTokenStorage, kind: str
451+
) -> None:
452+
"""SDK-defined: leaving `issuer` out is allowed, and the provider says at construction that
453+
token requests will follow whichever authorization server the MCP server advertises."""
454+
455+
async def assertion_provider(audience: str) -> str:
456+
raise NotImplementedError
457+
458+
with pytest.warns(UserWarning) as recorded:
459+
if kind == "secret":
460+
ClientCredentialsOAuthProvider(
461+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
462+
)
463+
else:
464+
PrivateKeyJWTOAuthProvider(
465+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
466+
)
467+
468+
[warning] = recorded
469+
assert warning.filename == __file__
470+
provider = "ClientCredentialsOAuthProvider" if kind == "secret" else "PrivateKeyJWTOAuthProvider"
471+
assert str(warning.message) == (
472+
f"{provider} created without `issuer`: the client credentials will be sent to whichever "
473+
"authorization server https://api.example.com/v1/mcp advertises. Pass issuer=<the issuer URL your "
474+
"authorization server's metadata reports> so that token requests are only ever built for that server."
475+
)
476+
477+
478+
@pytest.mark.anyio
479+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
480+
async def test_without_issuer_the_exchange_follows_whichever_server_was_discovered(
481+
mock_storage: MockTokenStorage, kind: str
482+
) -> None:
483+
"""SDK-defined: with no `issuer` configured the token request is built from whatever metadata
484+
discovery produced, as before."""
485+
486+
async def assertion_provider(audience: str) -> str:
487+
return "jwt"
488+
489+
with pytest.warns(UserWarning, match="created without `issuer`"):
490+
if kind == "secret":
491+
provider: OAuthClientProvider = ClientCredentialsOAuthProvider(
492+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", client_secret="s"
493+
)
494+
else:
495+
provider = PrivateKeyJWTOAuthProvider(
496+
server_url=_SERVER_URL, storage=mock_storage, client_id="c", assertion_provider=assertion_provider
497+
)
498+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
499+
500+
token_request = await _answer_discovery(
501+
flow,
502+
authorization_server="https://elsewhere.example.com",
503+
metadata=_metadata_for("https://elsewhere.example.com"),
504+
)
505+
506+
assert (token_request.method, str(token_request.url)) == ("POST", "https://elsewhere.example.com/token")
507+
await flow.aclose()
508+
509+
439510
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
440511
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
441512
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)