Skip to content

OAuth client: authorization URL is built with a second ? when the advertised authorization_endpoint already carries a query (RFC 6749 §3.1) #3505

Description

@r-marques

Initial Checks

  • I confirm that I'm using the newest release of my line (verified on 2.2.0 and 1.30.0, and on main)
  • I confirm that I searched for my issue in the issues before opening this one (searched for "authorization_endpoint query", "authorization_url urlencode", "second ?")

Release line

v2 (and v1 — same code)

Description

OAuthClientProvider._perform_authorization builds the browser redirect as

authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"   # src/mcp/client/auth/oauth2.py:427 on main

auth_endpoint comes straight from the server's RFC 8414 metadata (authorization_endpoint). RFC 6749 §3.1 says that URI "MAY include an application/x-www-form-urlencoded formatted query component, which MUST be retained when adding additional query parameters". When it does carry one, the f-string produces a second ?:

advertised:  https://auth.example.com/authorize?tenant=acme
sent:        https://auth.example.com/authorize?tenant=acme?response_type=code&client_id=…&redirect_uri=…&state=…&code_challenge=…

The authorization server then receives tenant = "acme?response_type=code" and no response_type at all — a hard failure at the consent page, on every authorization, for every server whose endpoint carries a query. Servers do advertise such endpoints: a tenant/policy selector (Azure AD B2C's ?p=<policy> is the well-known one), or — how we hit it — an environment/tier tag on a multi-tenant consent app (Nevermined advertises https://nevermined.app/oauth/authorize?network=sandbox|live because one consent app fronts two authorization servers). The TypeScript SDK is unaffected: client/auth.js builds the URL with new URL(endpoint) + searchParams.set(...), which retains the existing query.

Example Code

Minimal reproduction of the URL construction (no server needed):

from urllib.parse import urlencode

auth_endpoint = "https://auth.example.com/authorize?tenant=acme"   # from RFC 8414 metadata
auth_params = {"response_type": "code", "client_id": "c", "state": "s"}

print(f"{auth_endpoint}?{urlencode(auth_params)}")
# https://auth.example.com/authorize?tenant=acme?response_type=code&client_id=c&state=s
#                                                ^ second '?' — the server sees tenant="acme?response_type=code"

Expected (RFC 6749 §3.1):

https://auth.example.com/authorize?tenant=acme&response_type=code&client_id=c&state=s

Proposed fix — merge onto the existing query instead of concatenating:

from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

def build_authorization_url(authorization_endpoint: str, params: dict[str, str]) -> str:
    parts = urlsplit(authorization_endpoint)
    query = parse_qsl(parts.query, keep_blank_values=True) + list(params.items())
    return urlunsplit(parts._replace(query=urlencode(query)))

I have this change ready on a branch — https://github.com/r-marques/python-sdk/tree/fix/authorization-url-retains-endpoint-query — as a small PR (helper + two unit tests + one flow test that drives _perform_authorization with a query-bearing authorization_endpoint; uv run pytest tests/client/test_auth.py → 163 passed / 1 xfailed, ruff + pyright clean) and would be glad to open it if you'd like to take an outside PR for this — happy to defer to a maintainer fix otherwise.

Disclosure: drafted with AI assistance (Claude Code); the behaviour was verified by hand against the 1.30.0 and 2.2.0 wheels and main, and I can explain every line of the proposed change.

Python & MCP Python SDK

Python 3.14.7
mcp 2.2.0 (also reproduced on 1.30.0; the line is unchanged on main @ oauth2.py:427)

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Sep 15, 2026
  2. rajeevchandra commented on Sep 16, 2026

    @rajeevchandra

    Confirmed on main. The f-string at oauth2.py:427 blindly appends ? without checking if auth_endpoint already has a query component.

    Fix is to use urlsplit/parse_qsl/urlunsplit to merge the params. TypeScript SDK already does this correctly via new URL(endpoint) + searchParams.set(...).

    I'd be glad to open a PR if you're taking outside contributions for this — otherwise happy to defer.

  3. 0xamlab commented on Sep 19, 2026

    @0xamlab

    Confirmed on current main (6affe5c): src/mcp/client/auth/oauth2.py:427 still does

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

    so a query-bearing authorization_endpoint (which RFC 6749 §3.1 requires the client to retain) gets a second ?, and the AS parses e.g. tenant=acme?response_type=code. Reproduced by driving _perform_authorization_code_grant with authorization_endpoint = https://auth.example.com/authorize?tenant=acme.

    Fix plan:

    1. Module-level helper in oauth2.py:
    def build_authorization_url(authorization_endpoint: str, params: dict[str, str]) -> str:
        parts = urlsplit(authorization_endpoint)
        query = parse_qsl(parts.query, keep_blank_values=True) + list(params.items())
        return urlunsplit(parts._replace(query=urlencode(query)))
    1. Line 427 becomes build_authorization_url(auth_endpoint, auth_params). Plain endpoints render byte-identically to today; query-bearing ones get the new params merged with &, existing params first.

    2. Tests in tests/client/test_auth.py: unit tests for the helper (plain endpoint unchanged, existing query retained, blank values kept), plus a flow test driving _perform_authorization_code_grant with a query-bearing endpoint that asserts the captured redirect retains tenant=acme and carries response_type=code behind a single ?.

    Same root cause as #2776 (with the stalled #2779) — happy for this to be deduped either way; the patch is identical. If you'd take an outside PR for it, please assign and I'll open it against main right away (a v1.x backport too if wanted).

    Affiliation: thyn-ai. Drafted with AI assistance; verified against main by hand, and I can explain every line of the change.

  4. dgilman-perplexity commented on Sep 23, 2026

    @dgilman-perplexity

    We hit this in production against Salesforce's authorization server, whose discovered authorization_endpoint carries ?prompt=select_account — the resulting double-? URL breaks the flow, and we currently work around it with a subclass override. I have a minimal fix ready against current main (a _build_authorization_url helper that merges the flow's params into the endpoint's existing query per RFC 6749 §3.1, plus a regression test) — happy to have a maintainer assign this so the PR stays open. Noting #2779 takes the same approach but predates the httpx2 rename and no longer applies cleanly.

    🤖 Generated with Claude Code

  5. VinhLoiIT commented on Sep 25, 2026

    @VinhLoiIT

    Same issue here with Datadog subdomain query

    When using Datadog Custom Domain OAuth , the expected subdomain should be passed via ?subdomain=<SUBDOMAIN>

    In v1 implementation, the bug comes from mcp.client.auth.utils:build_protected_resource_metadata_discovery_urls function that the Priority 2 only preserves the Path and remove the queries like below:

    def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]:
        ...
        # Priority 2: Path-based well-known URI (if server has a path component)
        if parsed.path and parsed.path != "/":
    -        path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}")
    +        path_based_url = urlunparse(parsed._replace(path=f"/.well-known/oauth-protected-resource{parsed.path}"))
            urls.append(path_based_url)
        ...
  6. Kludex commented on Oct 10, 2026

    @Kludex
    Member

    Both reports identify the OAuth authorization URL appending a second ? when the advertised endpoint already has query parameters. This is tracked in #2776, so I’m closing this as a duplicate. AI-assisted triage; I reviewed both reports.

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

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions