Skip to content

Commit 1a9ead0

Browse files
authored
relax validation (#879)
1 parent 2cbc435 commit 1a9ead0

File tree

4 files changed

+41
-34
lines changed

4 files changed

+41
-34
lines changed

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ async def exchange_authorization_code(
214214

215215
return OAuthToken(
216216
access_token=mcp_token,
217-
token_type="bearer",
217+
token_type="Bearer",
218218
expires_in=3600,
219219
scope=" ".join(authorization_code.scopes),
220220
)

src/mcp/shared/auth.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Literal
22

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field
3+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
44

55

66
class OAuthToken(BaseModel):
@@ -9,11 +9,20 @@ class OAuthToken(BaseModel):
99
"""
1010

1111
access_token: str
12-
token_type: Literal["bearer"] = "bearer"
12+
token_type: Literal["Bearer"] = "Bearer"
1313
expires_in: int | None = None
1414
scope: str | None = None
1515
refresh_token: str | None = None
1616

17+
@field_validator("token_type", mode="before")
18+
@classmethod
19+
def normalize_token_type(cls, v: str | None) -> str | None:
20+
if isinstance(v, str):
21+
# Bearer is title-cased in the spec, so we normalize it
22+
# https://datatracker.ietf.org/doc/html/rfc6750#section-4
23+
return v.title()
24+
return v
25+
1726

1827
class InvalidScopeError(Exception):
1928
def __init__(self, message: str):
@@ -111,27 +120,19 @@ class OAuthMetadata(BaseModel):
111120
token_endpoint: AnyHttpUrl
112121
registration_endpoint: AnyHttpUrl | None = None
113122
scopes_supported: list[str] | None = None
114-
response_types_supported: list[Literal["code"]] = ["code"]
123+
response_types_supported: list[str] = ["code"]
115124
response_modes_supported: list[Literal["query", "fragment"]] | None = None
116-
grant_types_supported: (
117-
list[Literal["authorization_code", "refresh_token"]] | None
118-
) = None
119-
token_endpoint_auth_methods_supported: (
120-
list[Literal["none", "client_secret_post"]] | None
121-
) = None
125+
grant_types_supported: list[str] | None = None
126+
token_endpoint_auth_methods_supported: list[str] | None = None
122127
token_endpoint_auth_signing_alg_values_supported: None = None
123128
service_documentation: AnyHttpUrl | None = None
124129
ui_locales_supported: list[str] | None = None
125130
op_policy_uri: AnyHttpUrl | None = None
126131
op_tos_uri: AnyHttpUrl | None = None
127132
revocation_endpoint: AnyHttpUrl | None = None
128-
revocation_endpoint_auth_methods_supported: (
129-
list[Literal["client_secret_post"]] | None
130-
) = None
133+
revocation_endpoint_auth_methods_supported: list[str] | None = None
131134
revocation_endpoint_auth_signing_alg_values_supported: None = None
132135
introspection_endpoint: AnyHttpUrl | None = None
133-
introspection_endpoint_auth_methods_supported: (
134-
list[Literal["client_secret_post"]] | None
135-
) = None
136+
introspection_endpoint_auth_methods_supported: list[str] | None = None
136137
introspection_endpoint_auth_signing_alg_values_supported: None = None
137-
code_challenge_methods_supported: list[Literal["S256"]] | None = None
138+
code_challenge_methods_supported: list[str] | None = None

tests/client/test_auth.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def oauth_client_info():
9191
def oauth_token():
9292
return OAuthToken(
9393
access_token="test_access_token",
94-
token_type="bearer",
94+
token_type="Bearer",
9595
expires_in=3600,
9696
refresh_token="test_refresh_token",
9797
scope="read write",
@@ -143,7 +143,8 @@ def test_generate_code_verifier(self, oauth_provider):
143143
verifiers = {oauth_provider._generate_code_verifier() for _ in range(10)}
144144
assert len(verifiers) == 10
145145

146-
def test_generate_code_challenge(self, oauth_provider):
146+
@pytest.mark.anyio
147+
async def test_generate_code_challenge(self, oauth_provider):
147148
"""Test PKCE code challenge generation."""
148149
verifier = "test_code_verifier_123"
149150
challenge = oauth_provider._generate_code_challenge(verifier)
@@ -161,7 +162,8 @@ def test_generate_code_challenge(self, oauth_provider):
161162
assert "+" not in challenge
162163
assert "/" not in challenge
163164

164-
def test_get_authorization_base_url(self, oauth_provider):
165+
@pytest.mark.anyio
166+
async def test_get_authorization_base_url(self, oauth_provider):
165167
"""Test authorization base URL extraction."""
166168
# Test with path
167169
assert (
@@ -348,11 +350,13 @@ async def test_register_oauth_client_failure(self, oauth_provider):
348350
None,
349351
)
350352

351-
def test_has_valid_token_no_token(self, oauth_provider):
353+
@pytest.mark.anyio
354+
async def test_has_valid_token_no_token(self, oauth_provider):
352355
"""Test token validation with no token."""
353356
assert not oauth_provider._has_valid_token()
354357

355-
def test_has_valid_token_valid(self, oauth_provider, oauth_token):
358+
@pytest.mark.anyio
359+
async def test_has_valid_token_valid(self, oauth_provider, oauth_token):
356360
"""Test token validation with valid token."""
357361
oauth_provider._current_tokens = oauth_token
358362
oauth_provider._token_expiry_time = time.time() + 3600 # Future expiry
@@ -370,7 +374,7 @@ async def test_has_valid_token_expired(self, oauth_provider, oauth_token):
370374
@pytest.mark.anyio
371375
async def test_validate_token_scopes_no_scope(self, oauth_provider):
372376
"""Test scope validation with no scope returned."""
373-
token = OAuthToken(access_token="test", token_type="bearer")
377+
token = OAuthToken(access_token="test", token_type="Bearer")
374378

375379
# Should not raise exception
376380
await oauth_provider._validate_token_scopes(token)
@@ -381,7 +385,7 @@ async def test_validate_token_scopes_valid(self, oauth_provider, client_metadata
381385
oauth_provider.client_metadata = client_metadata
382386
token = OAuthToken(
383387
access_token="test",
384-
token_type="bearer",
388+
token_type="Bearer",
385389
scope="read write",
386390
)
387391

@@ -394,7 +398,7 @@ async def test_validate_token_scopes_subset(self, oauth_provider, client_metadat
394398
oauth_provider.client_metadata = client_metadata
395399
token = OAuthToken(
396400
access_token="test",
397-
token_type="bearer",
401+
token_type="Bearer",
398402
scope="read",
399403
)
400404

@@ -409,7 +413,7 @@ async def test_validate_token_scopes_unauthorized(
409413
oauth_provider.client_metadata = client_metadata
410414
token = OAuthToken(
411415
access_token="test",
412-
token_type="bearer",
416+
token_type="Bearer",
413417
scope="read write admin", # Includes unauthorized "admin"
414418
)
415419

@@ -423,7 +427,7 @@ async def test_validate_token_scopes_no_requested(self, oauth_provider):
423427
oauth_provider.client_metadata.scope = None
424428
token = OAuthToken(
425429
access_token="test",
426-
token_type="bearer",
430+
token_type="Bearer",
427431
scope="admin super",
428432
)
429433

@@ -530,7 +534,7 @@ async def test_refresh_access_token_success(
530534

531535
new_token = OAuthToken(
532536
access_token="new_access_token",
533-
token_type="bearer",
537+
token_type="Bearer",
534538
expires_in=3600,
535539
refresh_token="new_refresh_token",
536540
scope="read write",
@@ -563,7 +567,7 @@ async def test_refresh_access_token_no_refresh_token(self, oauth_provider):
563567
"""Test token refresh with no refresh token."""
564568
oauth_provider._current_tokens = OAuthToken(
565569
access_token="test",
566-
token_type="bearer",
570+
token_type="Bearer",
567571
# No refresh_token
568572
)
569573

@@ -756,7 +760,8 @@ async def test_async_auth_flow_no_token(self, oauth_provider):
756760
# No Authorization header should be added if no token
757761
assert "Authorization" not in updated_request.headers
758762

759-
def test_scope_priority_client_metadata_first(
763+
@pytest.mark.anyio
764+
async def test_scope_priority_client_metadata_first(
760765
self, oauth_provider, oauth_client_info
761766
):
762767
"""Test that client metadata scope takes priority."""
@@ -785,7 +790,8 @@ def test_scope_priority_client_metadata_first(
785790

786791
assert auth_params["scope"] == "read write"
787792

788-
def test_scope_priority_no_client_metadata_scope(
793+
@pytest.mark.anyio
794+
async def test_scope_priority_no_client_metadata_scope(
789795
self, oauth_provider, oauth_client_info
790796
):
791797
"""Test that no scope parameter is set when client metadata has no scope."""

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ async def exchange_authorization_code(
9696

9797
return OAuthToken(
9898
access_token=access_token,
99-
token_type="bearer",
99+
token_type="Bearer",
100100
expires_in=3600,
101101
scope="read write",
102102
refresh_token=refresh_token,
@@ -160,7 +160,7 @@ async def exchange_refresh_token(
160160

161161
return OAuthToken(
162162
access_token=new_access_token,
163-
token_type="bearer",
163+
token_type="Bearer",
164164
expires_in=3600,
165165
scope=" ".join(scopes) if scopes else " ".join(token_info.scopes),
166166
refresh_token=new_refresh_token,
@@ -831,7 +831,7 @@ async def test_authorization_get(
831831
assert "token_type" in token_response
832832
assert "refresh_token" in token_response
833833
assert "expires_in" in token_response
834-
assert token_response["token_type"] == "bearer"
834+
assert token_response["token_type"] == "Bearer"
835835

836836
# 5. Verify the access token
837837
access_token = token_response["access_token"]

0 commit comments

Comments
 (0)