Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2179,6 +2179,27 @@

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.

### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata

`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.

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:

```python
# v1
class OAuthClientInformationFull(OAuthClientMetadata): ...

# v2
class OAuthClientMetadata(OAuthClientMetadataBase): ... # request: strict
class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record: tolerant
```

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`.

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.

Check warning on line 2199 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

Migration guide omits the registration-time OAuthRegistrationError surface for an unusable auth method

The migration entry says an unrecognized `token_endpoint_auth_method` "now raises `OAuthTokenError` naming the method at the token exchange", but that only describes the stored/pre-registered-record path (`prepare_token_auth`) — in the dynamic-registration flow this section is about, `check_registration_usable()` raises `OAuthRegistrationError` as soon as the 201 is processed, before the record is persisted or any authorize/token request is made. Consider naming both surfaces so a migrating read

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: DCR responses with an unsupported auth method fail immediately with OAuthRegistrationError, before token exchange, so this migration guidance describes the wrong exception and recovery point. Document the registration-time failure; OAuthTokenError remains relevant only for stored records reaching prepare_token_auth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 2199:

<comment>DCR responses with an unsupported auth method fail immediately with `OAuthRegistrationError`, before token exchange, so this migration guidance describes the wrong exception and recovery point. Document the registration-time failure; `OAuthTokenError` remains relevant only for stored records reaching `prepare_token_auth`.</comment>

<file context>
@@ -2179,6 +2179,27 @@ client_metadata = OAuthClientMetadata(
+
+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`.
+
+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.
+
+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"`).
</file context>
Suggested change
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.
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 after parsing: 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`) raises `OAuthRegistrationError` naming the method when Dynamic Client Registration completes, before the record is stored or authorization begins. A stored record with such a method raises `OAuthTokenError` if it reaches token exchange.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The migration entry says an unrecognized token_endpoint_auth_method "now raises OAuthTokenError naming the method at the token exchange", but that only describes the stored/pre-registered-record path (prepare_token_auth) — in the dynamic-registration flow this section is about, check_registration_usable() raises OAuthRegistrationError as soon as the 201 is processed, before the record is persisted or any authorize/token request is made. Consider naming both surfaces so a migrating reader doesn't catch OAuthTokenError and miss the OAuthRegistrationError raised on the more common DCR path.

Extended reasoning...

What the doc says vs. what the code does. The last paragraph of the new "OAuthClientInformationFull no longer subclasses OAuthClientMetadata" section (docs/migration.md:2199) states: a registered token_endpoint_auth_method the SDK does not recognize "now raises OAuthTokenError naming the method at the token exchange, rather than the token request being sent unauthenticated for the server to reject." That sentence accurately describes exactly one of the two surfaces this PR introduces — the prepare_token_auth check (src/mcp/client/auth/oauth2.py:249-250), which only fires for a stored or pre-registered record that reaches a token request.

The DCR path fails earlier, with a different exception. In the dynamic-registration flow — the primary subject of the section, per its own framing ("A registration response the server sends…") — the unusable method is caught by check_registration_usable() (src/mcp/client/auth/oauth2.py:69-88), invoked immediately after handle_registration_response in async_auth_flow (src/mcp/client/auth/oauth2.py:704). It raises OAuthRegistrationError the moment the 201 body is parsed — before the record is persisted via storage.set_client_info and before any /authorize or /token request is issued. A fresh registration with an unusable method can therefore never reach the token exchange the doc describes.

This is pinned by the PR's own tests. test_a_registration_assigning_an_unusable_auth_method_surfaces_as_a_registration_error (tests/interaction/auth/test_discovery.py) asserts OAuthRegistrationError is raised, storage.client_info is None, and no /authorize or /token request was recorded. The interaction requirement client-auth:dcr:substituted-metadata (tests/interaction/_requirements.py) states the same: "reported as an OAuthRegistrationError before the record is persisted or authorization begins." The PR description likewise says the failure is reported "at registration / token exchange" — naming both surfaces the doc collapses into one.

Concrete walk-through. (1) Client sends DCR request; server answers 201 with {"client_id": "jwt-only", "token_endpoint_auth_method": "client_secret_jwt"}. (2) handle_registration_response parses the tolerant OAuthClientInformationFull successfully. (3) check_registration_usable finds client_secret_jwt outside _RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS and raises OAuthRegistrationError at oauth2.py:83. (4) The flow aborts; no token exchange ever happens, so an except OAuthTokenError handler written from the migration-guide sentence never fires. The OAuthTokenError path only triggers when a record with such a method is loaded from storage (hand-supplied pre-registered credentials) and reaches prepare_token_auth.

Impact and why this is a nit. A migrating user reading this entry would model the failure as an OAuthTokenError at token exchange and could write handling that misses the OAuthRegistrationError on the more common DCR path. Mitigating: the DCR path's exception type did not actually change from v1 — v1 raised OAuthRegistrationError too (via the ValidationError wrap in handle_registration_response), so v2 raising it earlier with a clearer message requires no user-code migration; the only genuinely new behavior delta is the stored-record path, which the sentence documents correctly. This is a documentation-completeness issue, not a runtime defect.

Fix. One-clause amendment naming both surfaces, e.g.: "…is reported as OAuthRegistrationError when the registration response is processed (for dynamic registration), or as OAuthTokenError naming the method at the token exchange (for stored/pre-registered records), rather than the token request being sent unauthenticated for the server to reject."


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"`).

### Stricter client authentication at `/token` and `/revoke`

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.
Expand Down
44 changes: 41 additions & 3 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Protocol
from typing import Any, Protocol, get_args
from urllib.parse import quote, urlencode, urljoin, urlparse

import anyio
import httpx2
from mcp_types.version import is_version_at_least
from pydantic import BaseModel, Field, ValidationError

from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
Expand Down Expand Up @@ -48,6 +48,7 @@
OAuthMetadata,
OAuthToken,
ProtectedResourceMetadata,
TokenEndpointAuthMethod,
)
from mcp.shared.auth_utils import (
calculate_token_expiry,
Expand All @@ -58,6 +59,33 @@

logger = logging.getLogger(__name__)

# Methods `OAuthContext.prepare_token_auth` recognizes on a registered client, derived from the
# same set the SDK is willing to request so the two cannot drift. `None`/"none" send no client
# secret; `private_key_jwt` adds no secret here (its assertion is supplied by
# `PrivateKeyJWTOAuthProvider`). Anything else is a method this client cannot apply.
_RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Registration responses that omit token_endpoint_auth_method are accepted as no-auth clients, then token requests omit the required Basic credentials. Normalize an omitted method to RFC 7591's client_secret_basic default and fail clearly if no client secret was issued.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/oauth2.py, line 66:

<comment>Registration responses that omit `token_endpoint_auth_method` are accepted as no-auth clients, then token requests omit the required Basic credentials. Normalize an omitted method to RFC 7591's `client_secret_basic` default and fail clearly if no client secret was issued.</comment>

<file context>
@@ -58,6 +59,33 @@
+# same set the SDK is willing to request so the two cannot drift. `None`/"none" send no client
+# secret; `private_key_jwt` adds no secret here (its assertion is supplied by
+# `PrivateKeyJWTOAuthProvider`). Anything else is a method this client cannot apply.
+_RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
+
+
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A dynamically registered client echoed as private_key_jwt is treated as usable, but the normal OAuthClientProvider has no assertion provider for its authorization-code or refresh exchanges. It will persist the registration and later send the token request without client authentication. Since PrivateKeyJWTOAuthProvider only implements the separate client_credentials flow, consider excluding private_key_jwt from the methods accepted by this base flow so the registration fails clearly instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/oauth2.py, line 66:

<comment>A dynamically registered client echoed as `private_key_jwt` is treated as usable, but the normal `OAuthClientProvider` has no assertion provider for its authorization-code or refresh exchanges. It will persist the registration and later send the token request without client authentication. Since `PrivateKeyJWTOAuthProvider` only implements the separate `client_credentials` flow, consider excluding `private_key_jwt` from the methods accepted by this base flow so the registration fails clearly instead.</comment>

<file context>
@@ -58,6 +59,33 @@
+# same set the SDK is willing to request so the two cannot drift. `None`/"none" send no client
+# secret; `private_key_jwt` adds no secret here (its assertion is supplied by
+# `PrivateKeyJWTOAuthProvider`). Anything else is a method this client cannot apply.
+_RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
+
+
</file context>



def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
"""Confirm a completed registration is one this client can act on.

RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
the client to "check the values in the response to determine if the registration is
sufficient for use". The one substitution that makes the minted credentials unusable is a
token-endpoint auth method this client cannot apply, so it is judged here - before the
record is persisted or any interactive authorization begins - rather than surfacing later
as an opaque failure at the token endpoint.

Raises:
OAuthRegistrationError: The server registered the client with a
`token_endpoint_auth_method` this client does not implement.
"""
if client_info.token_endpoint_auth_method not in _RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthRegistrationError(
"Authorization server registered the client with unsupported token_endpoint_auth_method "
f"{client_info.token_endpoint_auth_method!r}"
)


class PKCEParameters(BaseModel):
"""PKCE (Proof Key for Code Exchange) parameters."""
Expand Down Expand Up @@ -190,6 +218,12 @@ def prepare_token_auth(

Returns:
Tuple of (updated_data, updated_headers)

Raises:
OAuthTokenError: The registered client's `token_endpoint_auth_method` is one this
client does not implement. The authorization server may assign a method other
than the one requested (RFC 7591 §3.2.1); the registration record accepts it,
and the mismatch is reported here, where the method is applied.
"""
if headers is None:
headers = {} # pragma: no cover
Expand All @@ -212,7 +246,10 @@ def prepare_token_auth(
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
data["client_id"] = self.client_info.client_id
data["client_secret"] = self.client_info.client_secret
# For auth_method == "none", don't add any client_secret
elif auth_method not in _RECOGNIZED_TOKEN_ENDPOINT_AUTH_METHODS:
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
# assertion in the provider that implements it, not here.

return data, headers

Expand Down Expand Up @@ -664,6 +701,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
)
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
check_registration_usable(client_information)
# Only record the issuer when the registration above actually targeted
# the discovered AS — either via its published registration_endpoint,
# or because the resource-origin /register fallback is on the issuer's
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
content = await response.aread()
client_info = OAuthClientInformationFull.model_validate_json(content)
return client_info
except ValidationError as e: # pragma: no cover
raise OAuthRegistrationError(f"Invalid registration response: {e}")
except ValidationError as e:
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e


def is_valid_client_metadata_url(url: str | None) -> bool:
Expand Down Expand Up @@ -381,8 +381,8 @@ def create_client_info_from_metadata_url(

Args:
client_metadata_url: The URL to use as the client_id
redirect_uris: The redirect URIs from the client metadata (passed through for
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
redirect_uris: The redirect URIs from the client metadata, recorded on the client
information alongside the client_id

Returns:
OAuthClientInformationFull with the URL as client_id
Expand Down
32 changes: 11 additions & 21 deletions src/mcp/server/auth/handlers/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,28 +112,18 @@
else None
)

client_info = OAuthClientInformationFull(
client_id=client_id,
client_id_issued_at=client_id_issued_at,
client_secret=client_secret,
client_secret_expires_at=client_secret_expires_at,
# passthrough information from the client request
redirect_uris=client_metadata.redirect_uris,
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
grant_types=client_metadata.grant_types,
response_types=client_metadata.response_types,
client_name=client_metadata.client_name,
client_uri=client_metadata.client_uri,
logo_uri=client_metadata.logo_uri,
scope=client_metadata.scope,
contacts=client_metadata.contacts,
tos_uri=client_metadata.tos_uri,
policy_uri=client_metadata.policy_uri,
jwks_uri=client_metadata.jwks_uri,
jwks=client_metadata.jwks,
software_id=client_metadata.software_id,
software_version=client_metadata.software_version,
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
# the record is the whole validated request plus the credentials minted here - built
# from the request's dump so no metadata field can be silently omitted from the echo.
client_info = OAuthClientInformationFull.model_validate(
{
**client_metadata.model_dump(),
"client_id": client_id,
"client_id_issued_at": client_id_issued_at,
"client_secret": client_secret,
"client_secret_expires_at": client_secret_expires_at,
}
)

Check warning on line 126 in src/mcp/server/auth/handlers/register.py

View check run for this annotation

Claude / Claude Code Review

Default-config 201 echo omits client_secret_expires_at, which RFC 7591 requires (as 0) when a secret is issued

In the default configuration (`client_secret_expiry_seconds=None`), the 201 echo sets `client_secret_expires_at = None`, which `PydanticJSONResponse` (`exclude_none=True`) drops from the body entirely — but RFC 7591 §3.2.1 makes `client_secret_expires_at` REQUIRED whenever a `client_secret` is issued, with `0` (not omission) as the never-expires value, and this handler issues a secret for every auth method except `"none"`. A one-token fix (`else 0` instead of `else None`) suffices: `client_auth.
Comment on lines +115 to 126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In the default configuration (client_secret_expiry_seconds=None), the 201 echo sets client_secret_expires_at = None, which PydanticJSONResponse (exclude_none=True) drops from the body entirely — but RFC 7591 §3.2.1 makes client_secret_expires_at REQUIRED whenever a client_secret is issued, with 0 (not omission) as the never-expires value, and this handler issues a secret for every auth method except "none". A one-token fix (else 0 instead of else None) suffices: client_auth.py's expiry check uses truthiness, so 0 already reads as non-expiring. (Pre-existing in v1, but this PR rewrote exactly this construction citing exactly this RFC section.)

Extended reasoning...

The bug. RegistrationHandler.handle computes the secret expiry at src/mcp/server/auth/handlers/register.py:108-113:

client_secret_expires_at = (
    client_id_issued_at + self.options.client_secret_expiry_seconds
    if self.options.client_secret_expiry_seconds is not None
    else None
)

ClientRegistrationOptions.client_secret_expiry_seconds defaults to None, so in the default configuration client_secret_expires_at is None on the record. The 201 is rendered by PydanticJSONResponse (src/mcp/server/auth/json_response.py:10), which serializes with model_dump_json(exclude_none=True) — the key is dropped from the response body entirely.

Why that's off-spec. RFC 7591 §3.2.1 defines: "client_secret_expires_at: REQUIRED if client_secret is issued. Time at which the client secret will expire or 0 if it will not expire." The handler mints a client_secret for every token_endpoint_auth_method except "none" (register.py:54-57, and None is first defaulted to client_secret_post), so in the common case the 201 issues a secret yet omits the field the RFC requires alongside it. The spec's way to say "never expires" is 0, not omission.

Step-by-step proof. (1) An MCP server hosts the bundled AS with ClientRegistrationOptions()client_secret_expiry_seconds is None. (2) A client POSTs valid metadata to /register with no token_endpoint_auth_method; the handler defaults it to client_secret_post and mints client_secret = secrets.token_hex(32). (3) client_secret_expires_at evaluates to None via the else branch. (4) The record is built via model_validate and returned through PydanticJSONResponse, whose exclude_none=True drops the key. (5) The 201 body contains client_secret but no client_secret_expires_at — violating the REQUIRED-if-secret-issued rule of the very RFC section the new comment cites.

Why the PR's own safeguards miss it. The rewrite states the invariant that "no metadata field can be silently omitted from the echo" and adds test_every_request_metadata_field_exists_on_the_client_record — but that test compares field names between the two models, and the new interaction test only asserts the application_type echo. Neither observes the serialized 201 body's credential fields, so a None-valued field dropped at serialization time is invisible to both. (It's also not a metadata field being dropped — it's a credential field the handler itself sets to None — which is exactly the blind spot of a name-comparison test.)

Impact. The SDK's own client is unaffected (client_secret_expires_at is optional on OAuthClientInformationFull), so nothing concretely breaks on merge. The exposure is spec-strict third-party clients or conformance tooling validating the 201 per §3.2.1, which may reject or mis-handle a response that issues a secret without the required expiry field — somewhat ironic for a PR whose client half is about tolerating off-spec 201 bodies from third-party servers.

Fix. One token at register.py:112: else 0 instead of else None. This is behaviorally safe on the server side: the expiry check at src/mcp/server/auth/middleware/client_auth.py guards with if client.client_secret_expires_at and ... < now, so a falsy 0 already reads as non-expiring, and 0 is not dropped by exclude_none. Worth adding an assertion on the serialized 201 body (e.g. response.json()["client_secret_expires_at"] == 0) so the invariant is pinned where the omission actually happens.

Note on provenance. This is pre-existing — v1's hand-copied constructor passed the same None — so it is not a regression introduced by this PR. But the PR rewrote exactly this construction, citing exactly this RFC section, with the stated goal that nothing be silently omitted from the echo, so the gap sits squarely in the touched code; hence filing it as a non-blocking nit rather than pre-existing/unrelated.

try:
# Register client
await self.provider.register_client(client_info)
Expand Down
Loading
Loading