Skip to content

Commit 6ea73b0

Browse files
fix(auth): preserve configured OAuth scopes
1 parent a4f4ccd commit 6ea73b0

3 files changed

Lines changed: 68 additions & 9 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,12 +687,15 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
687687
self.context.client_info = None
688688
self.context.clear_tokens()
689689

690-
# Step 3: Apply scope selection strategy
690+
# Step 3: Apply scope selection strategy, preserving an explicitly
691+
# configured scope. An explicit empty string intentionally requests no
692+
# scopes, so only ``None`` opts into discovery-based selection.
691693
self.context.client_metadata.scope = get_client_metadata_scopes(
692694
extract_scope_from_www_auth(response),
693695
self.context.protected_resource_metadata,
694696
self.context.oauth_metadata,
695697
self.context.client_metadata.grant_types,
698+
configured_scope=self.context.client_metadata.scope,
696699
)
697700

698701
# Step 4: Register client or use URL-based client ID (CIMD)

src/mcp/client/auth/utils.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,21 +102,27 @@ def get_client_metadata_scopes(
102102
protected_resource_metadata: ProtectedResourceMetadata | None,
103103
authorization_server_metadata: OAuthMetadata | None = None,
104104
client_grant_types: list[str] | None = None,
105+
configured_scope: str | None = None,
105106
) -> str | None:
106-
"""Select effective scopes and augment for refresh token support."""
107-
selected_scope: str | None = None
107+
"""Select effective scopes and augment for refresh token support.
108+
109+
An explicitly configured scope is preferred over discovered scopes. ``None``
110+
continues to opt into the discovery-based selection strategy.
111+
"""
112+
selected_scope: str | None = configured_scope
108113

109114
# MCP spec scope selection priority:
110115
# 1. WWW-Authenticate header scope
111116
# 2. PRM scopes_supported
112117
# 3. AS scopes_supported (SDK fallback)
113118
# 4. Omit scope parameter
114-
if www_authenticate_scope is not None:
115-
selected_scope = www_authenticate_scope
116-
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
117-
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
118-
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
119-
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
119+
if selected_scope is None:
120+
if www_authenticate_scope is not None:
121+
selected_scope = www_authenticate_scope
122+
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
123+
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
124+
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
125+
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
120126

121127
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
122128
if (
@@ -126,6 +132,7 @@ def get_client_metadata_scopes(
126132
and "offline_access" in authorization_server_metadata.scopes_supported
127133
and client_grant_types is not None
128134
and "refresh_token" in client_grant_types
135+
and selected_scope
129136
and "offline_access" not in selected_scope.split()
130137
):
131138
selected_scope = f"{selected_scope} offline_access"

tests/client/test_auth.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,55 @@ def test_clear_tokens(self, oauth_provider: OAuthClientProvider, valid_tokens: O
268268
class TestOAuthFlow:
269269
"""Test OAuth flow methods."""
270270

271+
@pytest.mark.anyio
272+
async def test_explicit_client_scope_is_not_overwritten_by_discovery(self, oauth_provider: OAuthClientProvider):
273+
oauth_provider.context.client_metadata.scope = "explicit:read"
274+
oauth_provider.context.client_info = OAuthClientInformationFull(
275+
client_id="existing-client",
276+
client_secret="client-secret",
277+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
278+
)
279+
oauth_provider._initialized = True
280+
oauth_provider._perform_authorization = mock.AsyncMock(
281+
return_value=httpx2.Request("POST", "https://auth.example.com/token")
282+
)
283+
284+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
285+
request = await auth_flow.__anext__()
286+
prm_request = await auth_flow.asend(
287+
httpx2.Response(
288+
401,
289+
headers={"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/prm"'},
290+
request=request,
291+
)
292+
)
293+
prm_response = httpx2.Response(
294+
200,
295+
json={
296+
"resource": "https://api.example.com/v1/mcp",
297+
"authorization_servers": ["https://auth.example.com"],
298+
"scopes_supported": ["discovered:read", "discovered:write"],
299+
},
300+
request=prm_request,
301+
)
302+
asm_request = await auth_flow.asend(prm_response)
303+
asm_response = httpx2.Response(
304+
200,
305+
json={
306+
"issuer": "https://auth.example.com",
307+
"authorization_endpoint": "https://auth.example.com/authorize",
308+
"token_endpoint": "https://auth.example.com/token",
309+
"scopes_supported": ["discovered:read", "discovered:write"],
310+
},
311+
request=asm_request,
312+
)
313+
314+
token_request = await auth_flow.asend(asm_response)
315+
316+
assert oauth_provider.context.client_metadata.scope == "explicit:read"
317+
assert token_request.method == "POST"
318+
await auth_flow.aclose()
319+
271320
@pytest.mark.anyio
272321
async def test_build_protected_resource_discovery_urls(
273322
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage

0 commit comments

Comments
 (0)