From 5cfe6aa51071aac3f891cb426f62a90598b82029 Mon Sep 17 00:00:00 2001 From: Christian Sidak Date: Thu, 16 Apr 2026 21:39:37 -0700 Subject: [PATCH] Fix validate_scope rejecting scopes when client scope is None When OAuthClientMetadata.scope is None (no scopes registered), validate_scope() was converting it to an empty list, causing all requested scopes to be rejected with InvalidScopeError. Now treats None as "no restrictions" and allows any requested scope through. Fixes #2216 --- src/mcp/shared/auth.py | 9 ++++--- tests/shared/test_auth.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ebf534d79..79ef3cc8d 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: + # No registered scopes means no restrictions + 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..11c0f49c9 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -138,3 +138,52 @@ def test_invalid_non_empty_url_still_rejected(): } with pytest.raises(ValidationError): OAuthClientMetadata.model_validate(data) + + +class TestValidateScope: + """Tests for OAuthClientMetadata.validate_scope().""" + + def _make_client(self, scope: str | None = None) -> OAuthClientMetadata: + return OAuthClientMetadata.model_validate( + { + "redirect_uris": ["https://example.com/callback"], + "scope": scope, + } + ) + + def test_requested_scope_none_returns_none(self): + client = self._make_client(scope="read write") + assert client.validate_scope(None) is None + + def test_registered_scope_none_allows_any_requested_scope(self): + """When the client has no registered scopes (scope=None), + any requested scope should be allowed through.""" + client = self._make_client(scope=None) + result = client.validate_scope("read write admin") + assert result == ["read", "write", "admin"] + + def test_registered_scope_none_allows_single_scope(self): + client = self._make_client(scope=None) + result = client.validate_scope("read") + assert result == ["read"] + + def test_valid_scope_subset(self): + client = self._make_client(scope="read write admin") + result = client.validate_scope("read write") + assert result == ["read", "write"] + + def test_valid_scope_exact_match(self): + client = self._make_client(scope="read write") + result = client.validate_scope("read write") + assert result == ["read", "write"] + + def test_invalid_scope_raises_error(self): + from mcp.shared.auth import InvalidScopeError + + client = self._make_client(scope="read write") + with pytest.raises(InvalidScopeError, match="delete"): + client.validate_scope("read delete") + + def test_no_registered_scope_and_no_requested_scope(self): + client = self._make_client(scope=None) + assert client.validate_scope(None) is None