From 3b6b9d36bd7b40d8e4ae10617cbf6d1915097188 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:05:00 +0530 Subject: [PATCH 1/7] 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 | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ebf534d79..0c1abc6f9 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -89,11 +89,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 7463bc5a8..0fba1c722 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -3,7 +3,39 @@ import pytest from pydantic import ValidationError -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata +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 3017e6a2e80e002620873e33eaa269c5204e8ba9 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:21:56 +0530 Subject: [PATCH 2/7] 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 0fba1c722..e64a3e886 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -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 e8db3125afe2c85827c9e3f74f5c2730d7dd8ed6 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:29:27 +0530 Subject: [PATCH 3/7] 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 e64a3e886..2b4a00658 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -7,10 +7,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 5db7bd0acccd7b8b80ea7fd03a1feb12427df099 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 15:30:33 +0530 Subject: [PATCH 4/7] 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 0c1abc6f9..111362758 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -92,10 +92,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 cbe489e2997d3c5f7e808d66573a1a30201e0ce8 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 18:13:34 +0530 Subject: [PATCH 5/7] 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 111362758..55d85fcf1 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -96,9 +96,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 95d6e3fcc40309c6aebd645825b1329f500dc5a2 Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Wed, 8 Apr 2026 18:13:47 +0530 Subject: [PATCH 6/7] 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 From babd4fa65f3cc30550b6170af982ab346f6d6b1c Mon Sep 17 00:00:00 2001 From: Enjoy Kumawat Date: Tue, 14 Apr 2026 13:25:07 +0530 Subject: [PATCH 7/7] fix: restore OAuthClientMetadata import removed during rebase --- 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 2b4a00658..e263452a2 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthMetadata +from mcp.shared.auth import InvalidScopeError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata def _make_client(scope: str | None) -> OAuthClientInformationFull: