From f2876eaa06323e0d798c395a3df23572bf233c49 Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 10:09:45 +0100 Subject: [PATCH 1/6] Add OAuth Protected Resource Metadata support - Introduced OAuthProtectedResourceMetadata class for enhanced resource metadata handling. - Updated create_auth_routes to include resource_server_url and resource_name parameters. - Modified AuthSettings to include resource_server_url and resource_name fields. - Adjusted MetadataHandler to accept both OAuthMetadata and OAuthProtectedResourceMetadata. - Updated FastMCP to utilize new resource metadata features. Signed-off-by: Xin Fu --- src/mcp/server/auth/handlers/metadata.py | 4 ++-- src/mcp/server/auth/routes.py | 20 +++++++++++++++++++- src/mcp/server/auth/settings.py | 9 +++++++-- src/mcp/server/fastmcp/server.py | 2 ++ src/mcp/shared/auth.py | 22 ++++++++++++++++++++++ 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/auth/handlers/metadata.py b/src/mcp/server/auth/handlers/metadata.py index e37e5d311..74a5756b4 100644 --- a/src/mcp/server/auth/handlers/metadata.py +++ b/src/mcp/server/auth/handlers/metadata.py @@ -4,12 +4,12 @@ from starlette.responses import Response from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.shared.auth import OAuthMetadata +from mcp.shared.auth import OAuthMetadata, OAuthProtectedResourceMetadata @dataclass class MetadataHandler: - metadata: OAuthMetadata + metadata: OAuthMetadata | OAuthProtectedResourceMetadata async def handle(self, request: Request) -> Response: return PydanticJSONResponse( diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 4c56ca247..5b68243c5 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -16,7 +16,7 @@ from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions -from mcp.shared.auth import OAuthMetadata +from mcp.shared.auth import OAuthMetadata, OAuthProtectedResourceMetadata def validate_issuer_url(url: AnyHttpUrl): @@ -67,9 +67,11 @@ def cors_middleware( def create_auth_routes( provider: OAuthAuthorizationServerProvider[Any, Any, Any], issuer_url: AnyHttpUrl, + resource_server_url: AnyHttpUrl, service_documentation_url: AnyHttpUrl | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, + resource_name: str | None = None, ) -> list[Route]: validate_issuer_url(issuer_url) @@ -85,11 +87,27 @@ def create_auth_routes( ) client_authenticator = ClientAuthenticator(provider) + protected_resource_metadata = OAuthProtectedResourceMetadata( + resource=resource_server_url, + authorization_servers=[metadata.issuer], + scopes_supported=metadata.scopes_supported, + resource_name=resource_name, + resource_documentation= service_documentation_url, + ) + # Create routes # Allow CORS requests for endpoints meant to be hit by the OAuth client # (with the client secret). This is intended to support things like MCP Inspector, # where the client runs in a web browser. routes = [ + Route( + "/.well-known/oauth-protected-resource", + endpoint=cors_middleware( + MetadataHandler(protected_resource_metadata).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), Route( "/.well-known/oauth-authorization-server", endpoint=cors_middleware( diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index 1086bb77e..cdf211076 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -15,10 +15,15 @@ class RevocationOptions(BaseModel): class AuthSettings(BaseModel): issuer_url: AnyHttpUrl = Field( ..., - description="URL advertised as OAuth issuer; this should be the URL the server " - "is reachable at", + description="The authorization server's issuer identifier", + ) + resource_server_url: AnyHttpUrl = Field( + ..., description="URL of the MCP server, for use in protected resource metadata" ) service_documentation_url: AnyHttpUrl | None = None client_registration_options: ClientRegistrationOptions | None = None revocation_options: RevocationOptions | None = None required_scopes: list[str] | None = None + resource_name: str | None = Field( + None, description="Optional resource name to display in resource metadata" + ) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 3282baae6..532ac6571 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -810,9 +810,11 @@ async def handle_streamable_http( create_auth_routes( provider=self._auth_server_provider, issuer_url=self.settings.auth.issuer_url, + resource_server_url=self.settings.auth.resource_server_url, service_documentation_url=self.settings.auth.service_documentation_url, client_registration_options=self.settings.auth.client_registration_options, revocation_options=self.settings.auth.revocation_options, + resource_name=self.settings.auth.resource_name, ) ) routes.append( diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 22f8a971d..0d581b628 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -135,3 +135,25 @@ class OAuthMetadata(BaseModel): ) = None introspection_endpoint_auth_signing_alg_values_supported: None = None code_challenge_methods_supported: list[Literal["S256"]] | None = None + + +class OAuthProtectedResourceMetadata(BaseModel): + """ + RFC 9728 OAuth Protected Resource Metadata + See https://datatracker.ietf.org/doc/html/rfc9728 + """ + + resource: AnyHttpUrl + authorization_servers: list[AnyHttpUrl] | None = None + jwks_uri: AnyHttpUrl | None = None + scopes_supported: list[str] | None = None + bearer_methods_supported: list[str] | None = None + resource_signing_alg_values_supported: list[str] | None = None + resource_name: str | None = None + resource_documentation: str | None = None + resource_policy_uri: AnyHttpUrl | None = None + resource_tos_uri: AnyHttpUrl | None = None + tls_client_certificate_bound_access_tokens: bool | None = None + authorization_details_types_supported: list[str] | None = None + dpop_signing_alg_values_supported: list[str] | None = None + dpop_bound_access_tokens_required: bool | None = None From d087f90ac175ecf1ca1603c02faf858f5212d84e Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 12:19:15 +0100 Subject: [PATCH 2/6] Refactor authentication middleware and routes - Updated RequireAuthMiddleware to accept an optional resource_metadata_url parameter for enhanced error handling. - Adjusted create_auth_routes to include resource_server_url and resource_name parameters. - Modified OAuthProtectedResourceMetadata to change resource_documentation type to AnyHttpUrl. - Updated tests to reflect changes in resource_server_url and resource_name parameters. Signed-off-by: Xin Fu --- src/mcp/client/session_group.py | 1 - src/mcp/server/auth/middleware/bearer_auth.py | 19 +++++++++++++++---- src/mcp/server/auth/routes.py | 2 +- src/mcp/server/fastmcp/server.py | 2 ++ src/mcp/shared/auth.py | 2 +- tests/server/auth/test_error_handling.py | 2 ++ 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index a430533b3..a77dc7a1e 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -154,7 +154,6 @@ async def __aexit__( for exit_stack in self._session_exit_stacks.values(): tg.start_soon(exit_stack.aclose) - @property def sessions(self) -> list[mcp.ClientSession]: """Returns the list of sessions being managed.""" diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 30b5e2ba6..6454f8039 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -67,25 +67,36 @@ class RequireAuthMiddleware: auth info in the request state. """ - def __init__(self, app: Any, required_scopes: list[str]): + def __init__( + self, + app: Any, + required_scopes: list[str] | None = None, + resource_metadata_url: str | None = None, + ): """ Initialize the middleware. Args: app: ASGI application - provider: Authentication provider to validate tokens required_scopes: Optional list of scopes that the token must have + resource_metadata_url: Optional resource metadata URL """ self.app = app self.required_scopes = required_scopes + self.resource_metadata_url = resource_metadata_url async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: auth_user = scope.get("user") if not isinstance(auth_user, AuthenticatedUser): - raise HTTPException(status_code=401, detail="Unauthorized") + headers = ( + {"WWW-Authenticate": f'Bearer resource="{self.resource_metadata_url}"'} + if self.resource_metadata_url + else None + ) + raise HTTPException(status_code=401, detail="Unauthorized", headers=headers) auth_credentials = scope.get("auth") - for required_scope in self.required_scopes: + for required_scope in self.required_scopes or []: # auth_credentials should always be provided; this is just paranoia if ( auth_credentials is None diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 5b68243c5..aae88a0b1 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -92,7 +92,7 @@ def create_auth_routes( authorization_servers=[metadata.issuer], scopes_supported=metadata.scopes_supported, resource_name=resource_name, - resource_documentation= service_documentation_url, + resource_documentation=service_documentation_url, ) # Create routes diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 532ac6571..e08f8ecbc 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -715,9 +715,11 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): create_auth_routes( provider=self._auth_server_provider, issuer_url=self.settings.auth.issuer_url, + resource_server_url=self.settings.auth.resource_server_url, service_documentation_url=self.settings.auth.service_documentation_url, client_registration_options=self.settings.auth.client_registration_options, revocation_options=self.settings.auth.revocation_options, + resource_name=self.settings.auth.resource_name, ) ) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 0d581b628..06791ed25 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -150,7 +150,7 @@ class OAuthProtectedResourceMetadata(BaseModel): bearer_methods_supported: list[str] | None = None resource_signing_alg_values_supported: list[str] | None = None resource_name: str | None = None - resource_documentation: str | None = None + resource_documentation: AnyHttpUrl | None = None resource_policy_uri: AnyHttpUrl | None = None resource_tos_uri: AnyHttpUrl | None = None tls_client_certificate_bound_access_tokens: bool | None = None diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index 18e9933e7..ea4ba1596 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -40,6 +40,8 @@ def app(oauth_provider): auth_routes = create_auth_routes( oauth_provider, issuer_url=AnyHttpUrl("http://localhost"), + resource_server_url=AnyHttpUrl("http://localhost"), + resource_name="Test Resource", client_registration_options=client_registration_options, revocation_options=revocation_options, ) From 59673e69b151edb871d320b52d3a2cb138f18ffd Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 14:30:57 +0100 Subject: [PATCH 3/6] 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 --- .../simple-auth/mcp_simple_auth/server.py | 2 ++ src/mcp/server/auth/routes.py | 5 +++ src/mcp/server/fastmcp/server.py | 34 +++++++++++++++---- tests/client/test_auth.py | 2 +- .../auth/middleware/test_bearer_auth.py | 28 +++++++++++++++ .../fastmcp/auth/test_auth_integration.py | 30 +++++++++++++--- 6 files changed, 89 insertions(+), 12 deletions(-) diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 51f449113..9d120613f 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -261,6 +261,8 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP: auth_settings = AuthSettings( issuer_url=settings.server_url, + resource_server_url=settings.server_url, + resource_name="Simple GitHub MCP Server", client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=[settings.mcp_scope], diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index aae88a0b1..65c59088c 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -1,5 +1,6 @@ from collections.abc import Awaitable, Callable from typing import Any +from urllib.parse import urljoin from pydantic import AnyHttpUrl from starlette.middleware.cors import CORSMiddleware @@ -223,3 +224,7 @@ def build_metadata( metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"] return metadata + + +def get_oauth_protected_resource_metadata_url(server_url: AnyHttpUrl) -> AnyHttpUrl: + return AnyHttpUrl(urljoin(str(server_url), "/.well-known/oauth-protected-resource")) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e08f8ecbc..0b41f31ff 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -695,9 +695,15 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # Add auth endpoints if auth provider is configured if self._auth_server_provider: assert self.settings.auth - from mcp.server.auth.routes import create_auth_routes + from mcp.server.auth.routes import ( + create_auth_routes, + get_oauth_protected_resource_metadata_url, + ) required_scopes = self.settings.auth.required_scopes or [] + resource_metadata_url = get_oauth_protected_resource_metadata_url( + self.settings.auth.resource_server_url + ) middleware = [ # extract auth info from request (but do not require it) @@ -723,20 +729,24 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): ) ) - # When auth is not configured, we shouldn't require auth - if self._auth_server_provider: # Auth is enabled, wrap the endpoints with RequireAuthMiddleware routes.append( Route( self.settings.sse_path, - endpoint=RequireAuthMiddleware(handle_sse, required_scopes), + endpoint=RequireAuthMiddleware( + handle_sse, required_scopes, str(resource_metadata_url) + ), methods=["GET"], ) ) routes.append( Mount( self.settings.message_path, - app=RequireAuthMiddleware(sse.handle_post_message, required_scopes), + app=RequireAuthMiddleware( + sse.handle_post_message, + required_scopes, + str(resource_metadata_url), + ), ) ) else: @@ -795,9 +805,15 @@ async def handle_streamable_http( # Add auth endpoints if auth provider is configured if self._auth_server_provider: assert self.settings.auth - from mcp.server.auth.routes import create_auth_routes + from mcp.server.auth.routes import ( + create_auth_routes, + get_oauth_protected_resource_metadata_url, + ) required_scopes = self.settings.auth.required_scopes or [] + resource_metadata_url = get_oauth_protected_resource_metadata_url( + self.settings.auth.resource_server_url + ) middleware = [ Middleware( @@ -822,7 +838,11 @@ async def handle_streamable_http( routes.append( Mount( self.settings.streamable_http_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + app=RequireAuthMiddleware( + handle_streamable_http, + required_scopes, + str(resource_metadata_url), + ), ) ) else: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 996534e9c..d9a0999b5 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -96,7 +96,7 @@ def oauth_token(): @pytest.fixture -async def oauth_provider(client_metadata, mock_storage): +def oauth_provider(client_metadata, mock_storage): async def mock_redirect_handler(url: str) -> None: pass diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index e8c17a4c4..6b0cbec47 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -307,6 +307,34 @@ async def send(message: Message) -> None: assert excinfo.value.detail == "Unauthorized" assert not app.called + async def test_no_user_with_adds_www_authenticate_header( + self, + ): + """Test middleware with no user in scope.""" + app = MockApp() + middleware = RequireAuthMiddleware( + app, + required_scopes=["read"], + resource_metadata_url="https://example.com/.well-known/oauth-protected-resource", + ) + scope: Scope = {"type": "http"} + + async def receive() -> Message: + return {"type": "http.request"} + + async def send(message: Message) -> None: + pass + + with pytest.raises(HTTPException) as excinfo: + await middleware(scope, receive, send) + + assert excinfo.value.status_code == 401 + assert excinfo.value.detail == "Unauthorized" + assert excinfo.value.headers == { + "WWW-Authenticate": 'Bearer resource="https://example.com/.well-known/oauth-protected-resource"' + } + assert not app.called + async def test_non_authenticated_user(self): """Test middleware with non-authenticated user in scope.""" app = MockApp() diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index d237e860e..dae5a6ae9 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -205,15 +205,17 @@ def mock_oauth_provider(): def auth_app(mock_oauth_provider): # Create auth router auth_routes = create_auth_routes( - mock_oauth_provider, - AnyHttpUrl("https://auth.example.com"), - AnyHttpUrl("https://docs.example.com"), + provider=mock_oauth_provider, + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("https://api.example.com"), + service_documentation_url=AnyHttpUrl("https://docs.example.com"), client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=["read", "write", "profile"], default_scopes=["read", "write"], ), revocation_options=RevocationOptions(enabled=True), + resource_name="Test Resource Server", ) # Create Starlette app @@ -345,7 +347,27 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ class TestAuthEndpoints: @pytest.mark.anyio - async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): + async def test_protected_resource_metadata_endpoint( + self, test_client: httpx.AsyncClient + ): + """Test the OAuth 2.0 protected resource metadata endpoint.""" + response = await test_client.get("/.well-known/oauth-protected-resource") + print(f"Got response: {response.status_code}") + if response.status_code != 200: + print(f"Response content: {response.content}") + assert response.status_code == 200 + + metadata = response.json() + assert metadata["resource"] == "https://api.example.com/" + assert metadata["authorization_servers"] == ["https://auth.example.com/"] + assert metadata["resource_name"] == "Test Resource Server" + assert metadata["resource_documentation"] == "https://docs.example.com/" + assert metadata["scopes_supported"] == ["read", "write", "profile"] + + @pytest.mark.anyio + async def test_authorization_server_metadata_endpoint( + self, test_client: httpx.AsyncClient + ): """Test the OAuth 2.0 metadata endpoint.""" print("Sending request to metadata endpoint") response = await test_client.get("/.well-known/oauth-authorization-server") From 603fdc171b350d838dab0b4c953ec8182714e43b Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 19:03:27 +0100 Subject: [PATCH 4/6] Enhance OAuth metadata discovery in client - Added a new method to discover OAuth Protected Resource Metadata, improving the handling of authorization server URLs. - Updated the OAuthClientProvider to utilize the discovered protected resource metadata when fetching OAuth metadata. - Refactored tests to validate the new discovery logic and ensure correct URL calls for protected resource and authorization server metadata. Signed-off-by: Xin Fu --- src/mcp/client/auth.py | 55 +++++++++++++++++++++---- src/mcp/server/auth/routes.py | 8 +--- tests/client/test_auth.py | 76 +++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index fc6c96a43..7b2491dea 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -12,7 +12,7 @@ import time from collections.abc import AsyncGenerator, Awaitable, Callable from typing import Protocol -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode, urljoin, urlparse, urlunparse import anyio import httpx @@ -21,6 +21,7 @@ OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, + OAuthProtectedResourceMetadata, OAuthToken, ) from mcp.types import LATEST_PROTOCOL_VERSION @@ -116,19 +117,59 @@ def _get_authorization_base_url(self, server_url: str) -> str: Per MCP spec 2.3.2: https://api.example.com/v1/mcp -> https://api.example.com """ - from urllib.parse import urlparse, urlunparse - parsed = urlparse(server_url) - # Remove path component return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + async def _discover_protected_resource_metadata( + self, resource_server_url: str + ) -> OAuthProtectedResourceMetadata | None: + """ + Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata. + + If the server returns a 404 for the well-known endpoint, returns None. + """ + async with httpx.AsyncClient() as client: + response = await client.get( + urljoin(resource_server_url, "/.well-known/oauth-protected-resource") + ) + if response.status_code == 404: + return None + response.raise_for_status() + metadata_json = response.json() + logger.debug( + f"OAuth protected resource metadata discovered: {metadata_json}" + ) + return OAuthProtectedResourceMetadata.model_validate(metadata_json) + async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None: """ Discover OAuth metadata from server's well-known endpoint. + + First tries to discover protected resource metadata and use its authorization + server URL if available, otherwise falls back to the server's own well-known. """ - # Extract base URL per MCP spec - auth_base_url = self._get_authorization_base_url(server_url) - url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server") + auth_server_url = self._get_authorization_base_url(server_url) + + try: + protected_resource_metadata = ( + await self._discover_protected_resource_metadata(server_url) + ) + + if ( + protected_resource_metadata + and protected_resource_metadata.authorization_servers + and len(protected_resource_metadata.authorization_servers) > 0 + ): + auth_server_url = str( + protected_resource_metadata.authorization_servers[0] + ) + except Exception as e: + logger.warning( + "Could not load OAuth Protected Resource metadata, " + f"falling back to /.well-known/oauth-authorization-server: {e}" + ) + + url = urljoin(auth_server_url, "/.well-known/oauth-authorization-server") headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} async with httpx.AsyncClient() as client: diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index f81036b3a..69882ea7a 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -172,12 +172,8 @@ def build_metadata( client_registration_options: ClientRegistrationOptions, revocation_options: RevocationOptions, ) -> OAuthMetadata: - authorization_url = AnyHttpUrl( - str(issuer_url).rstrip("/") + AUTHORIZATION_PATH - ) - token_url = AnyHttpUrl( - str(issuer_url).rstrip("/") + TOKEN_PATH - ) + authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) + token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) # Create metadata metadata = OAuthMetadata( diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 5b7234352..0371b7d0e 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -20,6 +20,7 @@ OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, + OAuthProtectedResourceMetadata, OAuthToken, ) @@ -74,6 +75,16 @@ def oauth_metadata(): ) +@pytest.fixture +def oauth_protected_resource_metadata(): + return OAuthProtectedResourceMetadata( + resource="https://api.example.com/v1/mcp", + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=["read", "write"], + bearer_methods_supported=["header"], + ) + + @pytest.fixture def oauth_client_info(): return OAuthClientInformationFull( @@ -210,10 +221,13 @@ async def test_discover_oauth_metadata_success( assert result.token_endpoint == oauth_metadata.token_endpoint # Verify correct URL was called - mock_client.get.assert_called_once() - call_args = mock_client.get.call_args[0] + assert mock_client.get.call_count == 2 assert ( - call_args[0] + mock_client.get.call_args_list[0][0][0] + == "https://api.example.com/.well-known/oauth-protected-resource" + ) + assert ( + mock_client.get.call_args_list[1][0][0] == "https://api.example.com/.well-known/oauth-authorization-server" ) @@ -262,6 +276,62 @@ async def test_discover_oauth_metadata_cors_fallback( assert result is not None assert mock_client.get.call_count == 2 + @pytest.mark.anyio + async def test_discover_oauth_metadata_from_protected_resource( + self, oauth_provider, oauth_metadata, oauth_protected_resource_metadata + ): + """Test OAuth metadata discovery using protected resource metadata.""" + protected_resource_response = oauth_protected_resource_metadata.model_dump( + by_alias=True, mode="json" + ) + oauth_metadata_response = oauth_metadata.model_dump(by_alias=True, mode="json") + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + # First call returns protected resource metadata + protected_resource_mock = Mock() + protected_resource_mock.status_code = 200 + protected_resource_mock.json.return_value = protected_resource_response + + # Second call returns OAuth metadata from authorization server + oauth_metadata_mock = Mock() + oauth_metadata_mock.status_code = 200 + oauth_metadata_mock.json.return_value = oauth_metadata_response + + mock_client.get.side_effect = [ + protected_resource_mock, # Protected resource metadata call + oauth_metadata_mock, # OAuth metadata from auth server call + ] + + result = await oauth_provider._discover_oauth_metadata( + "https://api.example.com/v1/mcp" + ) + + assert result is not None + assert ( + result.authorization_endpoint == oauth_metadata.authorization_endpoint + ) + assert result.token_endpoint == oauth_metadata.token_endpoint + + # Verify correct URLs were called in order + assert mock_client.get.call_count == 2 + + # First call should be to protected resource metadata endpoint + first_call_args = mock_client.get.call_args_list[0][0] + assert ( + first_call_args[0] + == "https://api.example.com/.well-known/oauth-protected-resource" + ) + + # Second call should be to authorization server's OAuth metadata endpoint + second_call_args = mock_client.get.call_args_list[1][0] + assert ( + second_call_args[0] + == "https://auth.example.com/.well-known/oauth-authorization-server" + ) + @pytest.mark.anyio async def test_register_oauth_client_success( self, oauth_provider, oauth_metadata, oauth_client_info From aee7ee979d80d3b502e0fe68ce2047604d6872a4 Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 19:12:22 +0100 Subject: [PATCH 5/6] Renamed test method to better reflect its purpose Signed-off-by: Xin Fu --- tests/server/auth/middleware/test_bearer_auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index 6b0cbec47..0116931f0 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -307,10 +307,14 @@ async def send(message: Message) -> None: assert excinfo.value.detail == "Unauthorized" assert not app.called - async def test_no_user_with_adds_www_authenticate_header( + async def test_unauthenticated_user_adds_www_authenticate_header( self, ): - """Test middleware with no user in scope.""" + """Test middleware with unauthenticated user. + + It should add www-authenticate header to the response when resource metadata + URL is provided. + """ app = MockApp() middleware = RequireAuthMiddleware( app, From 453de4caa00868558638981b5313cc0bef3534e3 Mon Sep 17 00:00:00 2001 From: Xin Fu Date: Mon, 26 May 2025 19:14:16 +0100 Subject: [PATCH 6/6] Fix resource URL in the oauth_protected_resource_metadata fixture Signed-off-by: Xin Fu --- tests/client/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 0371b7d0e..7e08ef64f 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -78,7 +78,7 @@ def oauth_metadata(): @pytest.fixture def oauth_protected_resource_metadata(): return OAuthProtectedResourceMetadata( - resource="https://api.example.com/v1/mcp", + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), authorization_servers=[AnyHttpUrl("https://auth.example.com")], scopes_supported=["read", "write"], bearer_methods_supported=["header"],