Skip to content

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

Open

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!

  6. kristian-presso commented on Oct 16, 2026

    @kristian-presso

    Confirmed on current main (9f66f37) and on the mcp 2.3.0 release (httpx2 2.13.1). Headers set on the httpx2.AsyncClient passed to streamable_http_client (the v2 way to configure headers) reach the MCP requests but none of the OAuth requests, including the token refresh. Even httpx2's own default python-httpx2/2.13.1 UA is missing, so these requests go out with no User-Agent at all, which is what WAF / Cloudflare bot rules block.

    Same output on both versions:

    request                                        user-agent   x-tenant  authorization
    POST /mcp                                      my-app/1.0   acme      -
    GET  /.well-known/oauth-protected-resource/mcp MISSING      MISSING   -
    GET  /.well-known/oauth-authorization-server   MISSING      MISSING   -
    POST /register                                 MISSING      MISSING   -
    POST /token          (authorization_code)      MISSING      MISSING   -
    POST /mcp                                      my-app/1.0   acme      Bearer
    POST /mcp                                      my-app/1.0   acme      Bearer
    POST /token          (refresh, 1 s expiry)     MISSING      MISSING   -
    POST /mcp                                      my-app/1.0   acme      Bearer
    
    Repro script (stdlib server + OAuthClientProvider, no external services)

    One local ThreadingHTTPServer plays both resource server and authorization server and records request headers. Run with pip install mcp==2.3.0 (or an editable install of main), then python repro.py.

    """Repro for modelcontextprotocol/python-sdk#1664.
    
    One local server plays both MCP resource server and OAuth authorization server and
    records the headers of every request it receives. The client sets User-Agent and a
    custom header on its httpx2.AsyncClient (the documented v2 way to configure headers),
    connects with OAuthClientProvider, then waits for the 1 s access token to expire so the
    next call goes through the refresh path.
    """
    
    import asyncio
    import json
    import threading
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from importlib.metadata import version
    from urllib.parse import parse_qs, urlparse
    
    import httpx2
    
    from mcp import ClientSession
    from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
    from mcp.client.streamable_http import streamable_http_client
    from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
    
    LOG: list[tuple[str, str, dict[str, str]]] = []
    
    
    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args):  # silence stderr
            pass
    
        def _send(self, status, body=None, headers=None):
            data = json.dumps(body).encode() if body is not None else b""
            self.send_response(status)
            for k, v in (headers or {}).items():
                self.send_header(k, v)
            if body is not None:
                self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
    
        def _record(self):
            LOG.append((self.command, self.path.split("?")[0], {k.lower(): v for k, v in self.headers.items()}))
            n = int(self.headers.get("Content-Length") or 0)
            return self.rfile.read(n) if n else b""
    
        def do_GET(self):
            self._record()
            base = f"http://127.0.0.1:{self.server.server_port}"
            if self.path == "/.well-known/oauth-protected-resource/mcp":
                return self._send(200, {"resource": f"{base}/mcp", "authorization_servers": [base]})
            if self.path == "/.well-known/oauth-authorization-server":
                return self._send(200, {
                    "issuer": base,
                    "authorization_endpoint": f"{base}/authorize",
                    "token_endpoint": f"{base}/token",
                    "registration_endpoint": f"{base}/register",
                    "response_types_supported": ["code"],
                    "grant_types_supported": ["authorization_code", "refresh_token"],
                    "code_challenge_methods_supported": ["S256"],
                })
            return self._send(405 if self.path == "/mcp" else 404)
    
        def do_DELETE(self):
            self._record()
            self._send(200)
    
        def do_POST(self):
            raw = self._record()
            base = f"http://127.0.0.1:{self.server.server_port}"
            if self.path == "/register":
                meta = json.loads(raw)
                return self._send(201, {**meta, "client_id": "client-1"})
            if self.path == "/token":
                n = sum(1 for m, p, _ in LOG if p == "/token")
                return self._send(200, {"access_token": f"at-{n}", "token_type": "Bearer",
                                        "expires_in": 1, "refresh_token": f"rt-{n}"})
            if self.path == "/mcp":
                if not self.headers.get("Authorization", "").startswith("Bearer at-"):
                    return self._send(401, headers={"WWW-Authenticate":
                        f'Bearer resource_metadata="{base}/.well-known/oauth-protected-resource/mcp"'})
                msg = json.loads(raw)
                if "id" not in msg:
                    return self._send(202)
                if msg["method"] == "initialize":
                    result = {"protocolVersion": msg["params"]["protocolVersion"], "capabilities": {"tools": {}},
                              "serverInfo": {"name": "repro", "version": "0"}}
                else:
                    result = {"tools": []}
                return self._send(200, {"jsonrpc": "2.0", "id": msg["id"], "result": result})
            return self._send(404)
    
    
    class MemStorage(TokenStorage):
        def __init__(self):
            self.tokens, self.client = None, None
    
        async def get_tokens(self):
            return self.tokens
    
        async def set_tokens(self, tokens: OAuthToken):
            self.tokens = tokens
    
        async def get_client_info(self):
            return self.client
    
        async def set_client_info(self, client_info: OAuthClientInformationFull):
            self.client = client_info
    
    
    async def main():
        server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        threading.Thread(target=server.serve_forever, daemon=True).start()
        url = f"http://127.0.0.1:{server.server_port}/mcp"
        auth_url: list[str] = []
    
        async def redirect_handler(u: str):
            auth_url.append(u)
    
        async def callback_handler():
            q = parse_qs(urlparse(auth_url[-1]).query)
            return AuthorizationCodeResult(code="code-1", state=q["state"][0])
    
        provider = OAuthClientProvider(
            server_url=url,
            client_metadata=OAuthClientMetadata(client_name="repro", redirect_uris=["http://localhost:3030/cb"],
                                                grant_types=["authorization_code", "refresh_token"]),
            storage=MemStorage(),
            redirect_handler=redirect_handler,
            callback_handler=callback_handler,
        )
        headers = {"User-Agent": "my-app/1.0", "X-Tenant": "acme"}
        async with httpx2.AsyncClient(headers=headers, auth=provider) as client:
            async with streamable_http_client(url, http_client=client) as (read, write):
                async with ClientSession(read, write) as session:
                    await session.initialize()
                    await asyncio.sleep(1.5)  # let the 1 s access token expire -> refresh path
                    await session.list_tools()
        server.shutdown()
    
        print(f"mcp {version('mcp')}, httpx2 {version('httpx2')}")
        print(f"{'request':<52} {'user-agent':<22} {'x-tenant':<9} authorization")
        for method, path, h in LOG:
            auth = h.get("authorization", "-")
            auth = auth.split()[0] if auth != "-" else "-"
            print(f"{method + ' ' + path:<52} {h.get('user-agent', 'MISSING'):<22} {h.get('x-tenant', 'MISSING'):<9} {auth}")
    
    
    asyncio.run(main())

    Root cause

    The OAuth requests are built from scratch inside the auth flow, with only the headers each call site sets:

    httpx2 merges client headers in build_request, but a request yielded from Auth.async_auth_flow goes straight to _send_handling_redirects with no merge. So client-level headers= can never reach these requests, and create_mcp_http_client(headers=...) is not a workaround either.

    Proposed scope

    Every SDK auth provider already runs through one choke point, RedirectAwareAuth.async_auth_flow, which sees both the original MCP request (already carrying the client's merged headers) and every request the flow yields. Copying the original request's User-Agent onto each flow-generated request there with setdefault fixes all of the call sites above, both extensions and any future constructor, without changing any public API:

    outgoing = await flow.__anext__()
    user_agent = request.headers.get("User-Agent")
    while True:
        if outgoing is not request and user_agent is not None:
            outgoing.headers.setdefault("User-Agent", user_agent)
        response = yield outgoing

    I tried this locally: the repro above then shows my-app/1.0 on all OAuth requests including the refresh, and tests/client/test_auth.py, tests/shared/test_httpx_utils.py, tests/client/auth and tests/interaction/auth still pass (311 passed, 1 xfailed). Redirects followed inside the flow inherit the header from the request they follow.

    Compared with the earlier PRs (#2077, #2526), this is one place instead of each call site, and it also covers the client-credentials, private-key-JWT and identity-assertion providers. A request hook in create_mcp_http_client would miss callers who pass their own httpx2.AsyncClient, which is the documented v2 path.

    Tests I'd add:

    • tests/shared/test_httpx_utils.py: a minimal RedirectAwareAuth over MockTransport checking that a flow-generated request gets the client's UA, an explicit UA set by the flow is not overwritten, a followed same-origin redirect keeps it, and Authorization, mcp-session-id and custom client headers are not copied.
    • tests/interaction/auth/test_flow.py: full flow with httpx2.AsyncClient(headers={"User-Agent": ...}) using record_requests, asserting the UA on PRM, ASM, register, token and refresh.

    Open question

    Should forwarding stop at User-Agent? I'd propose yes. The authorization server is often a different origin, so copying arbitrary client headers there (custom auth or tenant headers, cookies) could leak them. Authorization, mcp-session-id, mcp-protocol-version and Content-Type must not be copied in any case. If someone needs other headers on OAuth requests (an API gateway key, for example), I think that is a separate opt-in. The same goes for a default SDK identity along the lines of SEP-1336: worth doing, but separate from this fix. Is that the split you want?

    Why I care: I run MCP servers behind Cloudflare, where a request with no User-Agent is exactly what bot rules catch, so clients built on this SDK can fail OAuth against them for reasons that are hard to diagnose.

    @LucaButBoring, you reported this, so you get first call if you want to fix it yourself. Otherwise I'd be glad to take it. If a maintainer is happy with this scope, please assign it to me and I'll open a small PR (about 3 lines of source plus the tests above) with Fixes #1664.

    AI disclosure: this was traced, reproduced, prototyped and drafted with Claude Code, which I use as my coding agent. Happy to answer questions on any of it and to do the PR.

  7. AnandkumarMall commented on Oct 16, 2026

    @AnandkumarMall

    The RedirectAwareAuth approach looks much cleaner and safer than my earlier attempt in #3438. Feel free to take it @kristian-presso! 👍

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