Skip to content

Commit 1ec51d3

Browse files
committed
Pin the pre-registered-credentials divergence and the DCR application_type pass-through
Pre-registered credentials bound to a different issuer are silently discarded and re-registered - the path the spec blesses only for DCR-persisted credentials; for manually provisioned ones it says an error should surface. The new test pins the silent replacement (flow completes, no error, the seeded credential never presented, storage rebound to the current AS) under a recorded divergence scoped to the issuer-stamped arm; the unbound arm is a documented limitation in the entry note, since a mismatch cannot be detected for credentials that never recorded a binding. A consumer-set application_type of web on a loopback redirect - a value the derivation heuristic would never produce - reaches the /register body verbatim, distinguishing pass-through from any future heuristic. Also: the last caching deferral gains its greppable re-open token, the omit_iss precedence is documented in the harness, and the app-type heuristic note cross-references its tested override sibling. 944 -> 946 cells; suite green three runs.
1 parent 1d15ea1 commit 1ec51d3

4 files changed

Lines changed: 131 additions & 4 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,7 +3419,7 @@ def __post_init__(self) -> None:
34193419
added_in="2026-07-28",
34203420
deferred=(
34213421
"Not implemented in the SDK: the per-receipt freshness clock and independent expiry "
3422-
"need a client response cache that records per-page receipt times; none exists. The "
3422+
"need a SEP-2549 response cache that records per-page receipt times; none exists. The "
34233423
"carriage half (each page carries its own ttlMs, set per handler invocation) is "
34243424
"expressible today and can be split out if wanted."
34253425
),
@@ -5375,14 +5375,33 @@ def __post_init__(self) -> None:
53755375
"registration body carries it, pinned incidentally by the "
53765376
"client-auth:dcr:grant-types-default body snapshot. Only the derive-from-redirect-"
53775377
"URIs strategy for the 'web' SHOULD is unimplemented; a web-app consumer sets "
5378-
"application_type='web' explicitly and it is transmitted verbatim."
5378+
"application_type='web' explicitly and it is transmitted verbatim; the consumer-set "
5379+
"half is pinned by client-auth:dcr:app-type-override."
53795380
),
53805381
deferred=(
53815382
"Not implemented in the SDK: application_type is a static model default ('native') "
53825383
"on OAuthClientMetadata (src/mcp/shared/auth.py); no code path inspects the "
53835384
"redirect URIs to choose between 'native' and 'web'."
53845385
),
53855386
),
5387+
"client-auth:dcr:app-type-override": Requirement(
5388+
source=(
5389+
f"{SPEC_2026_BASE_URL}"
5390+
"/basic/authorization/client-registration#application-type-and-redirect-uri-constraints"
5391+
),
5392+
behavior=(
5393+
"A consumer-set application_type is sent verbatim in the dynamic-registration "
5394+
"request; the SDK never rewrites it (SEP-837)."
5395+
),
5396+
added_in="2026-07-28",
5397+
transports=("streamable-http",),
5398+
note=(
5399+
"OAuth is HTTP-only. At this pin nothing could rewrite it -- python has no "
5400+
"redirect-URI derivation strategy (client-auth:dcr:app-type-heuristic, deferred) "
5401+
"-- so this entry pins the pass-through: a future heuristic may only fill the "
5402+
"omitted case, never overwrite an explicit choice."
5403+
),
5404+
),
53865405
"client-auth:dcr:grant-types-default": Requirement(
53875406
source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens",
53885407
behavior=(
@@ -5422,6 +5441,34 @@ def __post_init__(self) -> None:
54225441
transports=("streamable-http",),
54235442
note="OAuth is HTTP-only.",
54245443
),
5444+
"client-auth:as-binding:prereg-mismatch-error": Requirement(
5445+
source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding",
5446+
behavior=(
5447+
"Pre-registered credentials are specific to one authorization server: when the "
5448+
"authorization server indicated by protected resource metadata no longer matches "
5449+
"the issuer recorded with the credentials, the client surfaces an error rather "
5450+
"than silently attempting to use them (SEP-2352)."
5451+
),
5452+
added_in="2026-07-28",
5453+
transports=("streamable-http",),
5454+
note=(
5455+
"OAuth is HTTP-only. The divergence covers only credentials stored with an issuer. "
5456+
"A pre-registered credential stored without one carries no binding to compare: "
5457+
"credentials_match_issuer (src/mcp/client/auth/utils.py) leaves it as-is and the "
5458+
"flow silently presents it to whatever authorization server discovery finds -- a "
5459+
"documented limitation rather than a divergence, because the SHOULD's trigger "
5460+
"('no longer matches the one the credentials were registered with') presupposes a "
5461+
"recorded binding."
5462+
),
5463+
divergence=Divergence(
5464+
note=(
5465+
"The SDK has no pre-registered marker: an issuer-stamped credential whose "
5466+
"issuer mismatches the discovered authorization server takes the same path as "
5467+
"a DCR-persisted one -- silently discarded and re-registered, the path the "
5468+
"spec blesses only for DCR-persisted credentials -- and no error is surfaced."
5469+
),
5470+
),
5471+
),
54255472
"client-auth:invalid-client-clears-all": Requirement(
54265473
source="sdk",
54275474
behavior=(

tests/interaction/auth/_harness.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ class HeadlessOAuth:
147147
rejection path.
148148
149149
`omit_iss`: when set, `callback_handler` returns no iss regardless of what the redirect
150-
carried, so tests can drive the missing-iss paths (the `iss_override` sentinel cannot
151-
express absence).
150+
carried or what `iss_override` supplies (omission wins when both are set), so tests can
151+
drive the missing-iss paths (the `iss_override` sentinel cannot express absence).
152152
"""
153153

154154
def __init__(

tests/interaction/auth/test_flow.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,39 @@ async def test_dcr_sends_consumer_set_grant_types_verbatim() -> None:
283283
assert json.loads(register.content)["grant_types"] == ["authorization_code"]
284284

285285

286+
@requirement("client-auth:dcr:app-type-override")
287+
async def test_dcr_sends_a_consumer_set_application_type_verbatim() -> None:
288+
"""A consumer-set `application_type` is sent on the registration request verbatim, never rewritten.
289+
290+
The application-type section (SEP-837) says web applications SHOULD register
291+
`application_type: 'web'`; the SDK transmits a consumer-set value verbatim. The metadata
292+
sets `'web'` against the suite's loopback redirect URI -- deliberately the value
293+
redirect-URI derivation would NOT produce, so verbatim pass-through stays distinguishable
294+
from any future derivation strategy (which may only fill the omitted case, pinned as
295+
deferred on `client-auth:dcr:app-type-heuristic`).
296+
"""
297+
requests: list[httpx.Request] = []
298+
provider = InMemoryAuthorizationServerProvider()
299+
server = Server("guarded", on_list_tools=list_tools)
300+
client_metadata = OAuthClientMetadata(
301+
client_name="interaction-suite",
302+
redirect_uris=[AnyUrl(REDIRECT_URI)],
303+
application_type="web",
304+
)
305+
306+
with anyio.fail_after(5):
307+
async with connect_with_oauth(
308+
server, provider=provider, client_metadata=client_metadata, on_request=requests.append
309+
) as (client, _):
310+
result = await client.list_tools()
311+
312+
# The flow completed: the real AS accepted a registration carrying `application_type: "web"`.
313+
assert result.tools[0].name == "whoami"
314+
315+
register = next(r for r in requests if r.url.path == "/register")
316+
assert json.loads(register.content)["application_type"] == "web"
317+
318+
286319
async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app() -> None:
287320
"""Harness self-test: `shimmed_app` serves canned bodies, 404s, and forwards everything else.
288321

tests/interaction/auth/test_lifecycle.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,53 @@ async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_cli
319319
assert storage.client_info.issuer == f"{BASE_URL}/"
320320

321321

322+
@requirement("client-auth:as-binding:prereg-mismatch-error")
323+
async def test_preregistered_credentials_bound_to_a_different_issuer_are_silently_replaced_without_an_error() -> None:
324+
"""Pre-registered credentials with a mismatched issuer are silently replaced rather than erroring.
325+
326+
The 2026 binding section says a client holding pre-registered credentials SHOULD surface an
327+
error when the authorization server no longer matches the issuer they were registered with,
328+
rather than silently attempting to use them. The SDK cannot tell pre-registered from
329+
DCR-persisted credentials, so the issuer-stamped credential takes the DCR
330+
discard-and-re-register path and the flow completes with no error -- a known divergence,
331+
recorded on the requirement; the mismatched credential is at least never presented.
332+
"""
333+
recorded, on_request = record_requests()
334+
provider = InMemoryAuthorizationServerProvider()
335+
prereg = seeded_client(
336+
provider,
337+
client_id="prereg-old-as",
338+
client_secret="prereg-secret",
339+
token_endpoint_auth_method="client_secret_post",
340+
issuer="https://old-as.example.com",
341+
)
342+
storage = InMemoryTokenStorage(client_info=prereg)
343+
server = Server("guarded", on_list_tools=list_tools)
344+
345+
with anyio.fail_after(5):
346+
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
347+
result = await client.list_tools()
348+
349+
# The flow completed: no error surfaced -- the divergent observable everything below qualifies.
350+
assert result.tools[0].name == "echo"
351+
352+
# The replacement happened via a silent dynamic registration.
353+
assert path_counts(recorded)[("POST", "/register")] == 1
354+
355+
# The mismatched credential was never presented anywhere: the SDK does not "silently attempt
356+
# to use" it -- only the error half of the SHOULD is missed.
357+
for r in recorded:
358+
assert "prereg-old-as" not in r.url.query.decode()
359+
assert "prereg-old-as" not in r.content.decode()
360+
assert "prereg-secret" not in r.content.decode()
361+
362+
# The pre-registered identity is gone and its replacement is bound to the current AS:
363+
# "silently replaced" made concrete rather than inferred from a changed client_id.
364+
assert storage.client_info is not None
365+
assert storage.client_info.client_id != "prereg-old-as"
366+
assert storage.client_info.issuer == f"{BASE_URL}/"
367+
368+
322369
@requirement("client-auth:401-after-auth-throws")
323370
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
324371
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.

0 commit comments

Comments
 (0)