Skip to content

Commit 6b0166f

Browse files
committed
Split the registration request model from the registered-client record
OAuthClientInformationFull, the client's parse of the authorization server's Dynamic Client Registration response, inherited from OAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send. RFC 7591 3.2.1 says otherwise: the server may reject or replace any requested metadata value, and real servers echo an application_type outside OIDC Registration's web/native, an explicit null, an auth method the SDK does not implement, or an empty redirect_uris. Each raised ValidationError on a 2xx response, after the server had already provisioned the client, so the registration was discarded and orphaned. Make the two models siblings over a shared OAuthClientMetadataBase. The request keeps its strict types, so the SDK still refuses to send an unregistered application_type. The record accepts what a server may echo: application_type and token_endpoint_auth_method are str | None (with an echoed "" read as absent, as the optional URL fields already were), grant_types is list[str], and redirect_uris may be absent or empty. client_id is now required, as RFC 7591 3.2.1 makes it in the response. Whether a substituted value is usable is judged where it matters, not at parse: an auth method the client cannot apply is reported as an OAuthRegistrationError when the registration completes, before the record is stored or any interactive authorization begins, and prepare_token_auth reports the same for a stored record. The recognized set is derived from the one TokenEndpointAuthMethod type so the two cannot drift. The bundled registration endpoint now returns all registered metadata in its 201 response, building the record from the validated request's dump so a field can no longer be silently dropped from the echo; it previously omitted application_type, reporting the default in place of a client's "web".
1 parent 11934c9 commit 6b0166f

11 files changed

Lines changed: 435 additions & 80 deletions

File tree

docs/migration.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,6 +2179,27 @@ client_metadata = OAuthClientMetadata(
21792179

21802180
Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.
21812181

2182+
### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata
2183+
2184+
`OAuthClientMetadata` is the registration request a client sends; `OAuthClientInformationFull` is the authorization server's record of a registered client, parsed from its Dynamic Client Registration response. In v1 the second inherited from the first, which typed the response as though it had to be a request this SDK would send. It does not: [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) lets the server "reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and real servers return an `application_type` outside OIDC Registration's `web`/`native`, an explicit `null`, a `token_endpoint_auth_method` the SDK does not implement, or an empty `redirect_uris`. The inherited strict types turned each of those into a `ValidationError` on a 2xx response - after the server had already provisioned the client, so the registration was discarded and orphaned.
2185+
2186+
The two are now siblings over a shared `OAuthClientMetadataBase`. `OAuthClientMetadata` keeps its strict types (the SDK still refuses to *send* an unregistered `application_type`), while `OAuthClientInformationFull` accepts what a server may echo:
2187+
2188+
```python
2189+
# v1
2190+
class OAuthClientInformationFull(OAuthClientMetadata): ...
2191+
2192+
# v2
2193+
class OAuthClientMetadata(OAuthClientMetadataBase): ... # request: strict
2194+
class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record: tolerant
2195+
```
2196+
2197+
On `OAuthClientInformationFull`, `application_type` and `token_endpoint_auth_method` are now `str | None`, `grant_types` is `list[str]`, and `redirect_uris` is optional (`list[AnyUrl] | None`, no minimum length). `client_id` is now required (`str`): [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) makes it mandatory in the response, and a record of a registered client without one was never meaningful. Code that only reads these fields is unaffected. Code that relied on `isinstance(client_info, OAuthClientMetadata)`, or passed an `OAuthClientInformationFull` where an `OAuthClientMetadata` is expected, must reference the record type directly. `validate_scope()` and `validate_redirect_uri()` moved with the record: they are methods of `OAuthClientInformationFull` (the type authorization-server code holds) and are no longer available on `OAuthClientMetadata`.
2198+
2199+
A registration response the server sends is no longer rejected on these fields (an echoed `""` for `token_endpoint_auth_method` or `application_type` is treated as absent, as it already was for the optional URL fields). Whether a substituted value is usable is decided where the value is used: a registered `token_endpoint_auth_method` this SDK does not recognize (anything other than `none`, `client_secret_post`, `client_secret_basic`, or `private_key_jwt`) now raises `OAuthTokenError` naming the method at the token exchange, rather than the token request being sent unauthenticated for the server to reject.
2200+
2201+
The SDK's own registration endpoint also now returns all registered metadata in its 201 response (RFC 7591 §3.2.1), including the client's `application_type`, which v1 dropped from the echo (silently reporting the default in place of a client's `"web"`).
2202+
21822203
### Stricter client authentication at `/token` and `/revoke`
21832204

21842205
v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records.

src/mcp/client/auth/oauth2.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
import time
1212
from collections.abc import AsyncGenerator, Awaitable, Callable
1313
from dataclasses import dataclass, field
14-
from typing import Any, Protocol
14+
from typing import Any, Protocol, get_args
1515
from urllib.parse import quote, urlencode, urljoin, urlparse
1616

1717
import anyio
1818
import httpx2
1919
from mcp_types.version import is_version_at_least
2020
from pydantic import BaseModel, Field, ValidationError
2121

22-
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
22+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
@@ -48,6 +48,7 @@
4848
OAuthMetadata,
4949
OAuthToken,
5050
ProtectedResourceMetadata,
51+
TokenEndpointAuthMethod,
5152
)
5253
from mcp.shared.auth_utils import (
5354
calculate_token_expiry,
@@ -58,6 +59,33 @@
5859

5960
logger = logging.getLogger(__name__)
6061

62+
# Methods `OAuthContext.prepare_token_auth` recognizes on a registered client, derived from the
63+
# same set the SDK is willing to request so the two cannot drift. `None`/"none" send no client
64+
# secret; `private_key_jwt` adds no secret here (its assertion is supplied by
65+
# `PrivateKeyJWTOAuthProvider`). Anything else is a method this client cannot apply.
66+
_RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
67+
68+
69+
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
70+
"""Confirm a completed registration is one this client can act on.
71+
72+
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
73+
the client to "check the values in the response to determine if the registration is
74+
sufficient for use". The one substitution that makes the minted credentials unusable is a
75+
token-endpoint auth method this client cannot apply, so it is judged here - before the
76+
record is persisted or any interactive authorization begins - rather than surfacing later
77+
as an opaque failure at the token endpoint.
78+
79+
Raises:
80+
OAuthRegistrationError: The server registered the client with a
81+
`token_endpoint_auth_method` this client does not implement.
82+
"""
83+
if client_info.token_endpoint_auth_method not in _RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS:
84+
raise OAuthRegistrationError(
85+
"Authorization server registered the client with unsupported token_endpoint_auth_method "
86+
f"{client_info.token_endpoint_auth_method!r}"
87+
)
88+
6189

6290
class PKCEParameters(BaseModel):
6391
"""PKCE (Proof Key for Code Exchange) parameters."""
@@ -190,6 +218,12 @@ def prepare_token_auth(
190218
191219
Returns:
192220
Tuple of (updated_data, updated_headers)
221+
222+
Raises:
223+
OAuthTokenError: The registered client's `token_endpoint_auth_method` is one this
224+
client does not implement. The authorization server may assign a method other
225+
than the one requested (RFC 7591 §3.2.1); the registration record accepts it,
226+
and the mismatch is reported here, where the method is applied.
193227
"""
194228
if headers is None:
195229
headers = {} # pragma: no cover
@@ -212,7 +246,10 @@ def prepare_token_auth(
212246
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
213247
data["client_id"] = self.client_info.client_id
214248
data["client_secret"] = self.client_info.client_secret
215-
# For auth_method == "none", don't add any client_secret
249+
elif auth_method not in _RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS:
250+
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
251+
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
252+
# assertion in the provider that implements it, not here.
216253

217254
return data, headers
218255

@@ -664,6 +701,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
664701
)
665702
registration_response = yield registration_request
666703
client_information = await handle_registration_response(registration_response)
704+
check_registration_usable(client_information)
667705
# Only record the issuer when the registration above actually targeted
668706
# the discovered AS — either via its published registration_endpoint,
669707
# or because the resource-origin /register fallback is on the issuer's

src/mcp/client/auth/utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,8 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
301301
content = await response.aread()
302302
client_info = OAuthClientInformationFull.model_validate_json(content)
303303
return client_info
304-
except ValidationError as e: # pragma: no cover
305-
raise OAuthRegistrationError(f"Invalid registration response: {e}")
304+
except ValidationError as e:
305+
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
306306

307307

308308
def is_valid_client_metadata_url(url: str | None) -> bool:
@@ -381,8 +381,8 @@ def create_client_info_from_metadata_url(
381381
382382
Args:
383383
client_metadata_url: The URL to use as the client_id
384-
redirect_uris: The redirect URIs from the client metadata (passed through for
385-
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
384+
redirect_uris: The redirect URIs from the client metadata, recorded on the client
385+
information alongside the client_id
386386
387387
Returns:
388388
OAuthClientInformationFull with the URL as client_id

src/mcp/server/auth/handlers/register.py

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -112,27 +112,17 @@ async def handle(self, request: Request) -> Response:
112112
else None
113113
)
114114

115-
client_info = OAuthClientInformationFull(
116-
client_id=client_id,
117-
client_id_issued_at=client_id_issued_at,
118-
client_secret=client_secret,
119-
client_secret_expires_at=client_secret_expires_at,
120-
# passthrough information from the client request
121-
redirect_uris=client_metadata.redirect_uris,
122-
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
123-
grant_types=client_metadata.grant_types,
124-
response_types=client_metadata.response_types,
125-
client_name=client_metadata.client_name,
126-
client_uri=client_metadata.client_uri,
127-
logo_uri=client_metadata.logo_uri,
128-
scope=client_metadata.scope,
129-
contacts=client_metadata.contacts,
130-
tos_uri=client_metadata.tos_uri,
131-
policy_uri=client_metadata.policy_uri,
132-
jwks_uri=client_metadata.jwks_uri,
133-
jwks=client_metadata.jwks,
134-
software_id=client_metadata.software_id,
135-
software_version=client_metadata.software_version,
115+
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
116+
# the record is the whole validated request plus the credentials minted here - built
117+
# from the request's dump so no metadata field can be silently omitted from the echo.
118+
client_info = OAuthClientInformationFull.model_validate(
119+
{
120+
**client_metadata.model_dump(),
121+
"client_id": client_id,
122+
"client_id_issued_at": client_id_issued_at,
123+
"client_secret": client_secret,
124+
"client_secret_expires_at": client_secret_expires_at,
125+
}
136126
)
137127
try:
138128
# Register client

0 commit comments

Comments
 (0)