Skip to content

Commit 6282026

Browse files
committed
Make metadata more spec compliant
1 parent 7400109 commit 6282026

File tree

4 files changed

+92
-48
lines changed

4 files changed

+92
-48
lines changed

src/mcp/server/auth/router.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
2+
from typing import Callable
23

3-
from pydantic import AnyUrl
4+
from pydantic import AnyHttpUrl
45
from starlette.routing import Route, Router
56

67
from mcp.server.auth.handlers.authorize import AuthorizationHandler
@@ -24,7 +25,7 @@ class RevocationOptions:
2425
enabled: bool = False
2526

2627

27-
def validate_issuer_url(url: AnyUrl):
28+
def validate_issuer_url(url: AnyHttpUrl):
2829
"""
2930
Validate that the issuer URL meets OAuth 2.0 requirements.
3031
@@ -58,8 +59,8 @@ def validate_issuer_url(url: AnyUrl):
5859

5960
def create_auth_router(
6061
provider: OAuthServerProvider,
61-
issuer_url: AnyUrl,
62-
service_documentation_url: AnyUrl | None = None,
62+
issuer_url: AnyHttpUrl,
63+
service_documentation_url: AnyHttpUrl | None = None,
6364
client_registration_options: ClientRegistrationOptions | None = None,
6465
revocation_options: RevocationOptions | None = None,
6566
) -> Router:
@@ -134,34 +135,61 @@ def create_auth_router(
134135
return auth_router
135136

136137

138+
def modify_url_path(url: AnyHttpUrl, path_mapper: Callable[[str], str]) -> AnyHttpUrl:
139+
return AnyHttpUrl.build(
140+
scheme=url.scheme,
141+
username=url.username,
142+
password=url.password,
143+
host=url.host,
144+
port=url.port,
145+
path=path_mapper(url.path or ""),
146+
query=url.query,
147+
fragment=url.fragment,
148+
)
149+
150+
137151
def build_metadata(
138-
issuer_url: AnyUrl,
139-
service_documentation_url: AnyUrl | None,
152+
issuer_url: AnyHttpUrl,
153+
service_documentation_url: AnyHttpUrl | None,
140154
client_registration_options: ClientRegistrationOptions,
141155
revocation_options: RevocationOptions,
142156
) -> OAuthMetadata:
143-
issuer_url_str = str(issuer_url).rstrip("/")
157+
authorization_url = modify_url_path(
158+
issuer_url, lambda path: path.rstrip("/") + AUTHORIZATION_PATH.lstrip("/")
159+
)
160+
token_url = modify_url_path(
161+
issuer_url, lambda path: path.rstrip("/") + TOKEN_PATH.lstrip("/")
162+
)
144163
# Create metadata
145164
metadata = OAuthMetadata(
146-
issuer=issuer_url_str,
147-
service_documentation=str(service_documentation_url).rstrip("/")
148-
if service_documentation_url
149-
else None,
150-
authorization_endpoint=f"{issuer_url_str}{AUTHORIZATION_PATH}",
165+
issuer=issuer_url,
166+
authorization_endpoint=authorization_url,
167+
token_endpoint=token_url,
168+
scopes_supported=None,
151169
response_types_supported=["code"],
152-
code_challenge_methods_supported=["S256"],
153-
token_endpoint=f"{issuer_url_str}{TOKEN_PATH}",
154-
token_endpoint_auth_methods_supported=["client_secret_post"],
170+
response_modes_supported=None,
155171
grant_types_supported=["authorization_code", "refresh_token"],
172+
token_endpoint_auth_methods_supported=["client_secret_post"],
173+
token_endpoint_auth_signing_alg_values_supported=None,
174+
service_documentation=service_documentation_url,
175+
ui_locales_supported=None,
176+
op_policy_uri=None,
177+
op_tos_uri=None,
178+
introspection_endpoint=None,
179+
code_challenge_methods_supported=["S256"],
156180
)
157181

158182
# Add registration endpoint if supported
159183
if client_registration_options.enabled:
160-
metadata.registration_endpoint = f"{issuer_url_str}{REGISTRATION_PATH}"
184+
metadata.registration_endpoint = modify_url_path(
185+
issuer_url, lambda path: path.rstrip("/") + REGISTRATION_PATH.lstrip("/")
186+
)
161187

162188
# Add revocation endpoint if supported
163189
if revocation_options.enabled:
164-
metadata.revocation_endpoint = f"{issuer_url_str}{REVOCATION_PATH}"
190+
metadata.revocation_endpoint = modify_url_path(
191+
issuer_url, lambda path: path.rstrip("/") + REVOCATION_PATH.lstrip("/")
192+
)
165193
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
166194

167195
return metadata

src/mcp/server/fastmcp/server.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import anyio
1515
import pydantic_core
1616
import uvicorn
17-
from pydantic import BaseModel, Field
17+
from pydantic import AnyHttpUrl, BaseModel, Field
1818
from pydantic.networks import AnyUrl
1919
from pydantic_settings import BaseSettings, SettingsConfigDict
2020
from sse_starlette import EventSourceResponse
@@ -113,9 +113,13 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
113113
Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None
114114
) = Field(None, description="Lifespan context manager")
115115

116-
auth_issuer_url: AnyUrl | None = Field(None, description="Auth issuer URL")
117-
auth_service_documentation_url: AnyUrl | None = Field(
118-
None, description="Service documentation URL"
116+
auth_issuer_url: AnyHttpUrl | None = Field(
117+
None,
118+
description="URL advertised as OAuth issuer; this should be the URL the server "
119+
"is reachable at",
120+
)
121+
auth_service_documentation_url: AnyHttpUrl | None = Field(
122+
None, description="Service documentation URL advertised by OAuth"
119123
)
120124
auth_client_registration_options: ClientRegistrationOptions | None = None
121125
auth_revocation_options: RevocationOptions | None = None

src/mcp/shared/auth.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ class OAuthClientMetadata(BaseModel):
2424

2525
redirect_uris: List[AnyHttpUrl] = Field(..., min_length=1)
2626
# token_endpoint_auth_method: this implementation only supports none &
27-
# client_secret_basic;
28-
# ie: we do not support client_secret_post
29-
token_endpoint_auth_method: Literal["none", "client_secret_basic"] = (
30-
"client_secret_basic"
27+
# client_secret_post;
28+
# ie: we do not support client_secret_basic
29+
token_endpoint_auth_method: Literal["none", "client_secret_post"] = (
30+
"client_secret_post"
3131
)
3232
# grant_types: this implementation only supports authorization_code & refresh_token
3333
grant_types: List[Literal["authorization_code", "refresh_token"]] = [
@@ -66,23 +66,35 @@ class OAuthClientInformationFull(OAuthClientMetadata):
6666
class OAuthMetadata(BaseModel):
6767
"""
6868
RFC 8414 OAuth 2.0 Authorization Server Metadata.
69+
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
6970
"""
7071

71-
issuer: str
72-
authorization_endpoint: str
73-
token_endpoint: str
74-
registration_endpoint: Optional[str] = None
75-
scopes_supported: Optional[List[str]] = None
76-
response_types_supported: List[str]
77-
response_modes_supported: Optional[List[str]] = None
78-
grant_types_supported: Optional[List[str]] = None
79-
token_endpoint_auth_methods_supported: Optional[List[str]] = None
80-
token_endpoint_auth_signing_alg_values_supported: Optional[List[str]] = None
81-
service_documentation: Optional[str] = None
82-
revocation_endpoint: Optional[str] = None
83-
revocation_endpoint_auth_methods_supported: Optional[List[str]] = None
84-
revocation_endpoint_auth_signing_alg_values_supported: Optional[List[str]] = None
85-
introspection_endpoint: Optional[str] = None
86-
introspection_endpoint_auth_methods_supported: Optional[List[str]] = None
87-
introspection_endpoint_auth_signing_alg_values_supported: Optional[List[str]] = None
88-
code_challenge_methods_supported: Optional[List[str]] = None
72+
issuer: AnyHttpUrl
73+
authorization_endpoint: AnyHttpUrl
74+
token_endpoint: AnyHttpUrl
75+
registration_endpoint: AnyHttpUrl | None = None
76+
scopes_supported: list[str] | None = None
77+
response_types_supported: list[Literal["code"]] = ["code"]
78+
response_modes_supported: list[Literal["query", "fragment"]] | None = None
79+
grant_types_supported: (
80+
list[Literal["authorization_code", "refresh_token"]] | None
81+
) = None
82+
token_endpoint_auth_methods_supported: (
83+
list[Literal["none", "client_secret_post"]] | None
84+
) = None
85+
token_endpoint_auth_signing_alg_values_supported: None = None
86+
service_documentation: AnyHttpUrl | None = None
87+
ui_locales_supported: list[str] | None = None
88+
op_policy_uri: AnyHttpUrl | None = None
89+
op_tos_uri: AnyHttpUrl | None = None
90+
revocation_endpoint: AnyHttpUrl | None = None
91+
revocation_endpoint_auth_methods_supported: (
92+
list[Literal["client_secret_post"]] | None
93+
) = None
94+
revocation_endpoint_auth_signing_alg_values_supported: None = None
95+
introspection_endpoint: AnyHttpUrl | None = None
96+
introspection_endpoint_auth_methods_supported: (
97+
list[Literal["client_secret_post"]] | None
98+
) = None
99+
introspection_endpoint_auth_signing_alg_values_supported: None = None
100+
code_challenge_methods_supported: list[Literal["S256"]] | None = None

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import httpx
1616
import pytest
1717
from httpx_sse import aconnect_sse
18-
from pydantic import AnyUrl
18+
from pydantic import AnyHttpUrl
1919
from starlette.applications import Starlette
2020
from starlette.routing import Mount
2121

@@ -229,8 +229,8 @@ def auth_app(mock_oauth_provider):
229229
# Create auth router
230230
auth_router = create_auth_router(
231231
mock_oauth_provider,
232-
AnyUrl("https://auth.example.com"),
233-
AnyUrl("https://docs.example.com"),
232+
AnyHttpUrl("https://auth.example.com"),
233+
AnyHttpUrl("https://docs.example.com"),
234234
client_registration_options=ClientRegistrationOptions(enabled=True),
235235
revocation_options=RevocationOptions(enabled=True),
236236
)
@@ -373,7 +373,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
373373
assert response.status_code == 200
374374

375375
metadata = response.json()
376-
assert metadata["issuer"] == "https://auth.example.com"
376+
assert metadata["issuer"] == "https://auth.example.com/"
377377
assert (
378378
metadata["authorization_endpoint"] == "https://auth.example.com/authorize"
379379
)
@@ -389,7 +389,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
389389
"authorization_code",
390390
"refresh_token",
391391
]
392-
assert metadata["service_documentation"] == "https://docs.example.com"
392+
assert metadata["service_documentation"] == "https://docs.example.com/"
393393

394394
@pytest.mark.anyio
395395
async def test_token_validation_error(self, test_client: httpx.AsyncClient):

0 commit comments

Comments
 (0)