diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 892f8629d..034394573 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -44,8 +44,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install FastMCP - # run with frozen to use the current lockfile; static checks will determine if it needs updating - run: uv sync --frozen + # run with upgrade to always test against the latest compatible versions + run: uv sync --upgrade - name: Run tests (excluding integration and client_process) run: uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal @@ -69,8 +69,8 @@ jobs: python-version: "3.10" - name: Install FastMCP - # run with frozen to use the current lockfile; static checks will determine if it needs updating - run: uv sync --frozen + # run with upgrade to always test against the latest compatible versions + run: uv sync --upgrade - name: Run integration tests # use longer per-test timeout than the default 3s diff --git a/pyproject.toml b/pyproject.toml index e6c93d38f..ac755433f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "python-dotenv>=1.1.0", "exceptiongroup>=1.2.2", "httpx>=0.28.1", - "mcp>=1.12.4,<2.0.0", + "mcp>=1.17.0,<2.0.0", "openapi-pydantic>=0.5.1", "rich>=13.9.4", "cyclopts>=3.0.0", diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 25264ce05..2e94465a0 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -6,6 +6,7 @@ from contextvars import ContextVar from typing import TYPE_CHECKING from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware +from mcp.server.auth.routes import build_resource_metadata_url from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport from mcp.server.streamable_http import EventStore @@ -172,6 +173,12 @@ def create_sse_app( server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) + # Build RFC 9728-compliant metadata URL + resource_url = auth._get_resource_url(sse_path) + resource_metadata_url = ( + build_resource_metadata_url(resource_url) if resource_url else None + ) + # Create protected SSE endpoint route with GET method only server_routes.append( Route( @@ -179,7 +186,7 @@ def create_sse_app( endpoint=RequireAuthMiddleware( handle_sse, auth.required_scopes, - auth._get_resource_url("/.well-known/oauth-protected-resource"), + resource_metadata_url, ), methods=["GET"], ) @@ -192,7 +199,7 @@ def create_sse_app( app=RequireAuthMiddleware( sse.handle_post_message, auth.required_scopes, - auth._get_resource_url("/.well-known/oauth-protected-resource"), + resource_metadata_url, ), ) ) @@ -294,6 +301,12 @@ def create_streamable_http_app( server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) + # Build RFC 9728-compliant metadata URL + resource_url = auth._get_resource_url(streamable_http_path) + resource_metadata_url = ( + build_resource_metadata_url(resource_url) if resource_url else None + ) + # Create protected HTTP endpoint route server_routes.append( Route( @@ -301,7 +314,7 @@ def create_streamable_http_app( endpoint=RequireAuthMiddleware( streamable_http_app, auth.required_scopes, - auth._get_resource_url("/.well-known/oauth-protected-resource"), + resource_metadata_url, ), ) ) diff --git a/tests/server/auth/test_auth_provider.py b/tests/server/auth/test_auth_provider.py index d108d5e43..ab6ab36cf 100644 --- a/tests/server/auth/test_auth_provider.py +++ b/tests/server/auth/test_auth_provider.py @@ -32,10 +32,10 @@ class TestAuthProviderBase: async def test_www_authenticate_header_points_to_base_url( self, basic_remote_provider ): - """Test that WWW-Authenticate header always points to base URL's .well-known. + """Test that WWW-Authenticate header points to RFC 9728-compliant metadata URL. - This test verifies the fix for issue #1685 where the WWW-Authenticate header - was incorrectly including the MCP path in the .well-known URL. + The WWW-Authenticate header includes the resource path per RFC 9728, + so clients can discover where the metadata is actually registered. """ mcp = FastMCP("test-server", auth=basic_remote_provider) # Mount MCP at a non-root path @@ -57,10 +57,10 @@ class TestAuthProviderBase: assert match is not None metadata_url = match.group(1) - # Should point to base URL, not include /api/v1/mcp + # The metadata URL includes the resource path per RFC 9728 assert ( metadata_url - == "https://my-server.com/.well-known/oauth-protected-resource" + == "https://my-server.com/.well-known/oauth-protected-resource/api/v1/mcp" ) async def test_automatic_resource_url_capture(self, basic_remote_provider): @@ -77,8 +77,8 @@ class TestAuthProviderBase: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://my-server.com", ) as client: - # Get the .well-known metadata - response = await client.get("/.well-known/oauth-protected-resource") + # The .well-known metadata is at a path-aware location per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() @@ -94,7 +94,10 @@ class TestAuthProviderBase: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://my-server.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The .well-known metadata includes the resource path per RFC 9728 + response = await client.get( + "/.well-known/oauth-protected-resource/api/v2/services/mcp" + ) assert response.status_code == 200 data = response.json() diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py index eedb871b3..0ef2cadfd 100644 --- a/tests/server/auth/test_remote_auth_provider.py +++ b/tests/server/auth/test_remote_auth_provider.py @@ -195,7 +195,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://api.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 async def test_protected_resource_metadata_endpoint_resource_field(self): @@ -209,7 +210,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://api.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") data = response.json() # This is the key test - ensure resource field contains the full MCP URL @@ -228,7 +230,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://api.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") data = response.json() assert data["authorization_servers"] == ["https://auth.example.com/"] @@ -243,15 +246,24 @@ class TestRemoteAuthProviderIntegration: ) async def test_base_url_configurations(self, base_url: str, expected_resource: str): """Test different base_url configurations.""" + from urllib.parse import urlparse + auth_provider = self._create_test_auth_provider(base_url=base_url) mcp = FastMCP("test-server", auth=auth_provider) mcp_http_app = mcp.http_app() + # Extract the path from the expected resource to construct metadata URL + resource_parsed = urlparse(expected_resource) + # Remove leading slash if present to avoid double slashes + resource_path = resource_parsed.path.lstrip("/") + metadata_path = f"/.well-known/oauth-protected-resource/{resource_path}" + async with httpx.AsyncClient( transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://test.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get(metadata_path) assert response.status_code == 200 data = response.json() @@ -275,7 +287,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://api.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") data = response.json() assert data["resource"] == "https://api.example.com/mcp" @@ -298,7 +311,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://api.example.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") data = response.json() assert set(data["authorization_servers"]) == { @@ -382,7 +396,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://my-server.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() @@ -418,7 +433,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://my-server.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() @@ -455,7 +471,8 @@ class TestRemoteAuthProviderIntegration: transport=httpx.ASGITransport(app=mcp_http_app), base_url="https://my-server.com", ) as client: - response = await client.get("/.well-known/oauth-protected-resource") + # The metadata URL is path-aware per RFC 9728 + response = await client.get("/.well-known/oauth-protected-resource/mcp") assert response.status_code == 200 data = response.json() diff --git a/tests/server/http/test_http_auth_middleware.py b/tests/server/http/test_http_auth_middleware.py index c29a3391e..a6f0a99ff 100644 --- a/tests/server/http/test_http_auth_middleware.py +++ b/tests/server/http/test_http_auth_middleware.py @@ -39,9 +39,10 @@ class TestStreamableHTTPAppResourceMetadataURL: route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp") assert isinstance(route.endpoint, RequireAuthMiddleware) + # The metadata URL includes the resource path per RFC 9728 assert ( str(route.endpoint.resource_metadata_url) - == "https://resource.example.com/.well-known/oauth-protected-resource" + == "https://resource.example.com/.well-known/oauth-protected-resource/mcp" ) def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair): @@ -59,10 +60,11 @@ class TestStreamableHTTPAppResourceMetadataURL: ) route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp") assert isinstance(route.endpoint, RequireAuthMiddleware) - # Should not have double slash + # The metadata URL includes the resource path per RFC 9728 + # Trailing slash in base_url is normalized assert ( str(route.endpoint.resource_metadata_url) - == "https://resource.example.com/.well-known/oauth-protected-resource" + == "https://resource.example.com/.well-known/oauth-protected-resource/mcp" ) def test_no_auth_provider_mounts_without_require_auth_middleware( diff --git a/tests/server/openapi/test_basic_functionality.py b/tests/server/openapi/test_basic_functionality.py index ca558c115..f92031098 100644 --- a/tests/server/openapi/test_basic_functionality.py +++ b/tests/server/openapi/test_basic_functionality.py @@ -100,6 +100,7 @@ class TestTools: meta=dict(_fastmcp=dict(tags=["create", "users"])), title=None, annotations=None, + icons=None, description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL), inputSchema={ "type": "object", @@ -125,6 +126,7 @@ class TestTools: meta=dict(_fastmcp=dict(tags=["update", "users"])), title=None, annotations=None, + icons=None, description=IsStr( regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL ), diff --git a/uv.lock b/uv.lock index 9d140bc21..447ba2eac 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -599,7 +599,7 @@ requires-dist = [ { name = "cyclopts", specifier = ">=3.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", specifier = ">=1.12.4,<2.0.0" }, + { name = "mcp", specifier = ">=1.17.0,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, @@ -1041,7 +1041,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.12.4" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1056,9 +1056,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/88/f6cb7e7c260cd4b4ce375f2b1614b33ce401f63af0f49f7141a2e9bf0a45/mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5", size = 431148, upload-time = "2025-08-07T20:31:18.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/68/316cbc54b7163fa22571dcf42c9cc46562aae0a021b974e0a8141e897200/mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789", size = 160145, upload-time = "2025-08-07T20:31:15.69Z" }, + { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" }, ] [[package]]