Skip to content

Commit 5ebbc19

Browse files
committed
Validate scopes + provide default
1 parent 31618c1 commit 5ebbc19

File tree

5 files changed

+108
-29
lines changed

5 files changed

+108
-29
lines changed

src/mcp/server/auth/handlers/register.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.server.auth.errors import stringify_pydantic_error
1212
from mcp.server.auth.json_response import PydanticJSONResponse
1313
from mcp.server.auth.provider import OAuthRegisteredClientsStore
14+
from mcp.server.auth.settings import ClientRegistrationOptions
1415
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
1516

1617

@@ -33,14 +34,16 @@ class RegistrationErrorResponse(BaseModel):
3334
@dataclass
3435
class RegistrationHandler:
3536
clients_store: OAuthRegisteredClientsStore
36-
client_secret_expiry_seconds: int | None
37+
options: ClientRegistrationOptions
3738

3839
async def handle(self, request: Request) -> Response:
3940
# Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
4041
try:
4142
# Parse request body as JSON
4243
body = await request.json()
4344
client_metadata = OAuthClientMetadata.model_validate(body)
45+
46+
# Scope validation is handled below
4447
except ValidationError as validation_error:
4548
return PydanticJSONResponse(
4649
content=RegistrationErrorResponse(
@@ -56,10 +59,27 @@ async def handle(self, request: Request) -> Response:
5659
# cryptographically secure random 32-byte hex string
5760
client_secret = secrets.token_hex(32)
5861

62+
if client_metadata.scope is None and self.options.default_scopes is not None:
63+
client_metadata.scope = " ".join(self.options.default_scopes)
64+
elif (
65+
client_metadata.scope is not None and self.options.valid_scopes is not None
66+
):
67+
requested_scopes = set(client_metadata.scope.split())
68+
valid_scopes = set(self.options.valid_scopes)
69+
if not requested_scopes.issubset(valid_scopes):
70+
return PydanticJSONResponse(
71+
content=RegistrationErrorResponse(
72+
error="invalid_client_metadata",
73+
error_description="Requested scopes are not valid: "
74+
f"{', '.join(requested_scopes - valid_scopes)}",
75+
),
76+
status_code=400,
77+
)
78+
5979
client_id_issued_at = int(time.time())
6080
client_secret_expires_at = (
61-
client_id_issued_at + self.client_secret_expiry_seconds
62-
if self.client_secret_expiry_seconds is not None
81+
client_id_issued_at + self.options.client_secret_expiry_seconds
82+
if self.options.client_secret_expiry_seconds is not None
6383
else None
6484
)
6585

src/mcp/server/auth/routes.py

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Callable
22

3-
from pydantic import AnyHttpUrl, BaseModel, Field
3+
from pydantic import AnyHttpUrl
44
from starlette.routing import Route
55

66
from mcp.server.auth.handlers.authorize import AuthorizationHandler
@@ -10,30 +10,10 @@
1010
from mcp.server.auth.handlers.token import TokenHandler
1111
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
1212
from mcp.server.auth.provider import OAuthServerProvider
13+
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
1314
from mcp.shared.auth import OAuthMetadata
1415

1516

16-
class ClientRegistrationOptions(BaseModel):
17-
enabled: bool = False
18-
client_secret_expiry_seconds: int | None = None
19-
20-
21-
class RevocationOptions(BaseModel):
22-
enabled: bool = False
23-
24-
25-
class AuthSettings(BaseModel):
26-
issuer_url: AnyHttpUrl = Field(
27-
...,
28-
description="URL advertised as OAuth issuer; this should be the URL the server "
29-
"is reachable at",
30-
)
31-
service_documentation_url: AnyHttpUrl | None = None
32-
client_registration_options: ClientRegistrationOptions | None = None
33-
revocation_options: RevocationOptions | None = None
34-
required_scopes: list[str] | None = None
35-
36-
3717
def validate_issuer_url(url: AnyHttpUrl):
3818
"""
3919
Validate that the issuer URL meets OAuth 2.0 requirements.
@@ -109,7 +89,7 @@ def create_auth_routes(
10989
if client_registration_options.enabled:
11090
registration_handler = RegistrationHandler(
11191
provider.clients_store,
112-
client_secret_expiry_seconds=client_registration_options.client_secret_expiry_seconds,
92+
options=client_registration_options,
11393
)
11494
routes.append(
11595
Route(

src/mcp/server/auth/settings.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from pydantic import AnyHttpUrl, BaseModel, Field
2+
3+
4+
class ClientRegistrationOptions(BaseModel):
5+
enabled: bool = False
6+
client_secret_expiry_seconds: int | None = None
7+
valid_scopes: list[str] | None = None
8+
default_scopes: list[str] | None = None
9+
10+
11+
class RevocationOptions(BaseModel):
12+
enabled: bool = False
13+
14+
15+
class AuthSettings(BaseModel):
16+
issuer_url: AnyHttpUrl = Field(
17+
...,
18+
description="URL advertised as OAuth issuer; this should be the URL the server "
19+
"is reachable at",
20+
)
21+
service_documentation_url: AnyHttpUrl | None = None
22+
client_registration_options: ClientRegistrationOptions | None = None
23+
revocation_options: RevocationOptions | None = None
24+
required_scopes: list[str] | None = None

src/mcp/server/fastmcp/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
RequireAuthMiddleware,
3131
)
3232
from mcp.server.auth.provider import OAuthServerProvider
33-
from mcp.server.auth.routes import (
33+
from mcp.server.auth.settings import (
3434
AuthSettings,
3535
)
3636
from mcp.server.fastmcp.exceptions import ResourceError

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727
construct_redirect_uri,
2828
)
2929
from mcp.server.auth.routes import (
30-
AuthSettings,
3130
ClientRegistrationOptions,
3231
RevocationOptions,
3332
create_auth_routes,
3433
)
34+
from mcp.server.auth.settings import AuthSettings
3535
from mcp.server.fastmcp import FastMCP
3636
from mcp.shared.auth import (
3737
OAuthClientInformationFull,
@@ -226,7 +226,11 @@ def auth_app(mock_oauth_provider):
226226
mock_oauth_provider,
227227
AnyHttpUrl("https://auth.example.com"),
228228
AnyHttpUrl("https://docs.example.com"),
229-
client_registration_options=ClientRegistrationOptions(enabled=True),
229+
client_registration_options=ClientRegistrationOptions(
230+
enabled=True,
231+
valid_scopes=["read", "write", "profile"],
232+
default_scopes=["read", "write"],
233+
),
230234
revocation_options=RevocationOptions(enabled=True),
231235
)
232236

@@ -946,6 +950,57 @@ async def test_revoke_with_malformed_token(self, test_client, registered_client)
946950
assert error_response["error"] == "invalid_request"
947951
assert "token_type_hint" in error_response["error_description"]
948952

953+
@pytest.mark.anyio
954+
async def test_client_registration_disallowed_scopes(
955+
self, test_client: httpx.AsyncClient
956+
):
957+
"""Test client registration with scopes that are not allowed."""
958+
client_metadata = {
959+
"redirect_uris": ["https://client.example.com/callback"],
960+
"client_name": "Test Client",
961+
"scope": "read write profile admin", # 'admin' is not in valid_scopes
962+
}
963+
964+
response = await test_client.post(
965+
"/register",
966+
json=client_metadata,
967+
)
968+
assert response.status_code == 400
969+
error_data = response.json()
970+
assert "error" in error_data
971+
assert error_data["error"] == "invalid_client_metadata"
972+
assert "scope" in error_data["error_description"]
973+
assert "admin" in error_data["error_description"]
974+
975+
@pytest.mark.anyio
976+
async def test_client_registration_default_scopes(
977+
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
978+
):
979+
client_metadata = {
980+
"redirect_uris": ["https://client.example.com/callback"],
981+
"client_name": "Test Client",
982+
# No scope specified
983+
}
984+
985+
response = await test_client.post(
986+
"/register",
987+
json=client_metadata,
988+
)
989+
assert response.status_code == 201
990+
client_info = response.json()
991+
992+
# Verify client was registered successfully
993+
assert client_info["scope"] == "read write"
994+
995+
# Retrieve the client from the store to verify default scopes
996+
registered_client = await mock_oauth_provider.clients_store.get_client(
997+
client_info["client_id"]
998+
)
999+
assert registered_client is not None
1000+
1001+
# Check that default scopes were applied
1002+
assert registered_client.scope == "read write"
1003+
9491004

9501005
class TestFastMCPWithAuth:
9511006
"""Test FastMCP server with authentication."""

0 commit comments

Comments
 (0)