Repository navigation
User-Agent header in sHTTP transport is not forwarded to auth flow #1664
Description
Activity
- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuth
on Dec 2, 2025 Still present on current
main(and inmcp2.0.0), now viahttpx2:src/mcp/client/auth/oauth2.py:465— token exchangesrc/mcp/client/auth/oauth2.py:517— token refreshsrc/mcp/client/auth/utils.py:278— metadata discovery
Requests built with
httpx2.Request(...)never pick up the client's default headers, so they go out with noUser-Agentat all.Concrete impact beyond header hygiene: IBKR's OAuth endpoint hard-rejects requests that carry no
User-Agent, so token refresh can never succeed against it.POST https://api.ibkr.com/oauth2/api/v1/token grant_type=refresh_token&refresh_token=<deliberately-invalid>&client_id=<id> with User-Agent -> 400 invalid_grant "Invalid refresh token" (the normal error) without User-Agent -> 403 Access DeniedSame body, same endpoint, one header apart. Probed with an invalid refresh token so no real grant was consumed. Also ruled out, using the same method: the
resourceparameter (no difference) and a wrong token endpoint (404, not403).What makes this hard to diagnose is that it stays hidden. The access token keeps working for its ~5 minute lifetime, so the connection looks healthy right after authorization; the failure only surfaces at the first refresh or the next process restart — at which point re-authorization is required, every time. Our gateway looked like it needed a fresh browser authorization after every restart for weeks.
Verified against a deployed gateway: after injecting a
User-Agentinto the constructed requests (nothing else changed), three consecutive refreshes succeeded, including one across a restart, with the token file rotating each time.#2077 already handles all five call sites (refresh, PRM + ASM discovery, dynamic registration, token exchange). It has been waiting on review since February and now has conflicts. Happy to rebase it if that would help move it along.
Another instance, with a repro that needs no account: this also blocks the initial authorization, not just refresh, against any Cloudflare-fronted server with bot protection enabled.
Reproduced against
https://mcp.stackoverflow.com(public, supports DCR). Traced request headers from a real client login run onmcp1.27.2:POST https://mcp.stackoverflow.com -> 401 (expected; starts the auth flow) host, accept-encoding, connection, user-agent: python-httpx/0.28.1, accept, content-type GET /.well-known/oauth-protected-resource -> 403 BLOCKED host, mcp-protocol-version <-- no user-agent GET /.well-known/oauth-authorization-server -> 403 BLOCKED host, mcp-protocol-version <-- no user-agent POST /register -> 403 BLOCKED host, content-type <-- no user-agentThe first request is the only one that goes through the normal client path, so it is the only one that carries httpx's default UA — and the only one that is not blocked. The 403 body is a Cloudflare block page (
Method: blockplus the caller's IP and a RAY ID).Isolated to a single variable — same client, same URL, same body, everything else held constant:
Request well-known /registerno User-Agent403 BLOCKED 403 BLOCKED User-Agent: python-httpx/0.28.1404 (no metadata published) 201 {"client_id": …}Also ruled out, by the same method: the caller's IP, rate limiting, the UA string content (any non-empty value works), HTTP/1.1 vs HTTP/2, cookie persistence, the registration body shape, and the
MCP-Protocol-Version/ SSEAcceptheaders. All return 201 as long as a UA is present.Confirmed end-to-end: injecting a UA into the constructed requests at runtime (no other change) took the same login from
OAuthRegistrationError: Registration failed: 403 …Access Denied…to a successful authorization with tokens persisted.Still present in 2.1.1, same three constructors:
mcp/client/auth/utils.py:278— metadata discovery (Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: ...}))mcp/client/auth/utils.py:293— dynamic client registration (headers={"Content-Type": "application/json"})mcp/client/auth/oauth2.py:465/:517— token exchange / refresh
grep -rn "user-agent\|User-Agent\|user_agent" mcp/over the 2.1.1 wheel returns zero hits, so the SDK never sets one anywhere. Andhttpx2._client.py:1841-1859hands an auth-flow-yielded request straight to_send_handling_redirectswith no client-header merge, exactly as httpx 0.28 does — socreate_mcp_http_client(headers=...)is not a workaround on either major.Worth noting how this reads from the outside: the same server connects fine from other MCP clients (the vendor-documented
npx mcp-remote https://mcp.stackoverflow.combridge works), so it presents as "this SDK cannot talk to this server" rather than as a header problem. Any Cloudflare-fronted MCP server with bot protection on will behave this way.Two things that might be worth folding into the fix, on top of #2077's forwarding:
- A default SDK identity. Forwarding solves the case where the caller set a UA, but today the value that reaches the wire on the un-blocked request is httpx's own
python-httpx/0.28.1— the SDK has no identity of its own. Asetdefaultof something likemcp-python-sdk/{__version__}would give servers something to allowlist, and would cover callers who never set one. - Fixing it at the client factory rather than per call site. A request event hook attached in
create_mcp_http_clientfires on hand-built requests too (httpx2/_client.py:1878, inside_send_single_request, downstream of the auth flow), so it covers every current and future constructor without needing each one threaded through_forward_request_headers.
Happy to help test whichever shape you land on — the repro above is public and takes about a minute.
We're seeing the same issue.
Also, there's a fourth site: client registration —
utils.py:293— sets only
Content-Type: application/json, so it carries neitherUser-Agentnor
mcp-protocol-version.Hi maintainers! I ran into this issue while trying to pass custom headers (specifically for auth proxying and \User-Agent) through to the OAuth metadata discovery flow using \streamable_http_client.
Because the current API doesn't expose \headers\ or \ imeout\ directly on \streamable_http_client, those configurations provided by users are dropped if they rely on the default client construction path, breaking OAuth flows that mandate specific headers.
Approach taken in PR #3438:
I've updated the \streamable_http_client\ signature to directly accept \headers, \ imeout, and \�uth\ parameters. These are then forwarded cleanly into \create_mcp_http_client(), ensuring that all internal requests (including the pre-flight/OAuth discovery requests) carry the correct headers.I've also added comprehensive tests verifying backward compatibility and ensuring headers aren't swallowed anymore. All 31 internal tests pass locally.
I understand you're a small team with limited review capacity, but I'd really appreciate it if someone could take a look when time permits. If this approach looks good to you, I'd love to be assigned so the PR can be reopened!
Initial Checks
Description
Passing a custom
User-Agentheader to the sHTTP transport, e.g. here does not result in auth flow requests including that user agent. There also does not appear to be any way to customize this behavior externally to the SDK itself.In particular, this becomes a problem when working with AWS WAF, as one of its baseline rules is to require all requests to include a
User-Agentheader, to filter out low-grade spam.I am currently recommending to internal teams that they disable this rule for the time being, but this is not a great solution for them, as it means removing an application guardrail to work around an SDK limitation.
Example Code
Python & MCP Python SDK
Latest SDK commit: 27279bc (1.22.0)
Python 3.13.2