Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ client:
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires
# (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached.
# Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
Expand Down
6 changes: 4 additions & 2 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ client:
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025
# stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the
# re-register check is never reached. Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration

# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
Expand Down
4 changes: 4 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,10 @@ If you relied on extra fields round-tripping through MCP types, move that data i

## New Features

### OAuth client credentials are bound to their authorization server (SEP-2352)

Persisted OAuth client credentials are now bound to the authorization server that issued them: `OAuthClientInformationFull` records an `issuer`, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's `client_id` to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing `TokenStorage` implementations - the `issuer` round-trips through the unchanged `get_client_info`/`set_client_info`.

### Step-up authorization unions previously requested scopes (SEP-2350)

When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request.
Expand Down
24 changes: 24 additions & 0 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
create_client_info_from_metadata_url,
create_client_registration_request,
create_oauth_metadata_request,
credentials_match_issuer,
extract_field_from_www_auth,
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
Expand Down Expand Up @@ -564,6 +565,20 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
else:
logger.debug(f"Protected resource metadata discovery failed: {url}")

# SEP-2352: stored credentials are bound to the issuer that registered them.
# If the authorization server changed, drop them (and the old tokens) so the
# flow re-registers instead of presenting another server's credentials.
if (
self.context.client_info is not None
and self.context.auth_server_url is not None
and not credentials_match_issuer(
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
)
):
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
self.context.client_info = None
self.context.clear_tokens()

asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
self.context.auth_server_url, self.context.server_url
)
Expand Down Expand Up @@ -595,6 +610,13 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.

# Step 4: Register client or use URL-based client ID (CIMD)
if not self.context.client_info:
# SEP-2352: bind the credentials to the issuing AS. Prefer the PRM-advertised
# authorization server; on the legacy no-PRM path fall back to the issuer from
# the discovered metadata so the binding is still recorded.
bound_issuer = self.context.auth_server_url
if bound_issuer is None and self.context.oauth_metadata is not None:
bound_issuer = str(self.context.oauth_metadata.issuer)

if should_use_client_metadata_url(
self.context.oauth_metadata, self.context.client_metadata_url
):
Expand All @@ -604,6 +626,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
self.context.client_metadata_url, # type: ignore[arg-type]
redirect_uris=self.context.client_metadata.redirect_uris,
)
client_information.issuer = bound_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)
else:
Expand All @@ -615,6 +638,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
)
registration_response = yield registration_request
client_information = await handle_registration_response(registration_response)
client_information.issuer = bound_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)

Expand Down
20 changes: 20 additions & 0 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,26 @@ def is_valid_client_metadata_url(url: str | None) -> bool:
return False


def credentials_match_issuer(
client_info: OAuthClientInformationFull, issuer: str, client_metadata_url: str | None
) -> bool:
"""Whether stored client credentials may be reused against `issuer` (SEP-2352).

A URL-based client ID (CIMD) is portable across authorization servers — the same self-hosted
document is resolved by whichever server is in use — so it always matches; CIMD is identified
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
and are left as-is.
"""
if client_metadata_url is not None and client_info.client_id == client_metadata_url:
return True
if client_info.issuer is None:
return True
return client_info.issuer == issuer


def should_use_client_metadata_url(
oauth_metadata: OAuthMetadata | None,
client_metadata_url: str | None,
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ class OAuthClientInformationFull(OAuthClientMetadata):
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
issuer: str | None = None


class OAuthMetadata(BaseModel):
Expand Down
39 changes: 39 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
create_client_info_from_metadata_url,
create_client_registration_request,
create_oauth_metadata_request,
credentials_match_issuer,
extract_field_from_www_auth,
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
Expand Down Expand Up @@ -2793,3 +2794,41 @@ def test_validate_metadata_issuer_rejects_mismatch():
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
assert union_scopes(previous, new) == expected


def test_credentials_match_issuer_same_issuer():
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
assert credentials_match_issuer(info, "https://as", None) is True


def test_credentials_match_issuer_different_issuer():
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
assert credentials_match_issuer(info, "https://other", None) is False


def test_credentials_match_issuer_no_recorded_issuer_is_left_alone():
"""Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce."""
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")])
assert credentials_match_issuer(info, "https://as", None) is True


def test_credentials_match_issuer_cimd_is_portable():
"""A client_id equal to the configured client_metadata_url (CIMD) is portable across servers."""
cimd_url = "https://client.example/metadata.json"
info = OAuthClientInformationFull(
client_id=cimd_url,
redirect_uris=[AnyUrl("http://localhost/cb")],
token_endpoint_auth_method="none",
issuer="https://as",
)
assert credentials_match_issuer(info, "https://other", cimd_url) is True


def test_credentials_match_issuer_url_shaped_dcr_id_is_not_portable():
"""A URL-shaped client_id from DCR (not the configured CIMD URL) stays bound to its issuer."""
info = OAuthClientInformationFull(
client_id="https://as.example.com/clients/123",
redirect_uris=[AnyUrl("http://localhost/cb")],
issuer="https://as.example.com",
)
assert credentials_match_issuer(info, "https://other", "https://client.example/metadata.json") is False
9 changes: 9 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3360,6 +3360,15 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:as-binding": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-binding",
behavior=(
"Stored client credentials are bound to the issuer that registered them; when the authorization "
"server changes, the client discards them and re-registers rather than reusing them (SEP-2352)."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:invalid-client-clears-all": Requirement(
source="sdk",
behavior=(
Expand Down
34 changes: 34 additions & 0 deletions tests/interaction/auth/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,40 @@ async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenge
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"


@requirement("client-auth:as-binding")
async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None:
"""Credentials bound to a stale issuer are dropped and re-registered against the current AS.

The stored client is bound (SEP-2352) to a different issuer than the one the server's PRM
advertises, simulating an authorization-server migration. The client must discard it, perform
Dynamic Client Registration with the current AS, and never present the stale `client_id` at the
authorize or token endpoints.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
stale = seeded_client(provider, client_id="stale-as-client", issuer="https://old-as.example.com")
storage = InMemoryTokenStorage(client_info=stale)
server = Server("guarded", on_list_tools=list_tools)

with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (
client,
_,
):
await client.list_tools()

# The client re-registered with the current AS...
assert path_counts(recorded)[("POST", "/register")] == 1
# ...and the stale client_id never reached the authorize or token endpoints.
authorize_and_token = find(recorded, "GET", "/authorize") + find(recorded, "POST", "/token")
assert all("stale-as-client" not in r.url.query.decode() for r in authorize_and_token)
assert all("stale-as-client" not in r.content.decode() for r in find(recorded, "POST", "/token"))
# The persisted client is now bound to the current AS.
assert storage.client_info is not None
assert storage.client_info.client_id != "stale-as-client"
assert storage.client_info.issuer == f"{BASE_URL}/"


@requirement("client-auth:401-after-auth-throws")
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.
Expand Down
Loading