diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 9a119c633..7d4dde1d0 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -421,9 +421,9 @@ async def _handle_reconnection( await event_source.response.aclose() return - # Stream ended again without response - reconnect again (reset attempt counter) + # Stream ended again without response - reconnect again logger.info("SSE stream disconnected, reconnecting...") - await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0) + await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, attempt + 1) except Exception as e: # pragma: no cover logger.debug(f"Reconnection failed: {e}") # Try to reconnect again if we still have an event ID diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ca5b7b45a..d7e6cbc3b 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,11 +71,15 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None requested_scopes = requested_scope.split(" ") - allowed_scopes = [] if self.scope is None else self.scope.split(" ") - for scope in requested_scopes: - if scope not in allowed_scopes: # pragma: no branch - raise InvalidScopeError(f"Client was not registered with scope {scope}") - return requested_scopes # pragma: no cover + if self.scope is None: + # Client registered without scope restrictions — allow any requested scope + return requested_scopes + requested = set(requested_scopes) + allowed = set(self.scope.split()) + if not requested.issubset(allowed): + invalid = requested - allowed + raise InvalidScopeError(f"Client was not registered with scope(s): {' '.join(sorted(invalid))}") + return requested_scopes def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: if redirect_uri is not None: diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index cd3c35332..55f5eac27 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,42 @@ """Tests for OAuth 2.0 shared code.""" -from mcp.shared.auth import OAuthMetadata +import pytest + +from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthMetadata + + +def _make_client(scope: str | None) -> OAuthClientInformationFull: + return OAuthClientInformationFull.model_validate( + { + "redirect_uris": ["https://example.com/callback"], + "scope": scope, + "client_id": "test-client", + } + ) + + +def test_validate_scope_returns_none_when_no_scope_requested(): + client = _make_client("read write") + assert client.validate_scope(None) is None + + +def test_validate_scope_allows_registered_scopes(): + client = _make_client("read write") + assert client.validate_scope("read") == ["read"] + assert client.validate_scope("read write") == ["read", "write"] + + +def test_validate_scope_raises_for_unregistered_scope(): + client = _make_client("read") + with pytest.raises(InvalidScopeError): + client.validate_scope("read admin") + + +def test_validate_scope_allows_any_scope_when_client_has_no_scope_restriction(): + """When client.scope is None, any requested scope should be allowed (issue #2216).""" + client = _make_client(None) + assert client.validate_scope("read") == ["read"] + assert client.validate_scope("read write admin") == ["read", "write", "admin"] def test_oauth():