Skip to content

Commit d725212

Browse files
committed
Handle Bearer auth params in multi-challenge headers
1 parent 8a05814 commit d725212

2 files changed

Lines changed: 55 additions & 7 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,52 @@
1616
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
1717

1818

19+
def _split_www_authenticate_segments(header_value: str) -> list[str]:
20+
"""Split a WWW-Authenticate header on top-level commas."""
21+
segments: list[str] = []
22+
current: list[str] = []
23+
in_quotes = False
24+
25+
for char in header_value:
26+
if char == '"':
27+
in_quotes = not in_quotes
28+
if char == "," and not in_quotes:
29+
segment = "".join(current).strip()
30+
if segment:
31+
segments.append(segment)
32+
current = []
33+
continue
34+
current.append(char)
35+
36+
tail = "".join(current).strip()
37+
if tail:
38+
segments.append(tail)
39+
return segments
40+
41+
42+
def _extract_bearer_auth_params(www_auth_header: str) -> str | None:
43+
"""Return the auth-param portion of the first Bearer challenge."""
44+
segments = _split_www_authenticate_segments(www_auth_header)
45+
collecting = False
46+
auth_params: list[str] = []
47+
48+
for segment in segments:
49+
scheme, separator, remainder = segment.partition(" ")
50+
if scheme.lower() == "bearer" and separator:
51+
collecting = True
52+
auth_params = [remainder.strip()]
53+
continue
54+
55+
if collecting:
56+
if separator and "=" not in scheme:
57+
break
58+
auth_params.append(segment)
59+
60+
if not auth_params:
61+
return None
62+
return ", ".join(part for part in auth_params if part)
63+
64+
1965
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
2066
"""Extract field from WWW-Authenticate header.
2167
@@ -26,15 +72,12 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
2672
if not www_auth_header:
2773
return None
2874

29-
# Strip the auth scheme (e.g. "Bearer") so parsing only sees auth-params.
30-
_, separator, auth_params = www_auth_header.partition(" ")
31-
if not separator:
32-
auth_params = www_auth_header
75+
auth_params = _extract_bearer_auth_params(www_auth_header)
76+
if auth_params is None:
77+
return None
3378

3479
# Match comma-delimited auth-params while respecting quoted values.
35-
pattern = re.compile(
36-
r'(?:^|,\s*)(?P<name>[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P<quoted>[^"]+)"|(?P<unquoted>[^,\s]+))'
37-
)
80+
pattern = re.compile(r'(?:^|,\s*)(?P<name>[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P<quoted>[^"]+)"|(?P<unquoted>[^,\s]+))')
3881
for match in pattern.finditer(auth_params):
3982
if match.group("name") == field_name:
4083
# Return quoted value if present, otherwise unquoted value

tests/client/test_auth.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,11 @@ class TestWWWAuthenticate:
20222022
"scope",
20232023
"read write",
20242024
),
2025+
(
2026+
'Basic realm="legacy", Bearer scope="read write", error="insufficient_scope"',
2027+
"scope",
2028+
"read write",
2029+
),
20252030
],
20262031
)
20272032
def test_extract_field_from_www_auth_valid_cases(

0 commit comments

Comments
 (0)