From d2981137e2e565181977cdd74eb897208e6f00bd Mon Sep 17 00:00:00 2001 From: Vincenzo Maria Calandra Date: Thu, 29 May 2025 10:31:40 +0200 Subject: [PATCH 1/4] Refactor FastMCP routing to use Router and streamline request handling --- src/mcp/server/fastmcp/server.py | 76 ++++++++++++++------------------ 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index daa07753e..55a15c333 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -618,7 +618,7 @@ async def run_streamable_http_async(self) -> None: import uvicorn starlette_app = self.streamable_http_app() - starlette_app.router.redirect_slashes = False + config = uvicorn.Config( starlette_app, host=self.settings.host, @@ -770,7 +770,7 @@ async def sse_endpoint(request: Request) -> Response: def streamable_http_app(self) -> Starlette: """Return an instance of the StreamableHTTP server app.""" from starlette.middleware import Middleware - from starlette.routing import Route + from starlette.routing import Mount, Router # Create session manager on first call (lazy initialization) if self._session_manager is None: @@ -782,22 +782,29 @@ def streamable_http_app(self) -> Starlette: ) # Create the ASGI handler - async def handle_streamable_http(request: Request) -> Response: - await self.session_manager.handle_request( - request.scope, request.receive, request._send - ) - return Response() + async def handle_streamable_http( + scope: Scope, receive: Receive, send: Send + ) -> None: + await self.session_manager.handle_request(scope, receive, send) + + async def streamable_http_endpoint(request: Request): + return await handle_streamable_http(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] + + # Normalize the main path (no trailing slash) + _main_path = self.settings.streamable_http_path.removesuffix("/") - # Create routes - routes: list[Route] = [] + streamable_router = Router( + routes=[ + Route("/", endpoint=streamable_http_endpoint, methods=["GET", "POST"]), + ], + redirect_slashes=False, + ) + + routes: list[Route | Mount ] = [] middleware: list[Middleware] = [] required_scopes = [] - # Always register both /mcp and /mcp/ for full compatibility - _main_path = self.settings.streamable_http_path.removesuffix("/") - _alt_path = _main_path + "/" - - # Add auth endpoints if auth provider is configured + # 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 @@ -822,39 +829,22 @@ async def handle_streamable_http(request: Request) -> Response: revocation_options=self.settings.auth.revocation_options, ) ) - routes.extend( - [ - Route( - _main_path, - endpoint=RequireAuthMiddleware( - handle_streamable_http, required_scopes - ), - methods=["GET", "POST", "OPTIONS"], - ), - Route( - _alt_path, - endpoint=RequireAuthMiddleware( - handle_streamable_http, required_scopes - ), - methods=["GET", "POST", "OPTIONS"], + + routes.append( + Mount( + _main_path, + app=RequireAuthMiddleware( + streamable_router, required_scopes ), - ] + ) ) else: # Auth is disabled, no wrapper needed - routes.extend( - [ - Route( - _main_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"], - ), - Route( - _alt_path, - endpoint=handle_streamable_http, - methods=["GET", "POST", "OPTIONS"], - ), - ] + routes.append( + Mount( + _main_path, + app=streamable_router, + ) ) routes.extend(self._custom_starlette_routes) From 706b50d1fbf7aad0742c25f7b175f0b59fb11ee6 Mon Sep 17 00:00:00 2001 From: Vincenzo Maria Calandra Date: Thu, 29 May 2025 10:33:46 +0200 Subject: [PATCH 2/4] Add async support to token validation test and enhance metadata snapshot assertions --- tests/client/test_auth.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 2edaff946..caeb696ea 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -352,7 +352,8 @@ def test_has_valid_token_no_token(self, oauth_provider): """Test token validation with no token.""" assert not oauth_provider._has_valid_token() - def test_has_valid_token_valid(self, oauth_provider, oauth_token): + @pytest.mark.anyio + async def test_has_valid_token_valid(self, oauth_provider, oauth_token): """Test token validation with valid token.""" oauth_provider._current_tokens = oauth_token oauth_provider._token_expiry_time = time.time() + 3600 # Future expiry @@ -967,19 +968,35 @@ def test_build_metadata( ), revocation_options=RevocationOptions(enabled=True), ) + print("Built OAuth Metadata:") + print(metadata) - assert metadata == snapshot( + print("Expected OAuth Metadata:") + snapshot_metadata = snapshot( OAuthMetadata( issuer=AnyHttpUrl(issuer_url), authorization_endpoint=AnyHttpUrl(authorization_endpoint), token_endpoint=AnyHttpUrl(token_endpoint), registration_endpoint=AnyHttpUrl(registration_endpoint), scopes_supported=["read", "write", "admin"], + response_types_supported=["code"], + response_modes_supported=None, grant_types_supported=["authorization_code", "refresh_token"], token_endpoint_auth_methods_supported=["client_secret_post"], + token_endpoint_auth_signing_alg_values_supported=None, service_documentation=AnyHttpUrl(service_documentation_url), + ui_locales_supported=None, + op_policy_uri=None, + op_tos_uri=None, revocation_endpoint=AnyHttpUrl(revocation_endpoint), revocation_endpoint_auth_methods_supported=["client_secret_post"], + revocation_endpoint_auth_signing_alg_values_supported=None, + introspection_endpoint=None, + introspection_endpoint_auth_methods_supported=None, + introspection_endpoint_auth_signing_alg_values_supported=None, code_challenge_methods_supported=["S256"], ) ) + print(snapshot_metadata) + + assert metadata == snapshot_metadata From be5c6dc10ab7a86094104df5af4f6f0a59822055 Mon Sep 17 00:00:00 2001 From: vectorstain Date: Thu, 29 May 2025 14:18:28 +0200 Subject: [PATCH 3/4] Fix tests --- tests/client/test_auth.py | 3 +-- tests/server/fastmcp/test_server.py | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index caeb696ea..0bee64032 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -352,8 +352,7 @@ def test_has_valid_token_no_token(self, oauth_provider): """Test token validation with no token.""" assert not oauth_provider._has_valid_token() - @pytest.mark.anyio - async def test_has_valid_token_valid(self, oauth_provider, oauth_token): + def test_has_valid_token_valid(self, oauth_provider, oauth_token): """Test token validation with valid token.""" oauth_provider._current_tokens = oauth_token oauth_provider._token_expiry_time = time.time() + 3600 # Future expiry diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 9f972a08f..a975974eb 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -128,18 +128,15 @@ async def test_starlette_routes_with_mount_path(self): app = mcp.streamable_http_app() # Find routes by type - streamable_routes = [r for r in app.routes if isinstance(r, Route)] + streamable_routes = [r for r in app.routes if isinstance(r, Mount)] # Verify routes exist - assert len(streamable_routes) == 2, "Should have two streamable routes" + assert len(streamable_routes) == 1, "Should have two streamable routes" # Verify path values assert ( streamable_routes[0].path == "/mcp" ), "Streamable route path should be /mcp" - assert ( - streamable_routes[1].path == "/mcp/" - ), "Streamable route path should be /mcp/" @pytest.mark.anyio async def test_non_ascii_description(self): From 5f12a3878110d17666bc2fbc1db6ee762f032a24 Mon Sep 17 00:00:00 2001 From: vectorstain Date: Thu, 29 May 2025 15:28:20 +0200 Subject: [PATCH 4/4] Fix tests --- tests/client/test_auth.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 0bee64032..2edaff946 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -967,35 +967,19 @@ def test_build_metadata( ), revocation_options=RevocationOptions(enabled=True), ) - print("Built OAuth Metadata:") - print(metadata) - print("Expected OAuth Metadata:") - snapshot_metadata = snapshot( + assert metadata == snapshot( OAuthMetadata( issuer=AnyHttpUrl(issuer_url), authorization_endpoint=AnyHttpUrl(authorization_endpoint), token_endpoint=AnyHttpUrl(token_endpoint), registration_endpoint=AnyHttpUrl(registration_endpoint), scopes_supported=["read", "write", "admin"], - response_types_supported=["code"], - response_modes_supported=None, grant_types_supported=["authorization_code", "refresh_token"], token_endpoint_auth_methods_supported=["client_secret_post"], - token_endpoint_auth_signing_alg_values_supported=None, service_documentation=AnyHttpUrl(service_documentation_url), - ui_locales_supported=None, - op_policy_uri=None, - op_tos_uri=None, revocation_endpoint=AnyHttpUrl(revocation_endpoint), revocation_endpoint_auth_methods_supported=["client_secret_post"], - revocation_endpoint_auth_signing_alg_values_supported=None, - introspection_endpoint=None, - introspection_endpoint_auth_methods_supported=None, - introspection_endpoint_auth_signing_alg_values_supported=None, code_challenge_methods_supported=["S256"], ) ) - print(snapshot_metadata) - - assert metadata == snapshot_metadata