Skip to content

User-Agent header in sHTTP transport is not forwarded to auth flow #1664

Description

@LucaButBoring

Initial Checks

Description

Passing a custom User-Agent header 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-Agent header, 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

print("📡 Opening StreamableHTTP transport connection with auth...")
async with streamablehttp_client(
    url=self.server_url,
    auth=oauth_auth,
    timeout=timedelta(seconds=60),
    headers={
        "User-Agent": "mcp-python-sdk/0.1.0",
    },
) as (read_stream, write_stream, get_session_id):
    await self._run_session(read_stream, write_stream, get_session_id)

Python & MCP Python SDK

Latest SDK commit: 27279bc (1.22.0)
Python 3.13.2

Activity

  1. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    authIssues and PRs related to Authentication / OAuth
    on Dec 2, 2025
  2. max1874 commented on Aug 25, 2026

    @max1874

    Still present on current main (and in mcp 2.0.0), now via httpx2:

    • src/mcp/client/auth/oauth2.py:465 — token exchange
    • src/mcp/client/auth/oauth2.py:517 — token refresh
    • src/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 no User-Agent at 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 Denied
    

    Same 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 resource parameter (no difference) and a wrong token endpoint (404, not 403).

    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-Agent into 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.

  3. svd commented on Aug 25, 2026

    @svd

    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 on mcp 1.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-agent
    

    The 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: block plus 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 /register
    no User-Agent 403 BLOCKED 403 BLOCKED
    User-Agent: python-httpx/0.28.1 404 (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 / SSE Accept headers. 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. And httpx2._client.py:1841-1859 hands an auth-flow-yielded request straight to _send_handling_redirects with no client-header merge, exactly as httpx 0.28 does — so create_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.com bridge 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:

    1. 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. A setdefault of something like mcp-python-sdk/{__version__} would give servers something to allowlist, and would cover callers who never set one.
    2. Fixing it at the client factory rather than per call site. A request event hook attached in create_mcp_http_client fires 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.

  4. chrisfjones commented on Sep 2, 2026

    @chrisfjones

    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 neither User-Agent nor
    mcp-protocol-version.

  5. AnandkumarMall commented on Sep 3, 2026

    @AnandkumarMall

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions