Skip to content

Commit cf4cc3f

Browse files
committed
Change to the updated MCP spec where priority order is 1. Client scope 2. WWW-auth scope 3. PRM scopes
1 parent 0708565 commit cf4cc3f

File tree

3 files changed

+1158
-1039
lines changed

3 files changed

+1158
-1039
lines changed

src/mcp/client/auth.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class OAuthContext:
9797
oauth_metadata: OAuthMetadata | None = None
9898
auth_server_url: str | None = None
9999
protocol_version: str | None = None
100+
www_authenticate_scope: str | None = None
100101

101102
# Client registration
102103
client_info: OAuthClientInformationFull | None = None
@@ -228,6 +229,30 @@ def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response
228229

229230
return None
230231

232+
def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | None:
233+
"""
234+
Extract scope parameter from WWW-Authenticate header as per RFC6750.
235+
236+
Returns:
237+
Scope string if found in WWW-Authenticate header, None otherwise
238+
"""
239+
if not init_response or init_response.status_code != 401:
240+
return None
241+
242+
www_auth_header = init_response.headers.get("WWW-Authenticate")
243+
if not www_auth_header:
244+
return None
245+
246+
# Pattern matches: scope="value" or scope=value (unquoted)
247+
pattern = r'scope=(?:"([^"]+)"|([^\s,]+))'
248+
match = re.search(pattern, www_auth_header)
249+
250+
if match:
251+
# Return quoted value if present, otherwise unquoted value
252+
return match.group(1) or match.group(2)
253+
254+
return None
255+
231256
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
232257
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
233258
url = self._extract_resource_metadata_from_www_auth(init_response)
@@ -480,16 +505,21 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
480505
self.context.oauth_metadata = metadata
481506

482507
# Only set scope if client_metadata.scope is None
508+
# Per MCP spec, priority order:
509+
# 1. Use scope from WWW-Authenticate header (if provided)
510+
# 2. Use all scopes from PRM scopes_supported (if available)
511+
# 3. Omit scope parameter if neither is available
483512
if self.context.client_metadata.scope is None:
484-
# Priority 1: Use PRM's scopes_supported if available
485-
if (
513+
if self.context.www_authenticate_scope is not None:
514+
# Priority 1: WWW-Authenticate header scope
515+
self.context.client_metadata.scope = self.context.www_authenticate_scope
516+
elif (
486517
self.context.protected_resource_metadata is not None
487518
and self.context.protected_resource_metadata.scopes_supported is not None
488519
):
520+
# Priority 2: PRM scopes_supported
489521
self.context.client_metadata.scope = " ".join(self.context.protected_resource_metadata.scopes_supported)
490-
# Priority 2: Fall back to OAuth metadata scopes if available
491-
elif metadata.scopes_supported is not None:
492-
self.context.client_metadata.scope = " ".join(metadata.scopes_supported)
522+
# Priority 3: Omit scope parameter
493523

494524
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
495525
"""HTTPX auth flow integration."""
@@ -518,12 +548,15 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
518548
# Perform full OAuth flow
519549
try:
520550
# OAuth flow must be inline due to generator constraints
521-
# Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
551+
# Step 1: Extract scope from WWW-Authenticate header
552+
self.context.www_authenticate_scope = self._extract_scope_from_www_auth(response)
553+
554+
# Step 2: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
522555
discovery_request = await self._discover_protected_resource(response)
523556
discovery_response = yield discovery_request
524557
await self._handle_protected_resource_response(discovery_response)
525558

526-
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
559+
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
527560
discovery_urls = self._get_discovery_urls()
528561
for url in discovery_urls:
529562
oauth_metadata_request = self._create_oauth_metadata_request(url)
@@ -538,16 +571,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
538571
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
539572
break # Non-4XX error, stop trying
540573

541-
# Step 3: Register client if needed
574+
# Step 4: Register client if needed
542575
registration_request = await self._register_client()
543576
if registration_request:
544577
registration_response = yield registration_request
545578
await self._handle_registration_response(registration_response)
546579

547-
# Step 4: Perform authorization
580+
# Step 5: Perform authorization
548581
auth_code, code_verifier = await self._perform_authorization()
549582

550-
# Step 5: Exchange authorization code for tokens
583+
# Step 6: Exchange authorization code for tokens
551584
token_request = await self._exchange_token(auth_code, code_verifier)
552585
token_response = yield token_request
553586
await self._handle_token_response(token_response)

tests/client/test_auth.py

Lines changed: 120 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -121,21 +121,6 @@ def prm_metadata_without_scopes():
121121
)
122122

123123

124-
@pytest.fixture
125-
def oauth_metadata_response_without_scopes():
126-
"""OAuth metadata response without scopes."""
127-
return httpx.Response(
128-
200,
129-
content=(
130-
b'{"issuer": "https://auth.example.com", '
131-
b'"authorization_endpoint": "https://auth.example.com/authorize", '
132-
b'"token_endpoint": "https://auth.example.com/token", '
133-
b'"registration_endpoint": "https://auth.example.com/register"}'
134-
# No scopes_supported field
135-
),
136-
)
137-
138-
139124
class TestPKCEParameters:
140125
"""Test PKCE parameter generation."""
141126

@@ -449,60 +434,63 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien
449434
assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com/"
450435

451436
@pytest.mark.anyio
452-
async def test_prioritize_prm_scopes_over_oauth_metadata(
437+
async def test_prioritize_www_auth_scope_over_prm(
453438
self,
454439
oauth_provider_without_scope: OAuthClientProvider,
455440
oauth_metadata_response: httpx.Response,
456441
prm_metadata: ProtectedResourceMetadata,
457442
):
458-
"""Test that PRM scopes are prioritized over auth server metadata scopes."""
443+
"""Test that WWW-Authenticate scope is prioritized over PRM scopes."""
459444
provider = oauth_provider_without_scope
460445

461-
# Set up PRM metadata with specific scopes
446+
# Set up PRM metadata with scopes
462447
provider.context.protected_resource_metadata = prm_metadata
463448

449+
# Set WWW-Authenticate scope (priority 1)
450+
provider.context.www_authenticate_scope = "special:scope from:www-authenticate"
451+
464452
# Process the OAuth metadata
465453
await provider._handle_oauth_metadata_response(oauth_metadata_response)
466454

467-
# Verify that PRM scopes are used (not OAuth metadata scopes)
468-
assert provider.context.client_metadata.scope == "resource:read resource:write"
455+
# Verify that WWW-Authenticate scope is used (not PRM scopes)
456+
assert provider.context.client_metadata.scope == "special:scope from:www-authenticate"
469457

470458
@pytest.mark.anyio
471-
async def test_fallback_to_oauth_metadata_scopes_when_no_prm_scopes(
459+
async def test_prioritize_prm_scopes_when_no_www_auth_scope(
472460
self,
473461
oauth_provider_without_scope: OAuthClientProvider,
474462
oauth_metadata_response: httpx.Response,
475-
prm_metadata_without_scopes: ProtectedResourceMetadata,
463+
prm_metadata: ProtectedResourceMetadata,
476464
):
477-
"""Test fallback to OAuth metadata scopes when PRM has no scopes."""
465+
"""Test that PRM scopes are prioritized when WWW-Authenticate header has no scopes."""
478466
provider = oauth_provider_without_scope
479467

480-
# Set up PRM metadata without scopes
481-
provider.context.protected_resource_metadata = prm_metadata_without_scopes
468+
# Set up PRM metadata with specific scopes
469+
provider.context.protected_resource_metadata = prm_metadata
482470

483-
# Process the OAuth metadata
471+
# Process the OAuth metadata (no WWW-Authenticate scope)
484472
await provider._handle_oauth_metadata_response(oauth_metadata_response)
485473

486-
# Verify that OAuth metadata scopes are used as fallback
487-
assert provider.context.client_metadata.scope == "read write admin"
474+
# Verify that PRM scopes are used
475+
assert provider.context.client_metadata.scope == "resource:read resource:write"
488476

489477
@pytest.mark.anyio
490-
async def test_no_scope_changes_when_both_missing(
478+
async def test_omit_scope_when_no_prm_scopes_or_www_auth(
491479
self,
492480
oauth_provider_without_scope: OAuthClientProvider,
481+
oauth_metadata_response: httpx.Response,
493482
prm_metadata_without_scopes: ProtectedResourceMetadata,
494-
oauth_metadata_response_without_scopes: httpx.Response,
495483
):
496-
"""Test that no scope changes occur when both PRM and OAuth metadata lack scopes."""
484+
"""Test that scope is omitted when PRM has no scopes and WWW-Authenticate doesn't specify scope."""
497485
provider = oauth_provider_without_scope
498486

499487
# Set up PRM metadata without scopes
500488
provider.context.protected_resource_metadata = prm_metadata_without_scopes
501489

502-
# Process the OAuth metadata
503-
await provider._handle_oauth_metadata_response(oauth_metadata_response_without_scopes)
490+
# Process the OAuth metadata (no WWW-Authenticate scope set)
491+
await provider._handle_oauth_metadata_response(oauth_metadata_response)
504492

505-
# Verify that scope remains None
493+
# Verify that scope is omitted
506494
assert provider.context.client_metadata.scope is None
507495

508496
@pytest.mark.anyio
@@ -515,6 +503,9 @@ async def test_preserve_existing_client_scope(
515503
"""Test that existing client scope is preserved regardless of metadata."""
516504
provider = oauth_provider
517505

506+
# Set WWW-Authenticate scope
507+
provider.context.www_authenticate_scope = "special:scope from:www-authenticate"
508+
518509
# Set up PRM metadata with scopes
519510
provider.context.protected_resource_metadata = prm_metadata
520511

@@ -1092,3 +1083,98 @@ async def callback_handler() -> tuple[str, str | None]:
10921083

10931084
result = provider._extract_resource_metadata_from_www_auth(init_response)
10941085
assert result is None, f"Should return None for {description}"
1086+
1087+
@pytest.mark.parametrize(
1088+
"www_auth_header,expected_scope",
1089+
[
1090+
# Quoted scope
1091+
('Bearer scope="read write"', "read write"),
1092+
# Unquoted scope
1093+
("Bearer scope=read", "read"),
1094+
# Multiple parameters with quoted scope
1095+
('Bearer realm="api", scope="admin:write resource:read"', "admin:write resource:read"),
1096+
# Multiple parameters with unquoted scope
1097+
('Bearer realm="api", scope=basic', "basic"),
1098+
# Scope with special characters (colons, underscores)
1099+
('Bearer scope="resource:read resource:write user_profile"', "resource:read resource:write user_profile"),
1100+
],
1101+
)
1102+
def test_extract_scope_from_www_auth_valid_cases(
1103+
self,
1104+
client_metadata: OAuthClientMetadata,
1105+
mock_storage: MockTokenStorage,
1106+
www_auth_header: str,
1107+
expected_scope: str,
1108+
):
1109+
"""Test extraction of scope from various valid WWW-Authenticate headers."""
1110+
1111+
async def redirect_handler(url: str) -> None:
1112+
pass
1113+
1114+
async def callback_handler() -> tuple[str, str | None]:
1115+
return "test_auth_code", "test_state"
1116+
1117+
provider = OAuthClientProvider(
1118+
server_url="https://api.example.com/v1/mcp",
1119+
client_metadata=client_metadata,
1120+
storage=mock_storage,
1121+
redirect_handler=redirect_handler,
1122+
callback_handler=callback_handler,
1123+
)
1124+
1125+
init_response = httpx.Response(
1126+
status_code=401,
1127+
headers={"WWW-Authenticate": www_auth_header},
1128+
request=httpx.Request("GET", "https://api.example.com/test"),
1129+
)
1130+
1131+
result = provider._extract_scope_from_www_auth(init_response)
1132+
assert result == expected_scope
1133+
1134+
@pytest.mark.parametrize(
1135+
"status_code,www_auth_header,description",
1136+
[
1137+
# No header
1138+
(401, None, "no WWW-Authenticate header"),
1139+
# Empty header
1140+
(401, "", "empty WWW-Authenticate header"),
1141+
# Header without scope
1142+
(401, 'Bearer realm="api", error="insufficient_scope"', "no scope parameter"),
1143+
# Malformed header
1144+
(401, "Bearer scope=", "malformed scope parameter"),
1145+
# Non-401 status code
1146+
(200, 'Bearer scope="read write"', "200 OK response"),
1147+
(500, 'Bearer scope="read write"', "500 error response"),
1148+
],
1149+
)
1150+
def test_extract_scope_from_www_auth_invalid_cases(
1151+
self,
1152+
client_metadata: OAuthClientMetadata,
1153+
mock_storage: MockTokenStorage,
1154+
status_code: int,
1155+
www_auth_header: str | None,
1156+
description: str,
1157+
):
1158+
"""Test extraction returns None for invalid cases."""
1159+
1160+
async def redirect_handler(url: str) -> None:
1161+
pass
1162+
1163+
async def callback_handler() -> tuple[str, str | None]:
1164+
return "test_auth_code", "test_state"
1165+
1166+
provider = OAuthClientProvider(
1167+
server_url="https://api.example.com/v1/mcp",
1168+
client_metadata=client_metadata,
1169+
storage=mock_storage,
1170+
redirect_handler=redirect_handler,
1171+
callback_handler=callback_handler,
1172+
)
1173+
1174+
headers = {"WWW-Authenticate": www_auth_header} if www_auth_header is not None else {}
1175+
init_response = httpx.Response(
1176+
status_code=status_code, headers=headers, request=httpx.Request("GET", "https://api.example.com/test")
1177+
)
1178+
1179+
result = provider._extract_scope_from_www_auth(init_response)
1180+
assert result is None, f"Should return None for {description}"

0 commit comments

Comments
 (0)