diff --git a/AGENTS.md b/AGENTS.md index 223c92090..0f681b787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,3 +65,4 @@ While these have slightly different semantics and implementations, in general ch 4. Make the smallest set of changes that achieve the desired outcome. 5. Always read code before modifying it blindly. 6. Follow established patterns and maintain consistency. +7. Use `uv run --with pandas something.py` instead of `uv pip install pandas && uv run something.py` for one-offs. diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 1b97702c4..4ac815b42 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -10,11 +10,12 @@ from mcp.server.auth.middleware.bearer_auth import ( BearerAuthBackend, RequireAuthMiddleware, ) -from mcp.server.auth.routes import create_auth_routes +from mcp.server.auth.routes import create_auth_routes, create_protected_resource_routes from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -104,6 +105,18 @@ def setup_auth_middleware_and_routes( ) ) + # Add OAuth Protected Resource Metadata endpoint (RFC 9728) + # This is required by the MCP spec for OAuth discovery + # Note: We use the issuer URL as the resource URL since the actual + # resource URL depends on the transport and path configuration + auth_routes.extend( + create_protected_resource_routes( + resource_url=auth.issuer_url, + authorization_servers=[auth.issuer_url], + scopes_supported=auth.required_scopes, + ) + ) + return middleware, auth_routes, required_scopes @@ -185,17 +198,30 @@ def create_sse_app( server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) # Auth is enabled, wrap endpoints with RequireAuthMiddleware + # Build the resource metadata URL + resource_metadata_url = AnyHttpUrl( + f"{str(auth.issuer_url).rstrip('/')}/.well-known/oauth-protected-resource" + ) + server_routes.append( Route( sse_path, - endpoint=RequireAuthMiddleware(handle_sse, required_scopes), + endpoint=RequireAuthMiddleware( + handle_sse, + required_scopes, + resource_metadata_url=resource_metadata_url, + ), methods=["GET"], ) ) server_routes.append( Mount( message_path, - app=RequireAuthMiddleware(sse.handle_post_message, required_scopes), + app=RequireAuthMiddleware( + sse.handle_post_message, + required_scopes, + resource_metadata_url=resource_metadata_url, + ), ) ) else: @@ -315,10 +341,19 @@ def create_streamable_http_app( server_middleware.extend(auth_middleware) # Auth is enabled, wrap endpoint with RequireAuthMiddleware + # Build the resource metadata URL + resource_metadata_url = AnyHttpUrl( + f"{str(auth.issuer_url).rstrip('/')}/.well-known/oauth-protected-resource" + ) + server_routes.append( Mount( streamable_http_path, - app=RequireAuthMiddleware(handle_streamable_http, required_scopes), + app=RequireAuthMiddleware( + handle_streamable_http, + required_scopes, + resource_metadata_url=resource_metadata_url, + ), ) ) else: diff --git a/tests/server/http/test_oauth_protected_resource.py b/tests/server/http/test_oauth_protected_resource.py new file mode 100644 index 000000000..40063d39f --- /dev/null +++ b/tests/server/http/test_oauth_protected_resource.py @@ -0,0 +1,108 @@ +"""Test OAuth protected resource metadata endpoint.""" + +import httpx +import pytest + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import ClientRegistrationOptions +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider + + +@pytest.fixture +def oauth_server(): + """Create a FastMCP server with OAuth enabled.""" + server = FastMCP( + "TestServer", + auth=InMemoryOAuthProvider( + issuer_url="http://localhost:8000", + client_registration_options=ClientRegistrationOptions(enabled=True), + ), + ) + + @server.tool + def test_tool() -> str: + return "test" + + return server + + +@pytest.fixture +def oauth_app(oauth_server): + """Create HTTP app with OAuth enabled.""" + return oauth_server.http_app() + + +async def test_oauth_protected_resource_endpoint(oauth_app): + """Test that the OAuth protected resource metadata endpoint exists and returns correct data.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000" + ) as client: + # Test GET request + response = await client.get("/.well-known/oauth-protected-resource") + assert response.status_code == 200 + + data = response.json() + assert "resource" in data + assert "authorization_servers" in data + assert "bearer_methods_supported" in data + + # Check that authorization servers contains our issuer URL + # The issuer URL might have a trailing slash + assert len(data["authorization_servers"]) == 1 + assert data["authorization_servers"][0].rstrip("/") == "http://localhost:8000" + assert data["bearer_methods_supported"] == ["header"] + + +async def test_oauth_protected_resource_options_request(oauth_app): + """Test that the OAuth protected resource endpoint responds to OPTIONS requests.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000" + ) as client: + # Test simple OPTIONS request - the endpoint should at least respond + response = await client.options("/.well-known/oauth-protected-resource") + # The endpoint exists and handles OPTIONS (even if it returns various status codes) + assert response.status_code < 500 # Not a server error + + +async def test_oauth_authorization_server_endpoint_still_exists(oauth_app): + """Test that the existing OAuth authorization server endpoint still works.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000" + ) as client: + response = await client.get("/.well-known/oauth-authorization-server") + assert response.status_code == 200 + + data = response.json() + assert "issuer" in data + assert "authorization_endpoint" in data + assert "token_endpoint" in data + assert "registration_endpoint" in data + + +async def test_www_authenticate_header_includes_resource_metadata(): + """Test that 401 responses include the resource metadata URL in WWW-Authenticate header.""" + server = FastMCP( + "TestServer", + auth=InMemoryOAuthProvider( + issuer_url="http://localhost:8000", + ), + ) + + app = server.http_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000" + ) as client: + # Make a request without authentication + response = await client.get("/mcp/") + assert response.status_code == 401 + + # Check WWW-Authenticate header + www_auth = response.headers.get("www-authenticate") + assert www_auth is not None + assert "Bearer" in www_auth + assert ( + 'resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource"' + in www_auth + ) + assert 'error="invalid_token"' in www_auth