Skip to content

Commit df3e428

Browse files
authored
Improve OAuth protected resource metadata URL construction per RFC 9728 (#1407)
1 parent 814c9c0 commit df3e428

File tree

4 files changed

+192
-32
lines changed

4 files changed

+192
-32
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ResourceServerSettings(BaseSettings):
3232
# Server settings
3333
host: str = "localhost"
3434
port: int = 8001
35-
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001")
35+
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp")
3636

3737
# Authorization Server settings
3838
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
@@ -137,7 +137,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
137137

138138
# Create settings
139139
host = "localhost"
140-
server_url = f"http://{host}:{port}"
140+
server_url = f"http://{host}:{port}/mcp"
141141
settings = ResourceServerSettings(
142142
host=host,
143143
port=port,

src/mcp/server/auth/routes.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Awaitable, Callable
22
from typing import Any
3+
from urllib.parse import urlparse
34

45
from pydantic import AnyHttpUrl
56
from starlette.middleware.cors import CORSMiddleware
@@ -186,6 +187,25 @@ def build_metadata(
186187
return metadata
187188

188189

190+
def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl:
191+
"""
192+
Build RFC 9728 compliant protected resource metadata URL.
193+
194+
Inserts /.well-known/oauth-protected-resource between host and resource path
195+
as specified in RFC 9728 §3.1.
196+
197+
Args:
198+
resource_server_url: The resource server URL (e.g., https://example.com/mcp)
199+
200+
Returns:
201+
The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp)
202+
"""
203+
parsed = urlparse(str(resource_server_url))
204+
# Handle trailing slash: if path is just "/", treat as empty
205+
resource_path = parsed.path if parsed.path != "/" else ""
206+
return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}")
207+
208+
189209
def create_protected_resource_routes(
190210
resource_url: AnyHttpUrl,
191211
authorization_servers: list[AnyHttpUrl],
@@ -218,9 +238,15 @@ def create_protected_resource_routes(
218238

219239
handler = ProtectedResourceMetadataHandler(metadata)
220240

241+
# RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path
242+
metadata_url = build_resource_metadata_url(resource_url)
243+
# Extract just the path part for route registration
244+
parsed = urlparse(str(metadata_url))
245+
well_known_path = parsed.path
246+
221247
return [
222248
Route(
223-
"/.well-known/oauth-protected-resource",
249+
well_known_path,
224250
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
225251
methods=["GET", "OPTIONS"],
226252
)

src/mcp/server/fastmcp/server.py

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -825,11 +825,10 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
825825
# Determine resource metadata URL
826826
resource_metadata_url = None
827827
if self.settings.auth and self.settings.auth.resource_server_url:
828-
from pydantic import AnyHttpUrl
828+
from mcp.server.auth.routes import build_resource_metadata_url
829829

830-
resource_metadata_url = AnyHttpUrl(
831-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
832-
)
830+
# Build compliant metadata URL for WWW-Authenticate header
831+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
833832

834833
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
835834
routes.append(
@@ -938,11 +937,10 @@ def streamable_http_app(self) -> Starlette:
938937
# Determine resource metadata URL
939938
resource_metadata_url = None
940939
if self.settings.auth and self.settings.auth.resource_server_url:
941-
from pydantic import AnyHttpUrl
940+
from mcp.server.auth.routes import build_resource_metadata_url
942941

943-
resource_metadata_url = AnyHttpUrl(
944-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
945-
)
942+
# Build compliant metadata URL for WWW-Authenticate header
943+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
946944

947945
routes.append(
948946
Route(
@@ -961,23 +959,13 @@ def streamable_http_app(self) -> Starlette:
961959

962960
# Add protected resource metadata endpoint if configured as RS
963961
if self.settings.auth and self.settings.auth.resource_server_url:
964-
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
965-
from mcp.server.auth.routes import cors_middleware
966-
from mcp.shared.auth import ProtectedResourceMetadata
967-
968-
protected_resource_metadata = ProtectedResourceMetadata(
969-
resource=self.settings.auth.resource_server_url,
970-
authorization_servers=[self.settings.auth.issuer_url],
971-
scopes_supported=self.settings.auth.required_scopes,
972-
)
973-
routes.append(
974-
Route(
975-
"/.well-known/oauth-protected-resource",
976-
endpoint=cors_middleware(
977-
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
978-
["GET", "OPTIONS"],
979-
),
980-
methods=["GET", "OPTIONS"],
962+
from mcp.server.auth.routes import create_protected_resource_routes
963+
964+
routes.extend(
965+
create_protected_resource_routes(
966+
resource_url=self.settings.auth.resource_server_url,
967+
authorization_servers=[self.settings.auth.issuer_url],
968+
scopes_supported=self.settings.auth.required_scopes,
981969
)
982970
)
983971

tests/server/auth/test_protected_resource.py

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pydantic import AnyHttpUrl
99
from starlette.applications import Starlette
1010

11-
from mcp.server.auth.routes import create_protected_resource_routes
11+
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
1212

1313

1414
@pytest.fixture
@@ -36,10 +36,11 @@ async def test_client(test_app: Starlette):
3636

3737

3838
@pytest.mark.anyio
39-
async def test_metadata_endpoint(test_client: httpx.AsyncClient):
40-
"""Test the OAuth 2.0 Protected Resource metadata endpoint."""
39+
async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
40+
"""Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
4141

42-
response = await test_client.get("/.well-known/oauth-protected-resource")
42+
# For resource with path "/resource", metadata should be accessible at the path-aware location
43+
response = await test_client.get("/.well-known/oauth-protected-resource/resource")
4344
assert response.json() == snapshot(
4445
{
4546
"resource": "https://example.com/resource",
@@ -50,3 +51,148 @@ async def test_metadata_endpoint(test_client: httpx.AsyncClient):
5051
"bearer_methods_supported": ["header"],
5152
}
5253
)
54+
55+
56+
@pytest.mark.anyio
57+
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
58+
"""Test that root path returns 404 for path-based resource."""
59+
60+
# Root path should return 404 for path-based resources
61+
response = await test_client.get("/.well-known/oauth-protected-resource")
62+
assert response.status_code == 404
63+
64+
65+
@pytest.fixture
66+
def root_resource_app():
67+
"""Fixture to create protected resource routes for root-level resource."""
68+
69+
# Create routes for a resource without path component
70+
protected_resource_routes = create_protected_resource_routes(
71+
resource_url=AnyHttpUrl("https://example.com"),
72+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
73+
scopes_supported=["read"],
74+
resource_name="Root Resource",
75+
)
76+
77+
app = Starlette(routes=protected_resource_routes)
78+
return app
79+
80+
81+
@pytest.fixture
82+
async def root_resource_client(root_resource_app: Starlette):
83+
"""Fixture to create an HTTP client for the root resource app."""
84+
async with httpx.AsyncClient(
85+
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
86+
) as client:
87+
yield client
88+
89+
90+
@pytest.mark.anyio
91+
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
92+
"""Test metadata endpoint for root-level resource."""
93+
94+
# For root resource, metadata should be at standard location
95+
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
96+
assert response.status_code == 200
97+
assert response.json() == snapshot(
98+
{
99+
"resource": "https://example.com/",
100+
"authorization_servers": ["https://auth.example.com/"],
101+
"scopes_supported": ["read"],
102+
"resource_name": "Root Resource",
103+
"bearer_methods_supported": ["header"],
104+
}
105+
)
106+
107+
108+
class TestMetadataUrlConstruction:
109+
"""Test URL construction utility function."""
110+
111+
def test_url_without_path(self):
112+
"""Test URL construction for resource without path component."""
113+
resource_url = AnyHttpUrl("https://example.com")
114+
result = build_resource_metadata_url(resource_url)
115+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
116+
117+
def test_url_with_path_component(self):
118+
"""Test URL construction for resource with path component."""
119+
resource_url = AnyHttpUrl("https://example.com/mcp")
120+
result = build_resource_metadata_url(resource_url)
121+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
122+
123+
def test_url_with_trailing_slash_only(self):
124+
"""Test URL construction for resource with trailing slash only."""
125+
resource_url = AnyHttpUrl("https://example.com/")
126+
result = build_resource_metadata_url(resource_url)
127+
# Trailing slash should be treated as empty path
128+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
129+
130+
@pytest.mark.parametrize(
131+
"resource_url,expected_url",
132+
[
133+
("https://example.com", "https://example.com/.well-known/oauth-protected-resource"),
134+
("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"),
135+
("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"),
136+
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
137+
],
138+
)
139+
def test_various_resource_configurations(self, resource_url: str, expected_url: str):
140+
"""Test URL construction with various resource configurations."""
141+
result = build_resource_metadata_url(AnyHttpUrl(resource_url))
142+
assert str(result) == expected_url
143+
144+
145+
class TestRouteConsistency:
146+
"""Test consistency between URL generation and route registration."""
147+
148+
def test_route_path_matches_metadata_url(self):
149+
"""Test that route path matches the generated metadata URL."""
150+
resource_url = AnyHttpUrl("https://example.com/mcp")
151+
152+
# Generate metadata URL
153+
metadata_url = build_resource_metadata_url(resource_url)
154+
155+
# Create routes
156+
routes = create_protected_resource_routes(
157+
resource_url=resource_url,
158+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
159+
)
160+
161+
# Extract path from metadata URL
162+
from urllib.parse import urlparse
163+
164+
metadata_path = urlparse(str(metadata_url)).path
165+
166+
# Verify consistency
167+
assert len(routes) == 1
168+
assert routes[0].path == metadata_path
169+
170+
@pytest.mark.parametrize(
171+
"resource_url,expected_path",
172+
[
173+
("https://example.com", "/.well-known/oauth-protected-resource"),
174+
("https://example.com/", "/.well-known/oauth-protected-resource"),
175+
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
176+
],
177+
)
178+
def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str):
179+
"""Test that URL generation and route creation are consistent."""
180+
resource_url_obj = AnyHttpUrl(resource_url)
181+
182+
# Test URL generation
183+
metadata_url = build_resource_metadata_url(resource_url_obj)
184+
from urllib.parse import urlparse
185+
186+
url_path = urlparse(str(metadata_url)).path
187+
188+
# Test route creation
189+
routes = create_protected_resource_routes(
190+
resource_url=resource_url_obj,
191+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
192+
)
193+
route_path = routes[0].path
194+
195+
# Both should match expected path
196+
assert url_path == expected_path
197+
assert route_path == expected_path
198+
assert url_path == route_path

0 commit comments

Comments
 (0)