Skip to content

Commit 87d50f9

Browse files
committed
Union previously requested scopes on step-up re-authorization (SEP-2350)
On a 403 insufficient_scope challenge, 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. Escalating one operation no longer drops the permissions granted for another. Scope accumulation is a client-side responsibility per the spec; the server stays stateless. Adds a deterministic union_scopes helper (order-preserving, deduped) and unions at the step-up call site. Flips auth/scope-step-up green on the default conformance leg (it stays baselined on the 2026-07-28 leg, where it fails earlier for an unrelated connection-lifecycle reason).
1 parent cf41441 commit 87d50f9

6 files changed

Lines changed: 94 additions & 8 deletions

File tree

docs/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,10 @@ If you relied on extra fields round-tripping through MCP types, move that data i
13031303

13041304
## New Features
13051305

1306+
### Step-up authorization unions previously requested scopes (SEP-2350)
1307+
1308+
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.
1309+
13061310
### OAuth Dynamic Client Registration sends `application_type` (SEP-837)
13071311

13081312
`OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`:

src/mcp/client/auth/oauth2.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
handle_token_response_scopes,
3636
is_valid_client_metadata_url,
3737
should_use_client_metadata_url,
38+
union_scopes,
3839
validate_authorization_response_iss,
3940
validate_metadata_issuer,
4041
)
@@ -634,13 +635,17 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
634635
# Step 2: Check if we need to step-up authorization
635636
if error == "insufficient_scope": # pragma: no branch
636637
try:
637-
# Step 2a: Update the required scopes
638-
self.context.client_metadata.scope = get_client_metadata_scopes(
638+
# Step 2a: Union previously requested scopes with the newly challenged
639+
# scopes (SEP-2350) so escalating one operation keeps the others' grants
640+
challenged_scope = get_client_metadata_scopes(
639641
extract_scope_from_www_auth(response),
640642
self.context.protected_resource_metadata,
641643
self.context.oauth_metadata,
642644
self.context.client_metadata.grant_types,
643645
)
646+
self.context.client_metadata.scope = union_scopes(
647+
self.context.client_metadata.scope, challenged_scope
648+
)
644649

645650
# Step 2b: Perform (re-)authorization and token exchange
646651
token_response = yield await self._perform_authorization()

src/mcp/client/auth/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ def get_client_metadata_scopes(
131131
return selected_scope
132132

133133

134+
def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None:
135+
"""Merge two space-delimited scope strings, preserving order and dropping duplicates.
136+
137+
SEP-2350: on step-up re-authorization the client requests the union of previously requested
138+
scopes and the newly challenged scopes, so escalating one operation does not drop the
139+
permissions granted for another. Previously requested scopes come first; new scopes are
140+
appended in order.
141+
"""
142+
if not previous_scope:
143+
return new_scope
144+
if not new_scope:
145+
return previous_scope
146+
147+
merged = previous_scope.split()
148+
seen = set(merged)
149+
for scope in new_scope.split():
150+
if scope not in seen:
151+
merged.append(scope)
152+
seen.add(scope)
153+
return " ".join(merged)
154+
155+
134156
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]:
135157
"""Generate an ordered list of URLs for authorization server metadata discovery.
136158

tests/client/test_auth.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
handle_registration_response,
2727
is_valid_client_metadata_url,
2828
should_use_client_metadata_url,
29+
union_scopes,
2930
validate_authorization_response_iss,
3031
validate_metadata_issuer,
3132
)
@@ -1387,10 +1388,9 @@ async def test_403_insufficient_scope_updates_scope_from_header(
13871388
async def capture_redirect(url: str) -> None:
13881389
nonlocal redirect_captured, captured_state
13891390
redirect_captured = True
1390-
# Verify the new scope is included in authorization URL
1391-
assert "scope=admin%3Awrite+admin%3Adelete" in url or "scope=admin:write+admin:delete" in url.replace(
1392-
"%3A", ":"
1393-
).replace("+", " ")
1391+
# SEP-2350: the authorization URL carries the union of the prior and challenged scopes
1392+
scope = parse_qs(urlparse(url).query)["scope"][0]
1393+
assert scope == "read write admin:write admin:delete"
13941394
# Extract state from redirect URL
13951395
parsed = urlparse(url)
13961396
params = parse_qs(parsed.query)
@@ -1420,8 +1420,8 @@ async def mock_callback() -> AuthorizationCodeResult:
14201420
# Trigger step-up - should get token exchange request
14211421
token_exchange_request = await auth_flow.asend(response_403)
14221422

1423-
# Verify scope was updated
1424-
assert oauth_provider.context.client_metadata.scope == "admin:write admin:delete"
1423+
# Verify scope was updated to the union of prior and challenged scopes (SEP-2350)
1424+
assert oauth_provider.context.client_metadata.scope == "read write admin:write admin:delete"
14251425
assert redirect_captured
14261426

14271427
# Complete the flow with successful token response
@@ -2717,3 +2717,20 @@ def test_validate_metadata_issuer_accepts_match():
27172717
def test_validate_metadata_issuer_rejects_mismatch():
27182718
with pytest.raises(OAuthFlowError, match="metadata issuer mismatch"):
27192719
validate_metadata_issuer(_issuer_metadata(issuer="https://attacker.example.com"), _ISSUER)
2720+
2721+
2722+
@pytest.mark.parametrize(
2723+
("previous", "new", "expected"),
2724+
[
2725+
pytest.param("mcp:basic", "mcp:write", "mcp:basic mcp:write", id="disjoint-union-order"),
2726+
pytest.param(
2727+
"mcp:basic offline_access", "mcp:write mcp:basic", "mcp:basic offline_access mcp:write", id="dedup"
2728+
),
2729+
pytest.param(None, "mcp:write", "mcp:write", id="no-previous"),
2730+
pytest.param("mcp:basic", None, "mcp:basic", id="no-new"),
2731+
pytest.param(None, None, None, id="both-empty"),
2732+
],
2733+
)
2734+
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
2735+
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
2736+
assert union_scopes(previous, new) == expected

tests/interaction/_requirements.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3265,6 +3265,15 @@ def __post_init__(self) -> None:
32653265
transports=("streamable-http",),
32663266
note="OAuth is HTTP-only.",
32673267
),
3268+
"client-auth:403-scope-union": Requirement(
3269+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
3270+
behavior=(
3271+
"On a 403 insufficient_scope step-up, the re-authorization request carries the union of the "
3272+
"previously requested scopes and the newly challenged scopes (SEP-2350)."
3273+
),
3274+
transports=("streamable-http",),
3275+
note="OAuth is HTTP-only.",
3276+
),
32683277
"client-auth:as-metadata-discovery:priority-order": Requirement(
32693278
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
32703279
behavior=(

tests/interaction/auth/test_lifecycle.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe
178178
assert counts[("POST", "/token")] == 2
179179

180180

181+
@requirement("client-auth:403-scope-union")
182+
async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None:
183+
"""The step-up re-authorize requests the union of the previously requested and challenged scopes.
184+
185+
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
186+
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
187+
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
188+
"""
189+
provider = InMemoryAuthorizationServerProvider()
190+
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
191+
server = Server("guarded", on_list_tools=list_tools)
192+
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
193+
challenge = 'Bearer error="insufficient_scope", scope="write"'
194+
195+
with anyio.fail_after(5):
196+
async with connect_with_oauth(
197+
server,
198+
provider=provider,
199+
storage=storage,
200+
settings=settings,
201+
app_shim=step_up_shim(challenge),
202+
) as (client, headless):
203+
await client.list_tools()
204+
205+
assert len(headless.authorize_urls) == 2
206+
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
207+
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
208+
209+
181210
@requirement("client-auth:401-after-auth-throws")
182211
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
183212
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.

0 commit comments

Comments
 (0)