Skip to content

Commit 2142667

Browse files
committed
fix(auth): respect explicitly-set client_metadata.scope during discovery
The scope selection strategy in async_auth_flow unconditionally overwrites client_metadata.scope with server-advertised scopes. This is problematic when the caller has explicitly set scopes to limit permissions or to avoid rejection by servers that only permit certain scopes (e.g. SalesForce MCP server). Only apply automatic scope selection when client_metadata.scope is None, preserving any explicitly-set value. Github-Issue: #2317 Reported-by: jbweston
1 parent fb2276b commit 2142667

File tree

2 files changed

+143
-5
lines changed

2 files changed

+143
-5
lines changed

src/mcp/client/auth/oauth2.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -572,11 +572,14 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
572572
logger.debug(f"OAuth metadata discovery failed: {url}")
573573

574574
# Step 3: Apply scope selection strategy
575-
self.context.client_metadata.scope = get_client_metadata_scopes(
576-
extract_scope_from_www_auth(response),
577-
self.context.protected_resource_metadata,
578-
self.context.oauth_metadata,
579-
)
575+
# Respect explicitly-set scopes; only auto-select
576+
# when the caller hasn't specified any.
577+
if self.context.client_metadata.scope is None:
578+
self.context.client_metadata.scope = get_client_metadata_scopes(
579+
extract_scope_from_www_auth(response),
580+
self.context.protected_resource_metadata,
581+
self.context.oauth_metadata,
582+
)
580583

581584
# Step 4: Register client or use URL-based client ID (CIMD)
582585
if not self.context.client_info:

tests/client/test_auth.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,141 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide
11671167
assert oauth_provider.context.current_tokens.access_token == "new_access_token"
11681168
assert oauth_provider.context.token_expiry_time is not None
11691169

1170+
@pytest.mark.anyio
1171+
async def test_auth_flow_preserves_explicit_scopes(
1172+
self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage
1173+
):
1174+
"""Test that explicitly-set client_metadata.scope is not overwritten during discovery."""
1175+
oauth_provider.context.current_tokens = None
1176+
oauth_provider.context.token_expiry_time = None
1177+
oauth_provider._initialized = True
1178+
1179+
# The fixture sets scope="read write" — verify it is preserved
1180+
assert oauth_provider.context.client_metadata.scope == "read write"
1181+
1182+
test_request = httpx.Request("GET", "https://api.example.com/mcp")
1183+
auth_flow = oauth_provider.async_auth_flow(test_request)
1184+
1185+
# First request — no auth header
1186+
await auth_flow.__anext__()
1187+
1188+
# 401 triggers OAuth discovery
1189+
response = httpx.Response(
1190+
401,
1191+
headers={
1192+
"WWW-Authenticate": (
1193+
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",'
1194+
' scope="server:scope1 server:scope2"'
1195+
)
1196+
},
1197+
request=test_request,
1198+
)
1199+
1200+
# PRM discovery
1201+
prm_request = await auth_flow.asend(response)
1202+
prm_response = httpx.Response(
1203+
200,
1204+
content=(
1205+
b'{"resource": "https://api.example.com/v1/mcp",'
1206+
b' "authorization_servers": ["https://auth.example.com"],'
1207+
b' "scopes_supported": ["server:scope1", "server:scope2"]}'
1208+
),
1209+
request=prm_request,
1210+
)
1211+
1212+
# OAuth metadata discovery
1213+
oauth_request = await auth_flow.asend(prm_response)
1214+
oauth_response = httpx.Response(
1215+
200,
1216+
content=(
1217+
b'{"issuer": "https://auth.example.com",'
1218+
b' "authorization_endpoint": "https://auth.example.com/authorize",'
1219+
b' "token_endpoint": "https://auth.example.com/token",'
1220+
b' "registration_endpoint": "https://auth.example.com/register"}'
1221+
),
1222+
request=oauth_request,
1223+
)
1224+
1225+
# After scope selection (Step 3), the explicit scope must be preserved
1226+
await auth_flow.asend(oauth_response)
1227+
assert oauth_provider.context.client_metadata.scope == "read write"
1228+
1229+
# Clean up the generator
1230+
await auth_flow.aclose()
1231+
1232+
@pytest.mark.anyio
1233+
async def test_auth_flow_auto_selects_scopes_when_none(self, mock_storage: MockTokenStorage):
1234+
"""Test that scope auto-selection works when no explicit scope is set."""
1235+
1236+
async def redirect_handler(url: str) -> None:
1237+
pass # pragma: no cover
1238+
1239+
async def callback_handler() -> tuple[str, str | None]:
1240+
return "test_auth_code", "test_state" # pragma: no cover
1241+
1242+
client_metadata = OAuthClientMetadata(
1243+
client_name="Test Client",
1244+
client_uri=AnyHttpUrl("https://example.com"),
1245+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
1246+
scope=None,
1247+
)
1248+
provider = OAuthClientProvider(
1249+
server_url="https://api.example.com/v1/mcp",
1250+
client_metadata=client_metadata,
1251+
storage=mock_storage,
1252+
redirect_handler=redirect_handler,
1253+
callback_handler=callback_handler,
1254+
)
1255+
provider.context.current_tokens = None
1256+
provider.context.token_expiry_time = None
1257+
provider._initialized = True
1258+
1259+
test_request = httpx.Request("GET", "https://api.example.com/mcp")
1260+
auth_flow = provider.async_auth_flow(test_request)
1261+
1262+
await auth_flow.__anext__()
1263+
1264+
response = httpx.Response(
1265+
401,
1266+
headers={
1267+
"WWW-Authenticate": (
1268+
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",'
1269+
' scope="server:scope1 server:scope2"'
1270+
)
1271+
},
1272+
request=test_request,
1273+
)
1274+
1275+
prm_request = await auth_flow.asend(response)
1276+
prm_response = httpx.Response(
1277+
200,
1278+
content=(
1279+
b'{"resource": "https://api.example.com/v1/mcp",'
1280+
b' "authorization_servers": ["https://auth.example.com"],'
1281+
b' "scopes_supported": ["server:scope1", "server:scope2"]}'
1282+
),
1283+
request=prm_request,
1284+
)
1285+
1286+
oauth_request = await auth_flow.asend(prm_response)
1287+
oauth_response = httpx.Response(
1288+
200,
1289+
content=(
1290+
b'{"issuer": "https://auth.example.com",'
1291+
b' "authorization_endpoint": "https://auth.example.com/authorize",'
1292+
b' "token_endpoint": "https://auth.example.com/token",'
1293+
b' "registration_endpoint": "https://auth.example.com/register"}'
1294+
),
1295+
request=oauth_request,
1296+
)
1297+
1298+
await auth_flow.asend(oauth_response)
1299+
# Scope should have been auto-selected from the server metadata
1300+
assert provider.context.client_metadata.scope is not None
1301+
assert provider.context.client_metadata.scope == "server:scope1 server:scope2"
1302+
1303+
await auth_flow.aclose()
1304+
11701305
@pytest.mark.anyio
11711306
async def test_auth_flow_no_unnecessary_retry_after_oauth(
11721307
self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken

0 commit comments

Comments
 (0)