From 6f2a1a29d06f16999b7f89f1e45f050b4c2be807 Mon Sep 17 00:00:00 2001 From: Sebastian Velten Date: Fri, 24 Oct 2025 12:09:38 +0200 Subject: [PATCH 1/2] feat: deal with application/x-www-form-urlencoded token response --- src/mcp/client/auth.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 91f8576d7..c6c2dde0f 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -14,7 +14,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, field from typing import Protocol -from urllib.parse import urlencode, urljoin, urlparse +from urllib.parse import parse_qs, urlencode, urljoin, urlparse import anyio import httpx @@ -427,14 +427,43 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) + def _parse_content_type(self, content_type: str) -> tuple[str, dict[str, str]]: + """Parse Content-Type header into media type and parameters.""" + parts = content_type.split(";") + media_type = parts[0].strip() + + params: dict[str, str] = {} + for part in parts[1:]: + if "=" in part: + key, value = part.split("=", 1) + params[key.strip()] = value.strip() + + return media_type, params + async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" if response.status_code != 200: raise OAuthTokenError(f"Token exchange failed: {response.status_code}") + content_type = response.headers.get("Content-Type") + if content_type is None: + raise OAuthTokenError("Token exchange failed: Missing 'Content-Type' response header") + + media_type, params = self._parse_content_type(content_type) + if media_type not in ("application/json", "application/x-www-form-urlencoded"): + raise OAuthTokenError(f"Token exchange failed: Unexpected token response content type {media_type}") + try: content = await response.aread() - token_response = OAuthToken.model_validate_json(content) + if media_type == "application/json": + token_response = OAuthToken.model_validate_json(content) + else: + charset = params.get("charset", "utf-8") + parsed = parse_qs(content.decode(charset)) + token_data = {key: value[0] if value else None for key, value in parsed.items()} + if scope := token_data.get("scope"): + token_data["scope"] = scope.replace(",", " ") + token_response = OAuthToken.model_validate(token_data) # Validate scopes if token_response.scope and self.context.client_metadata.scope: From 71f7e39b1b78779fec4e0b6eb1c0a7b308d4dd9f Mon Sep 17 00:00:00 2001 From: Sebastian Velten Date: Fri, 24 Oct 2025 12:09:44 +0200 Subject: [PATCH 2/2] test: update auth flow tests --- tests/client/test_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index fb1a93e39..06af54753 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -401,6 +401,7 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl # Send a successful token response token_response = httpx.Response( 200, + headers={"Content-Type": "application/json"}, content=( b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' b'"refresh_token": "new_refresh_token"}' @@ -790,9 +791,9 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide # Send a successful token response token_response = httpx.Response( 200, + headers={"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}, content=( - b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' - b'"refresh_token": "new_refresh_token"}' + b"access_token=new_access_token&token_type=bearer&expires_in=3600&refresh_token=new_refresh_token" ), request=token_request, )