From 456692caf5b8b72014293409ca7156e05d2935ec Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:05:00 +0530 Subject: [PATCH 1/6] fix: validate_scope allows any scope when client has no registered scope restriction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OAuthClientMetadata.scope is None it means the client was registered without any scope restriction. The previous code built allowed_scopes as an empty list in that case, causing every requested scope to raise InvalidScopeError — the opposite of the intended behavior. Fix: return requested_scopes immediately when self.scope is None, bypassing the per-scope membership check. Also remove the now-unreachable pragma comments that suppressed coverage for the success path of the loop. Github-Issue: #2216 --- src/mcp/shared/auth.py | 9 ++++++--- tests/shared/test_auth.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ca5b7b45a..bfc251ee5 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,11 +71,14 @@ 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(" ") + if self.scope is None: + # Client registered without scope restrictions — allow any requested scope + return requested_scopes + allowed_scopes = self.scope.split(" ") for scope in requested_scopes: - if scope not in allowed_scopes: # pragma: no branch + if scope not in allowed_scopes: raise InvalidScopeError(f"Client was not registered with scope {scope}") - return requested_scopes # pragma: no cover + 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..172e61816 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,40 @@ """Tests for OAuth 2.0 shared code.""" -from mcp.shared.auth import OAuthMetadata +import pytest + +from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata + + +def _make_client(scope: str | None) -> OAuthClientInformationFull: + return OAuthClientInformationFull( + 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(): From c257c6950e88cdf5555848dfd6e5f2499426d413 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:21:56 +0530 Subject: [PATCH 2/6] fix: remove unused OAuthClientMetadata import in test_auth Github-Issue: #2216 --- tests/shared/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 172e61816..223b0b182 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -2,7 +2,7 @@ import pytest -from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata +from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthMetadata def _make_client(scope: str | None) -> OAuthClientInformationFull: From 71d0f7f758472213032634a05dd07f23fa45fbf9 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:29:27 +0530 Subject: [PATCH 3/6] fix: use model_validate to satisfy pyright AnyUrl type check Github-Issue: #2216 --- tests/shared/test_auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 223b0b182..55f5eac27 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -6,10 +6,12 @@ def _make_client(scope: str | None) -> OAuthClientInformationFull: - return OAuthClientInformationFull( - redirect_uris=["https://example.com/callback"], - scope=scope, - client_id="test-client", + return OAuthClientInformationFull.model_validate( + { + "redirect_uris": ["https://example.com/callback"], + "scope": scope, + "client_id": "test-client", + } ) From 6434eef9d02de83cd58b71efe5aa5da9b40abcc7 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:30:33 +0530 Subject: [PATCH 4/6] refactor: use set operations for scope validation per reviewer feedback Replace the for-loop with set.issubset() for clarity and to report all invalid scopes at once rather than one per loop iteration. Github-Issue: #2216 --- src/mcp/shared/auth.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index bfc251ee5..ce1e95c7d 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -74,10 +74,13 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: if self.scope is None: # Client registered without scope restrictions — allow any requested scope return requested_scopes - allowed_scopes = self.scope.split(" ") - for scope in requested_scopes: - if scope not in allowed_scopes: - raise InvalidScopeError(f"Client was not registered with scope {scope}") + 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: From 1b759d679757c6992549d3a95a0c586f83ffd8b0 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 18:13:34 +0530 Subject: [PATCH 5/6] style: inline InvalidScopeError raise to fit on one line Collapses the multi-line raise into a single line now that the message fits within the line length limit. --- src/mcp/shared/auth.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ce1e95c7d..d7e6cbc3b 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -78,9 +78,7 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: 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))}" - ) + 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: From 384b9dba59bfd384ef04eb6707c8cc84bdfb15ac Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 18:13:47 +0530 Subject: [PATCH 6/6] fix: preserve reconnect attempt counter on second stream disconnect When the SSE stream ends a second time without a response, the reconnect attempt counter was incorrectly reset to 0, causing infinite reconnect loops instead of properly backing off. Pass `attempt + 1` so the counter advances correctly. --- src/mcp/client/streamable_http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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