Skip to content

OAuth handler doesn't support redirect URLs with params #2776

Open

Description

@artdent

Initial Checks

Description

The oauth redirect handler uses the following logic (https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/client/auth/oauth2.py#L356-L357) to attach parameters to the redirect URL supplied by the server:

        authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
        await self.context.redirect_handler(authorization_url)

Unfortunately, this breaks if the auth_endpoint supplied by the server already has URL parameters. For example, at https://api.salesforce.com/.well-known/oauth-authorization-server/platform/mcp/v1/sandbox/platform/sobject-all you can see that the salesforce mcp server has an authorization endpoint of https://test.salesforce.com/services/oauth2/authorize?prompt=select_account.

The end result is that the client tries to visit a URL that loooks like https://test.salesforce.com/services/oauth2/authorize?prompt=select_account?response_type=code&..., which is incorrect.

Example Code

Python & MCP Python SDK

python3 3.13.2
mcp 1.27.2

Activity

  1. added 2 commits that reference this issue on Jun 5, 2026
    11c135a
    d9fd2a5
  2. Ben-Home commented on Jun 13, 2026

    @Ben-Home

    Good catch. This is a classic URL construction bug — urlencode on the full URL breaks when the base URL already has query parameters.

    Root cause

    The current code at oauth2.py#L356-L357 does:

    authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"

    This assumes auth_endpoint has no existing query string. When it does (like Salesforce's ?prompt=select_account), you get ?prompt=select_account?response_type=code&... — double ?, broken URL.

    Fix

    Use urllib.parse to decompose and reconstruct:

    from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
    
    parsed = urlparse(auth_endpoint)
    existing_params = parse_qs(parsed.query)
    # Merge: existing params take precedence if there's a conflict
    merged = {**auth_params, **existing_params}
    new_query = urlencode(merged, doseq=True)
    authorization_url = urlunparse(parsed._replace(query=new_query))

    The merge order matters: if the authorization server already specified a prompt param, it probably intends that to be honored. Server params should win over client-added defaults.

    Edge case: fragment handling

    If the auth_endpoint somehow has a fragment (#), urlparse handles it correctly — fragments are preserved in parsed.fragment and carried through urlunparse.

    Production pattern

    At CorpusIQ (corpusiq.io), we hit the same bug with multiple OAuth providers (Salesforce, HubSpot, Zoho all use pre-parameterized auth URLs). We wrapped the URL construction in a build_authorization_url() helper with the urlparse + merge pattern above. It's been stable across 36+ OAuth connectors.

    Happy to open a PR with this fix if that's helpful.

  3. Ben-Home commented on Jun 13, 2026

    @Ben-Home

    We ran into this exact issue when implementing OAuth 2.1 PKCE for 25+ business API connectors.

    The problem isn't just the redirect handler — it's that many OAuth providers (Google, Shopify, QuickBooks) require specific redirect_uri registration and will reject any URI that doesn't match exactly. If the server sends redirect_uri=https://auth.example.com/callback?provider=shopify but the handler strips the query params during the authorize call, the state parameter gets lost and the OAuth flow breaks silently.

    The fix we landed on:

    1. Parse the redirect URL with urllib.parse.urlparse() to preserve existing query params
    2. Merge auth_params into the existing query string instead of replacing it
    3. Reconstruct with urlunparse() so the full redirect_uri (including provider-specific params) survives

    Quick snippet of what worked:

    from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
    
    parsed = urlparse(auth_endpoint)
    existing_params = parse_qs(parsed.query)
    merged = {**existing_params, **auth_params}
    new_query = urlencode(merged, doseq=True)
    authorization_url = urlunparse(parsed._replace(query=new_query))

    Without this, multi-tenant OAuth setups where each provider needs its own callback path break entirely. The current implementation only works for the simple ?code=xxx&state=yyy case.

  4. added 3 commits that reference this issue on Jun 21, 2026
    457f6ee
    b8f826c
    79259a6
  5. josh-ninjatrader commented on Jul 28, 2026

    @josh-ninjatrader

    A second production provider hits this, so here's another data point for #2776 plus a verification of #2779.

    Reproduction against NinjaTrader

    NinjaTrader's MCP server advertises an authorization_endpoint with a query component:

    $ curl -s https://mcp-demo.tradovateapi.com/.well-known/oauth-protected-resource
    {"resource":"https://mcp-demo.tradovateapi.com/mcp",
     "authorization_servers":["https://demo.tradovateapi.com"], ...}
    
    $ curl -s https://demo.tradovateapi.com/.well-known/oauth-authorization-server
    {"authorization_endpoint":"https://web.ninjatrader.com/oauth?env=demo", ...}

    The env parameter selects the demo or the live backend. With mcp 1.26.0, the client opens this URL:

    https://web.ninjatrader.com/oauth?env=demo?response_type=code&client_id=...
    

    The consent page then parses the query as:

    Parameter Value
    env demo?response_type=code
    client_id 15218
    response_type absent

    Without response_type, the page stops treating the request as an authorization request. It renders a plain sign-in form with no consent panel, and sign-in lands on /welcome. No authorization code ever reaches the callback listener.

    TypeScript SDK clients reach the same endpoint through new URL() and searchParams.set(), so they keep the env parameter and work. Only Python SDK clients break, which makes this easy to miss during provider testing.

    Verification of #2779

    Checking out #2779 and running _build_authorization_url against the live metadata gives:

    Authorization endpoint env prompt response_type
    https://web.ninjatrader.com/oauth?env=demo demo n/a code
    https://web.ninjatrader.com/oauth?env=live live n/a code
    https://test.salesforce.com/services/oauth2/authorize?prompt=select_account n/a select_account code
    https://auth.example.com/authorize n/a n/a code

    uv run --frozen pytest tests/client/test_auth.py gives 137 passed and 1 xfailed on that branch.

    The merge behavior looks right for both providers. Preserving duplicate keys and dropping None values covers more than the reported case does.

    One blocker

    The #2779 branch sits 50 commits behind main and no longer applies cleanly:

    $ git apply --check pr2779.diff
    error: patch failed: src/mcp/client/auth/oauth2.py:9
    error: src/mcp/client/auth/oauth2.py: patch does not apply

    A rebase might be all that stands between the branch and a review.

    Happy to help test a rebased branch against the NinjaTrader endpoints if that's useful.


    Disclosure. I used AI tooling to help investigate and to run the preceding checks. I reviewed the findings and reproduced them myself.

  6. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    authIssues and PRs related to Authentication / OAuth
    on Aug 14, 2026
  7. braden-dev commented on Aug 29, 2026

    @braden-dev

    Railway's MCP server is a third production provider affected by this.

    Reproduction against Railway

    $ curl -s https://backboard.railway.com/.well-known/oauth-authorization-server | jq .authorization_endpoint
    "https://backboard.railway.com/oauth/auth?resource=https%3A%2F%2Fbackboard.railway.com"

    With mcp 2.0.0, the client opens:

    https://backboard.railway.com/oauth/auth?resource=https%3A%2F%2Fbackboard.railway.com?response_type=code&client_id=...
    

    Railway's authorization server rejects this outright with invalid_request (unlike the Salesforce/NinjaTrader cases where a page still renders), so the flow fails immediately rather than dead-ending at a consent screen.

    Secondary symptom worth knowing about: callback port collisions

    Because the failure is instant, client applications that retry the flow spin up a second localhost callback listener while the first attempt's listener is still bound, yielding:

    OAuth callback port 27890 is already in use ([Errno 98] Address already in use)
    

    In practice the user-visible errors alternate between invalid_request and Address already in use, which makes this look like a local networking/port problem and sends debugging in the wrong direction. Worth keeping in mind when triaging duplicate reports — "port already in use during MCP OAuth" reports may be this bug.

    Fix verification

    I can confirm the minimal form of the fix resolves it against Railway's live endpoint. Patching line 415 to join with & when the endpoint already carries a query string:

    separator = "&" if "?" in auth_endpoint else "?"
    authorization_url = f"{auth_endpoint}{separator}{urlencode(auth_params)}"

    completes the full authorization-code + PKCE flow against Railway successfully (consent page renders, callback receives the code, tokens issued). #2779's urlparse-based merge is the more correct approach — it would handle this case as well, since Railway's resource param and the flow params have no key conflicts. +1 for rebasing that branch.


    Disclosure: investigated and drafted with AI assistance; I reproduced the failure and verified the fix against Railway's live endpoints myself.

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 working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions