Skip to content

Commit 0dfbdfe

Browse files
fix(client/auth): preserve an authorization endpoint's existing query parameters
RFC 6749 §3.1 allows the authorization endpoint URI to carry a query component. The client built the authorization URL with a bare f"{endpoint}?{params}", producing a second "?" and a broken URL for any discovered authorization_endpoint that already has one (e.g. Salesforce's "...?prompt=select_account"). Merge the flow's parameters into the endpoint's existing query instead. Fixes #3505, fixes #2776. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f1b6589 commit 0dfbdfe

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

‎src/mcp/client/auth/oauth2.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections.abc import AsyncGenerator, Awaitable, Callable
1313
from dataclasses import dataclass, field
1414
from typing import Any, Protocol, get_args
15-
from urllib.parse import quote, urlencode, urljoin, urlparse
15+
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse
1616

1717
import anyio
1818
import httpx2
@@ -61,6 +61,18 @@
6161

6262
logger = logging.getLogger(__name__)
6363

64+
65+
def _build_authorization_url(auth_endpoint: str, auth_params: dict[str, str]) -> str:
66+
"""Append authorization parameters to the endpoint, preserving any query it already has.
67+
68+
RFC 6749 §3.1 allows the authorization endpoint URI to include a query component; the
69+
discovered `authorization_endpoint` therefore cannot be extended with a bare `?`.
70+
"""
71+
parsed = urlparse(auth_endpoint)
72+
query = urlencode(parse_qsl(parsed.query, keep_blank_values=True) + list(auth_params.items()))
73+
return urlunparse(parsed._replace(query=query))
74+
75+
6476
# Methods a registered client's record may carry without a token request being an error,
6577
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
6678
# send no client secret. `private_key_jwt` sends none from here either: only
@@ -424,7 +436,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
424436
if "offline_access" in self.context.client_metadata.scope.split():
425437
auth_params["prompt"] = "consent"
426438

427-
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
439+
authorization_url = _build_authorization_url(auth_endpoint, auth_params)
428440
await self.context.redirect_handler(authorization_url)
429441

430442
# Wait for callback

‎tests/client/test_auth.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3667,3 +3667,45 @@ async def echo_callback() -> AuthorizationCodeResult:
36673667
await auth_flow.asend(httpx2.Response(200, request=final_req))
36683668
except StopAsyncIteration:
36693669
pass
3670+
3671+
3672+
@pytest.mark.anyio
3673+
async def test_authorization_url_preserves_existing_endpoint_query(
3674+
oauth_provider: OAuthClientProvider,
3675+
):
3676+
"""RFC 6749 §3.1: the authorization endpoint URI may include a query component, so the
3677+
flow's parameters must be merged into it rather than appended after a second `?`."""
3678+
oauth_provider.context.oauth_metadata = OAuthMetadata(
3679+
issuer=AnyHttpUrl("https://auth.example.com"),
3680+
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize?audience=mcp&prompt="),
3681+
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
3682+
)
3683+
oauth_provider.context.client_info = OAuthClientInformationFull(
3684+
client_id="test_client_id",
3685+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3686+
)
3687+
3688+
captured_url: str | None = None
3689+
captured_state: str | None = None
3690+
3691+
async def capture_redirect(url: str) -> None:
3692+
nonlocal captured_url, captured_state
3693+
captured_url = url
3694+
captured_state = parse_qs(urlparse(url).query)["state"][0]
3695+
3696+
async def mock_callback() -> AuthorizationCodeResult:
3697+
return AuthorizationCodeResult(code="auth_code", state=captured_state)
3698+
3699+
oauth_provider.context.redirect_handler = capture_redirect
3700+
oauth_provider.context.callback_handler = mock_callback
3701+
3702+
auth_code, _ = await oauth_provider._perform_authorization_code_grant()
3703+
3704+
assert auth_code == "auth_code"
3705+
assert captured_url is not None
3706+
assert captured_url.count("?") == 1
3707+
params = parse_qs(urlparse(captured_url).query, keep_blank_values=True)
3708+
assert params["audience"] == ["mcp"] # the endpoint's own parameters survive
3709+
assert params["prompt"] == [""] # including blank-valued ones
3710+
assert params["client_id"] == ["test_client_id"]
3711+
assert params["response_type"] == ["code"]

0 commit comments

Comments
 (0)