Repository navigation
Description
Describe the bug
Ran into this wiring up an OAuth-protected MCP client. When a 401 WWW-Authenticate challenge carries several auth-params, extract_field_from_www_auth (mcp/client/auth/utils.py) matches the requested field name as a substring of another param's name, so a different param shadows the real value.
The pattern is rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' searched with re.search, with no boundary before field_name.
To reproduce
import httpx
from mcp.client.auth.utils import extract_field_from_www_auth, extract_resource_metadata_from_www_auth
def r(h):
return httpx.Response(401, headers={"WWW-Authenticate": h},
request=httpx.Request("GET", "https://api.example.com/"))
# A real `scope` is present, but `error_scope` shadows it:
print(extract_field_from_www_auth(r('Bearer error_scope="decoy", scope="read write"'), "scope"))
# -> 'decoy' (expected 'read write')
# Only a decoy param exists; no real `scope`:
print(extract_field_from_www_auth(r('Bearer custom_scope="leaked"'), "scope"))
# -> 'leaked' (expected None)
# Same for resource_metadata, which drives discovery URL selection:
print(extract_resource_metadata_from_www_auth(r('Bearer x_resource_metadata="https://decoy.example.com"')))
# -> 'https://decoy.example.com' (expected None)Expected behavior
The field name should match only as a complete auth-param name (at the header start or after a whitespace/comma separator), so scope doesn't match inside error_scope. This matters most for resource_metadata, since the client uses it to pick the protected-resource-metadata discovery URL — reading it from the wrong param points discovery at the wrong place.
Environment
mcpmain(reproduces onv1.xas well)
I have a small fix (anchor the param name to the header start or a separator) plus regression cases added to the existing test_extract_field_from_www_auth_* parametrized tests, and can open a PR if this looks right.
Reviewed by AI
Activity
Ben-Home commentedon Jun 28, 2026
refactor: parse WWW-Authenticate with a quote-aware auth-param tokenizer
fix: anchor WWW-Authenticate auth-param parsing at token boundaries (#…
fede-kamel commentedon Jul 10, 2026
Three open PRs fix this (#3012, #3041, #3050). I ran all three against the same differential probe battery (plus current
mainas baseline) to help triage — full harness output below. All three fix the reported shadowing, and all three passtests/client/test_auth.pywith zero failures on their branches (137/139/142 passed for #3050/#3041/#3012 — each adds a different number of regression tests). They differ on edge cases:mainBearer error_scope="admin", scope="files"→ scopeadminBearer x_resource_metadata=…, resource_metadata=…→ resource_metadataBearer as_scope=x, scope=files(unquoted) → scopexBearer error_scope="a",scope="files"(no space after comma) → scopeaBasic realm="r", Bearer scope="files"(two challenges) → scopeNoneDPoP algs="ES256", Bearer scope="files"(RFC 9449 co-advertisement) → scopeNoneNewauth realm="apps"→ realm (non-Bearer scheme, generic use)NoneBearer scope="say \"hi, there"(escaped quote + comma in value) → scopeBearer SCOPE="files"→ scope (RFC 7235 auth-param names are case-insensitive)Summary of the trade-offs:
^|[\s,]boundary +re.escapethe field name). No behavior change in any probe other than the fixes. It does not handle escaped quotes, but neither doesmain, so that is a pre-existing limitation rather than a regression.Bearerchallenge that is not first (Basic …, Bearer …/DPoP …, Bearer …) stops being found. That pattern is real — RFC 9449 servers co-advertiseDPoPandBearer.scope/ RFC 9728resource_metadatacallers — but it changes the generic contract ofextract_field_from_www_auth(arealmlookup on a non-Bearer challenge now returnsNonewheremainreturned the value). If that is the intended direction it should be explicit in the docstring/signature rather than implicit.The param-name case-insensitivity row fails on all four — trivial to add via
re.IGNORECASEon the #3050 approach if wanted.fede-kamel commentedon Jul 10, 2026
@maxisbey since three competing fixes are open for this, the differential matrix above might save a triage pass — TL;DR: #3050 is the minimal no-regression fix; #3012 adds proper escaped-quote parsing but currently regresses multi-challenge headers (live-server repro on the PR); #3041 works but narrows the helper to Bearer-only semantics. Happy to re-run the harness against any revision.