Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
get_client_metadata_scopes,
parse_scopes,
handle_auth_metadata_response,
handle_protected_resource_response,
handle_registration_response,
Expand Down Expand Up @@ -421,7 +422,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:

# OIDC requires prompt=consent when offline_access is requested
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if "offline_access" in self.context.client_metadata.scope.split():
if "offline_access" in parse_scopes(self.context.client_metadata.scope):
auth_params["prompt"] = "consent"

authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
Expand Down
18 changes: 14 additions & 4 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
return None


# RFC 6749 §3.3 separates scopes with spaces, but some authorization servers return
# comma-separated lists; accept both so a compliant client is not broken by the server's choice.
_SCOPE_SEPARATOR_RE = re.compile(r"[\s,]+")


def parse_scopes(scope: str) -> list[str]:
"""Split a scope string into individual scopes, accepting space and comma separators."""
return [item for item in _SCOPE_SEPARATOR_RE.split(scope.strip()) if item]


def extract_scope_from_www_auth(response: Response) -> str | None:
"""Extract scope parameter from WWW-Authenticate header as per RFC 6750.

Expand Down Expand Up @@ -127,15 +137,15 @@ def get_client_metadata_scopes(
and "offline_access" in authorization_server_metadata.scopes_supported
and client_grant_types is not None
and "refresh_token" in client_grant_types
and "offline_access" not in selected_scope.split()
and "offline_access" not in parse_scopes(selected_scope)
):
selected_scope = f"{selected_scope} offline_access"

return selected_scope


def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None:
"""Merge two space-delimited scope strings, preserving order and dropping duplicates.
"""Merge two scope strings, preserving order and dropping duplicates.

SEP-2350: on step-up re-authorization the client requests the union of previously requested
scopes and the newly challenged scopes, so escalating one operation does not drop the
Expand All @@ -147,9 +157,9 @@ def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | Non
if not new_scope:
return previous_scope

merged = previous_scope.split()
merged = parse_scopes(previous_scope)
seen = set(merged)
for scope in new_scope.split():
for scope in parse_scopes(new_scope):
if scope not in seen:
merged.append(scope)
seen.add(scope)
Expand Down
17 changes: 17 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
handle_auth_metadata_response,
handle_registration_response,
is_valid_client_metadata_url,
parse_scopes,
should_use_client_metadata_url,
union_scopes,
validate_authorization_response_iss,
Expand Down Expand Up @@ -3066,13 +3067,29 @@ def test_validate_metadata_issuer_rejects_mismatch():
pytest.param(None, "mcp:write", "mcp:write", id="no-previous"),
pytest.param("mcp:basic", None, "mcp:basic", id="no-new"),
pytest.param(None, None, None, id="both-empty"),
pytest.param("read,write", "write, admin", "read write admin", id="comma-separated-normalized"),
],
)
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
assert union_scopes(previous, new) == expected


@pytest.mark.parametrize(
("scope", "expected"),
[
pytest.param("read write", ["read", "write"], id="space-separated"),
pytest.param("read,write", ["read", "write"], id="comma-separated"),
pytest.param("read, write,\tadmin", ["read", "write", "admin"], id="mixed-separators"),
pytest.param(" read ", ["read"], id="surrounding-whitespace"),
pytest.param("", [], id="empty"),
],
)
def test_parse_scopes(scope: str, expected: list[str]):
"""RFC 6749 §3.3 separates scopes with spaces, but some servers use commas; accept both."""
assert parse_scopes(scope) == expected


def test_credentials_match_issuer_same_issuer():
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
assert credentials_match_issuer(info, "https://as", None) is True
Expand Down
Loading