From 136718460eaac9e719e65b5e14e764fe9da7d8aa Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Wed, 11 Feb 2026 14:26:10 -0800 Subject: [PATCH 1/2] feat: implement refresh token guidance sep-2207 --- src/mcp/client/auth/oauth2.py | 11 +- src/mcp/client/auth/utils.py | 40 ++-- tests/client/test_auth.py | 354 ++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 15 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 41aecc6f2..28fb7587e 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -346,6 +346,11 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: if self.context.client_metadata.scope: # pragma: no branch auth_params["scope"] = self.context.client_metadata.scope + # OIDC requires prompt=consent when offline_access is requested + # https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + if "offline_access" in self.context.client_metadata.scope.split(): + auth_params["prompt"] = "consent" + authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" await self.context.redirect_handler(authorization_url) @@ -580,6 +585,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. extract_scope_from_www_auth(response), self.context.protected_resource_metadata, self.context.oauth_metadata, + self.context.client_metadata.grant_types, ) # Step 4: Register client or use URL-based client ID (CIMD) @@ -626,7 +632,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. try: # Step 2a: Update the required scopes self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), self.context.protected_resource_metadata + extract_scope_from_www_auth(response), + self.context.protected_resource_metadata, + self.context.oauth_metadata, + self.context.client_metadata.grant_types, ) # Step 2b: Perform (re-)authorization and token exchange diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 1aa960b9c..2c92a692c 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -99,24 +99,36 @@ def get_client_metadata_scopes( www_authenticate_scope: str | None, protected_resource_metadata: ProtectedResourceMetadata | None, authorization_server_metadata: OAuthMetadata | None = None, + client_grant_types: list[str] | None = None, ) -> str | None: - """Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec.""" - # Per MCP spec, scope selection priority order: - # 1. Use scope from WWW-Authenticate header (if provided) - # 2. Use all scopes from PRM scopes_supported (if available) - # 3. Omit scope parameter if neither is available - + """Select effective scopes and augment for refresh token support.""" + selected_scope: str | None = None + + # MCP spec scope selection priority: + # 1. WWW-Authenticate header scope + # 2. PRM scopes_supported + # 3. AS scopes_supported (SDK fallback) + # 4. Omit scope parameter if www_authenticate_scope is not None: - # Priority 1: WWW-Authenticate header scope - return www_authenticate_scope + selected_scope = www_authenticate_scope elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: - # Priority 2: PRM scopes_supported - return " ".join(protected_resource_metadata.scopes_supported) + selected_scope = " ".join(protected_resource_metadata.scopes_supported) elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: - return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover - else: - # Priority 3: Omit scope parameter - return None + selected_scope = " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover + + # SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens + if ( + selected_scope is not None + and authorization_server_metadata is not None + and authorization_server_metadata.scopes_supported is not None + and "offline_access" in authorization_server_metadata.scopes_supported + and client_grant_types is not None + and "refresh_token" in client_grant_types + and "offline_access" not in selected_scope.split() + ): + selected_scope = f"{selected_scope} offline_access" + + return selected_scope def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 5aa985e36..bb0bce4c9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2264,3 +2264,357 @@ async def callback_handler() -> tuple[str, str | None]: await auth_flow.asend(final_response) except StopAsyncIteration: pass + + +class TestSEP2207OfflineAccessScope: + """Test SEP-2207: offline_access scope augmentation for OIDC-flavored refresh tokens.""" + + def _make_as_metadata(self, scopes_supported: list[str] | None = None) -> OAuthMetadata: + return OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + scopes_supported=scopes_supported, + ) + + def _make_prm(self, scopes_supported: list[str] | None = None) -> ProtectedResourceMetadata: + return ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=scopes_supported, + ) + + def test_offline_access_added_when_as_supports_and_client_has_refresh_token(self): + """offline_access is appended when AS advertises it and client supports refresh_token grant.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write offline_access" + + def test_offline_access_added_with_www_authenticate_scope(self): + """offline_access is appended even when scopes come from WWW-Authenticate header.""" + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope="read write", + protected_resource_metadata=None, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write offline_access" + + def test_offline_access_not_added_when_as_does_not_support(self): + """offline_access is not added when AS does not advertise it in scopes_supported.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_client_has_no_refresh_token_grant(self): + """offline_access is not added when client does not support refresh_token grant.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code"], + ) + assert scopes == "read write" + + def test_offline_access_not_duplicated_when_already_present(self): + """offline_access is not added again if it already appears in the selected scopes.""" + prm = self._make_prm(scopes_supported=["read", "offline_access", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read offline_access write" + + def test_offline_access_not_added_when_no_scopes_selected(self): + """offline_access is not added when no base scopes are available (None).""" + asm = self._make_as_metadata(scopes_supported=["offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=None, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + # When AS scopes are the only source and include offline_access, + # the base scope is "offline_access" and no duplication happens + assert scopes == "offline_access" + + def test_offline_access_not_added_when_as_scopes_supported_is_none(self): + """offline_access is not added when AS scopes_supported is None.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=None) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_no_as_metadata(self): + """offline_access is not added when AS metadata is not available.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=None, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_no_grant_types_provided(self): + """offline_access is not added when client_grant_types is None.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=None, + ) + assert scopes == "read write" + + def test_default_client_metadata_includes_refresh_token_grant(self): + """Default OAuthClientMetadata includes refresh_token in grant_types (SEP-2207 guidance).""" + metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")]) + assert "refresh_token" in metadata.grant_types + + @pytest.mark.anyio + async def test_auth_flow_adds_offline_access_when_as_advertises( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """E2E: auth flow includes offline_access in authorization request when AS advertises it.""" + + captured_auth_url: str | None = None + captured_state: str | None = None + + async def redirect_handler(url: str) -> None: + nonlocal captured_auth_url, captured_state + captured_auth_url = url + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", captured_state + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Pre-set client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send 401 + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp",' + b' "authorization_servers": ["https://auth.example.com"],' + b' "scopes_supported": ["read", "write"]}' + ), + request=prm_request, + ) + + # OAuth metadata discovery - AS advertises offline_access + oauth_request = await auth_flow.asend(prm_response) + oauth_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com",' + b' "authorization_endpoint": "https://auth.example.com/authorize",' + b' "token_endpoint": "https://auth.example.com/token",' + b' "scopes_supported": ["read", "write", "offline_access"]}' + ), + request=oauth_request, + ) + + # This triggers authorization, which calls redirect_handler + token_request = await auth_flow.asend(oauth_response) + + # Verify the authorization URL included offline_access in the scope + assert captured_auth_url is not None + parsed = urlparse(captured_auth_url) + params = parse_qs(parsed.query) + scope_value = params["scope"][0] + scope_parts = scope_value.split() + assert "offline_access" in scope_parts + assert "read" in scope_parts + assert "write" in scope_parts + + # OIDC requires prompt=consent when offline_access is requested + assert params["prompt"][0] == "consent" + + # Complete the token exchange + token_response = httpx.Response( + 200, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer",' + b' "expires_in": 3600, "refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + + # Close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_auth_flow_no_offline_access_when_as_does_not_advertise( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """E2E: auth flow does NOT include offline_access when AS doesn't advertise it.""" + + captured_auth_url: str | None = None + captured_state: str | None = None + + async def redirect_handler(url: str) -> None: + nonlocal captured_auth_url, captured_state + captured_auth_url = url + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", captured_state + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Pre-set client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + await auth_flow.__anext__() + + # Send 401 + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp",' + b' "authorization_servers": ["https://auth.example.com"],' + b' "scopes_supported": ["read", "write"]}' + ), + request=prm_request, + ) + + # OAuth metadata discovery - AS does NOT advertise offline_access + oauth_request = await auth_flow.asend(prm_response) + oauth_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com",' + b' "authorization_endpoint": "https://auth.example.com/authorize",' + b' "token_endpoint": "https://auth.example.com/token",' + b' "scopes_supported": ["read", "write"]}' + ), + request=oauth_request, + ) + + # This triggers authorization, which calls redirect_handler + token_request = await auth_flow.asend(oauth_response) + + # Verify the authorization URL does NOT include offline_access + assert captured_auth_url is not None + parsed = urlparse(captured_auth_url) + params = parse_qs(parsed.query) + scope_value = params["scope"][0] + scope_parts = scope_value.split() + assert "offline_access" not in scope_parts + assert "read" in scope_parts + assert "write" in scope_parts + + # prompt=consent should NOT be present without offline_access + assert "prompt" not in params + + # Complete the token exchange + token_response = httpx.Response( + 200, + content=b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + + # Close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass From ba50af6122e23668b66d5f2159b25decdb720338 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Wed, 11 Feb 2026 14:51:41 -0800 Subject: [PATCH 2/2] fix: lint --- src/mcp/client/auth/oauth2.py | 4 ++-- src/mcp/client/auth/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 28fb7587e..315c8349b 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -318,7 +318,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: - auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover + auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) else: auth_base_url = self.context.get_authorization_base_url(self.context.server_url) auth_endpoint = urljoin(auth_base_url, "/authorize") @@ -341,7 +341,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: # Only include resource param if conditions are met if self.context.should_include_resource_param(self.context.protocol_version): - auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover + auth_params["resource"] = self.context.get_resource_url() # RFC 8707 if self.context.client_metadata.scope: # pragma: no branch auth_params["scope"] = self.context.client_metadata.scope diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 2c92a692c..56d8fa7cc 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -114,7 +114,7 @@ def get_client_metadata_scopes( elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: selected_scope = " ".join(protected_resource_metadata.scopes_supported) elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: - selected_scope = " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover + selected_scope = " ".join(authorization_server_metadata.scopes_supported) # SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens if (