Skip to content

Commit 59673e6

Browse files
committed
Enhance OAuth 2.0 support and middleware functionality
- Added resource_server_url and resource_name to AuthSettings for improved metadata handling. - Updated FastMCP to utilize resource metadata URL in RequireAuthMiddleware. - Introduced get_oauth_protected_resource_metadata_url function for generating resource metadata URLs. - Modified tests to validate new metadata endpoints and middleware behavior. Signed-off-by: Xin Fu <[email protected]>
1 parent d087f90 commit 59673e6

File tree

6 files changed

+89
-12
lines changed

6 files changed

+89
-12
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
261261

262262
auth_settings = AuthSettings(
263263
issuer_url=settings.server_url,
264+
resource_server_url=settings.server_url,
265+
resource_name="Simple GitHub MCP Server",
264266
client_registration_options=ClientRegistrationOptions(
265267
enabled=True,
266268
valid_scopes=[settings.mcp_scope],

src/mcp/server/auth/routes.py

Lines changed: 5 additions & 0 deletions
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 urljoin
34

45
from pydantic import AnyHttpUrl
56
from starlette.middleware.cors import CORSMiddleware
@@ -223,3 +224,7 @@ def build_metadata(
223224
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
224225

225226
return metadata
227+
228+
229+
def get_oauth_protected_resource_metadata_url(server_url: AnyHttpUrl) -> AnyHttpUrl:
230+
return AnyHttpUrl(urljoin(str(server_url), "/.well-known/oauth-protected-resource"))

src/mcp/server/fastmcp/server.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -695,9 +695,15 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
695695
# Add auth endpoints if auth provider is configured
696696
if self._auth_server_provider:
697697
assert self.settings.auth
698-
from mcp.server.auth.routes import create_auth_routes
698+
from mcp.server.auth.routes import (
699+
create_auth_routes,
700+
get_oauth_protected_resource_metadata_url,
701+
)
699702

700703
required_scopes = self.settings.auth.required_scopes or []
704+
resource_metadata_url = get_oauth_protected_resource_metadata_url(
705+
self.settings.auth.resource_server_url
706+
)
701707

702708
middleware = [
703709
# extract auth info from request (but do not require it)
@@ -723,20 +729,24 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
723729
)
724730
)
725731

726-
# When auth is not configured, we shouldn't require auth
727-
if self._auth_server_provider:
728732
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
729733
routes.append(
730734
Route(
731735
self.settings.sse_path,
732-
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
736+
endpoint=RequireAuthMiddleware(
737+
handle_sse, required_scopes, str(resource_metadata_url)
738+
),
733739
methods=["GET"],
734740
)
735741
)
736742
routes.append(
737743
Mount(
738744
self.settings.message_path,
739-
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
745+
app=RequireAuthMiddleware(
746+
sse.handle_post_message,
747+
required_scopes,
748+
str(resource_metadata_url),
749+
),
740750
)
741751
)
742752
else:
@@ -795,9 +805,15 @@ async def handle_streamable_http(
795805
# Add auth endpoints if auth provider is configured
796806
if self._auth_server_provider:
797807
assert self.settings.auth
798-
from mcp.server.auth.routes import create_auth_routes
808+
from mcp.server.auth.routes import (
809+
create_auth_routes,
810+
get_oauth_protected_resource_metadata_url,
811+
)
799812

800813
required_scopes = self.settings.auth.required_scopes or []
814+
resource_metadata_url = get_oauth_protected_resource_metadata_url(
815+
self.settings.auth.resource_server_url
816+
)
801817

802818
middleware = [
803819
Middleware(
@@ -822,7 +838,11 @@ async def handle_streamable_http(
822838
routes.append(
823839
Mount(
824840
self.settings.streamable_http_path,
825-
app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
841+
app=RequireAuthMiddleware(
842+
handle_streamable_http,
843+
required_scopes,
844+
str(resource_metadata_url),
845+
),
826846
)
827847
)
828848
else:

tests/client/test_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def oauth_token():
9696

9797

9898
@pytest.fixture
99-
async def oauth_provider(client_metadata, mock_storage):
99+
def oauth_provider(client_metadata, mock_storage):
100100
async def mock_redirect_handler(url: str) -> None:
101101
pass
102102

tests/server/auth/middleware/test_bearer_auth.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,34 @@ async def send(message: Message) -> None:
307307
assert excinfo.value.detail == "Unauthorized"
308308
assert not app.called
309309

310+
async def test_no_user_with_adds_www_authenticate_header(
311+
self,
312+
):
313+
"""Test middleware with no user in scope."""
314+
app = MockApp()
315+
middleware = RequireAuthMiddleware(
316+
app,
317+
required_scopes=["read"],
318+
resource_metadata_url="https://example.com/.well-known/oauth-protected-resource",
319+
)
320+
scope: Scope = {"type": "http"}
321+
322+
async def receive() -> Message:
323+
return {"type": "http.request"}
324+
325+
async def send(message: Message) -> None:
326+
pass
327+
328+
with pytest.raises(HTTPException) as excinfo:
329+
await middleware(scope, receive, send)
330+
331+
assert excinfo.value.status_code == 401
332+
assert excinfo.value.detail == "Unauthorized"
333+
assert excinfo.value.headers == {
334+
"WWW-Authenticate": 'Bearer resource="https://example.com/.well-known/oauth-protected-resource"'
335+
}
336+
assert not app.called
337+
310338
async def test_non_authenticated_user(self):
311339
"""Test middleware with non-authenticated user in scope."""
312340
app = MockApp()

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,15 +205,17 @@ def mock_oauth_provider():
205205
def auth_app(mock_oauth_provider):
206206
# Create auth router
207207
auth_routes = create_auth_routes(
208-
mock_oauth_provider,
209-
AnyHttpUrl("https://auth.example.com"),
210-
AnyHttpUrl("https://docs.example.com"),
208+
provider=mock_oauth_provider,
209+
issuer_url=AnyHttpUrl("https://auth.example.com"),
210+
resource_server_url=AnyHttpUrl("https://api.example.com"),
211+
service_documentation_url=AnyHttpUrl("https://docs.example.com"),
211212
client_registration_options=ClientRegistrationOptions(
212213
enabled=True,
213214
valid_scopes=["read", "write", "profile"],
214215
default_scopes=["read", "write"],
215216
),
216217
revocation_options=RevocationOptions(enabled=True),
218+
resource_name="Test Resource Server",
217219
)
218220

219221
# Create Starlette app
@@ -345,7 +347,27 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
345347

346348
class TestAuthEndpoints:
347349
@pytest.mark.anyio
348-
async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
350+
async def test_protected_resource_metadata_endpoint(
351+
self, test_client: httpx.AsyncClient
352+
):
353+
"""Test the OAuth 2.0 protected resource metadata endpoint."""
354+
response = await test_client.get("/.well-known/oauth-protected-resource")
355+
print(f"Got response: {response.status_code}")
356+
if response.status_code != 200:
357+
print(f"Response content: {response.content}")
358+
assert response.status_code == 200
359+
360+
metadata = response.json()
361+
assert metadata["resource"] == "https://api.example.com/"
362+
assert metadata["authorization_servers"] == ["https://auth.example.com/"]
363+
assert metadata["resource_name"] == "Test Resource Server"
364+
assert metadata["resource_documentation"] == "https://docs.example.com/"
365+
assert metadata["scopes_supported"] == ["read", "write", "profile"]
366+
367+
@pytest.mark.anyio
368+
async def test_authorization_server_metadata_endpoint(
369+
self, test_client: httpx.AsyncClient
370+
):
349371
"""Test the OAuth 2.0 metadata endpoint."""
350372
print("Sending request to metadata endpoint")
351373
response = await test_client.get("/.well-known/oauth-authorization-server")

0 commit comments

Comments
 (0)