This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new dfd84bf3d9e fix(mcp): only serve the browser hello page on the MCP 
endpoint (#45391)
dfd84bf3d9e is described below

commit dfd84bf3d9ebcf64dd479e94ce43a39ed7597206
Author: Mikołaj Balcerek <[email protected]>
AuthorDate: Thu Oct 22 17:25:15 2026 +0200

    fix(mcp): only serve the browser hello page on the MCP endpoint (#45391)
    
    Co-authored-by: Mikołaj Balcerek <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 superset/mcp_service/jwt_verifier.py               |  28 ++++-
 .../mcp_service/test_browser_hello_middleware.py   | 126 +++++++++++++++++++++
 2 files changed, 150 insertions(+), 4 deletions(-)

diff --git a/superset/mcp_service/jwt_verifier.py 
b/superset/mcp_service/jwt_verifier.py
index 8276855d1a9..390686a5023 100644
--- a/superset/mcp_service/jwt_verifier.py
+++ b/superset/mcp_service/jwt_verifier.py
@@ -35,6 +35,7 @@ from collections.abc import Callable
 from contextvars import ContextVar
 from typing import Any, cast
 
+import fastmcp
 import httpx
 from authlib.jose import JsonWebToken
 from authlib.jose.errors import JoseError
@@ -42,6 +43,7 @@ from fastmcp.server.auth.auth import AccessToken
 from fastmcp.server.auth.providers.jwt import JWTVerifier
 from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
 from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
+from starlette._utils import get_route_path
 from starlette.authentication import AuthenticationError
 from starlette.middleware import Middleware
 from starlette.middleware.authentication import AuthenticationMiddleware
@@ -298,9 +300,11 @@ def _prefers_browser_html(conn: HTTPConnection) -> bool:
 class BrowserHelloMiddleware(BaseHTTPMiddleware):
     """Starlette middleware that returns a browser-friendly hello page.
 
-    Intercepts GET/HEAD requests with a browser Accept header before they
-    reach FastMCP's router (which returns 405 for GET). Works regardless
-    of whether MCP_AUTH_ENABLED is True or False.
+    Intercepts GET/HEAD requests with a browser Accept header to the MCP
+    endpoint before they reach FastMCP's router (which returns 405 for GET).
+    Works regardless of whether MCP_AUTH_ENABLED is True or False.
+
+    Only the MCP endpoint path is intercepted. Other routes are passed through.
 
     When auth_enabled=True the page includes Bearer token setup instructions.
     When auth_enabled=False the page omits the Authorization header from the
@@ -312,20 +316,36 @@ class BrowserHelloMiddleware(BaseHTTPMiddleware):
         app: Any,
         auth_enabled: bool = False,
         page_config: dict[str, Any] | None = None,
+        mcp_path: str | None = None,
     ) -> None:
         super().__init__(app)
         self._html = _build_browser_hello_html(
             auth_enabled=auth_enabled, page_config=page_config
         )
+        # The streamable-HTTP endpoint FastMCP mounts (``/mcp`` by default).
+        self._mcp_path = _normalize_path(
+            mcp_path or fastmcp.settings.streamable_http_path
+        )
 
     async def dispatch(
         self, request: Request, call_next: Callable[..., Any]
     ) -> Response:
-        if request.method in ("GET", "HEAD") and 
_prefers_browser_html(request):
+        if (
+            request.method in ("GET", "HEAD")
+            # Match BrowserHelloMiddleware only on _mcp_path, to not override
+            # e.g. /authorize endpoint
+            and _normalize_path(get_route_path(request.scope)) == 
self._mcp_path
+            and _prefers_browser_html(request)
+        ):
             return HTMLResponse(content=self._html, status_code=200)
         return await call_next(request)
 
 
+def _normalize_path(path: str) -> str:
+    """Compare paths regardless of a trailing slash (``/mcp`` == ``/mcp/``)."""
+    return path.rstrip("/") or "/"
+
+
 def _auth_error_handler(conn: HTTPConnection, exc: AuthenticationError) -> 
Response:
     """Auth error handler for unauthenticated MCP requests.
 
diff --git a/tests/unit_tests/mcp_service/test_browser_hello_middleware.py 
b/tests/unit_tests/mcp_service/test_browser_hello_middleware.py
new file mode 100644
index 00000000000..ecdbb725646
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_browser_hello_middleware.py
@@ -0,0 +1,126 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for BrowserHelloMiddleware path scoping.
+
+The hello page must only answer browser navigations to the MCP endpoint.
+Other routes on the same app -- notably the browser-facing OAuth routes of
+an ``MCP_AUTH_FACTORY`` OAuth-proxy provider -- must reach their handlers.
+"""
+
+import pytest
+from starlette.applications import Starlette
+from starlette.middleware import Middleware
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+from starlette.routing import Mount, Route
+from starlette.testclient import TestClient
+
+from superset.mcp_service.jwt_verifier import BrowserHelloMiddleware
+
+BROWSER_ACCEPT = 
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+HELLO_MARKER = b"mcpServers"
+
+PASSTHROUGH_PATHS_TO_TEST = [
+    "/authorize",
+    "/consent",
+    "/auth/callback",
+    "/.well-known/oauth-authorization-server",
+    "/.well-known/oauth-protected-resource/mcp",
+    "/health",
+    "/",
+]
+
+
+async def _mcp_endpoint(request: Request) -> Response:
+    # FastMCP's streamable-HTTP endpoint only accepts POST.
+    return PlainTextResponse("mcp endpoint", status_code=405)
+
+
+async def _route(request: Request) -> Response:
+    return PlainTextResponse(f"route {request.url.path}")
+
+
+def _build_app(mcp_path: str | None = None) -> Starlette:
+    routes = [Route(mcp_path or "/mcp", _mcp_endpoint, methods=["GET", 
"POST"])] + [
+        Route(path, _route, methods=["GET", "HEAD"])
+        for path in PASSTHROUGH_PATHS_TO_TEST
+    ]
+    return Starlette(
+        routes=routes,
+        middleware=[
+            Middleware(BrowserHelloMiddleware, auth_enabled=True, 
mcp_path=mcp_path)
+        ],
+    )
+
+
+def _build_client(mcp_path: str | None = None) -> TestClient:
+    return TestClient(_build_app(mcp_path))
+
+
+def test_browser_get_on_mcp_endpoint_returns_hello_page() -> None:
+    response = _build_client().get("/mcp", headers={"accept": BROWSER_ACCEPT})
+    assert response.status_code == 200
+    assert HELLO_MARKER in response.content
+
+
+def test_browser_get_on_mcp_endpoint_with_trailing_slash_returns_hello_page() 
-> None:
+    response = _build_client().get(
+        "/mcp/", headers={"accept": BROWSER_ACCEPT}, follow_redirects=False
+    )
+    assert response.status_code == 200
+    assert HELLO_MARKER in response.content
+
+
+def test_browser_head_on_mcp_endpoint_returns_hello_page() -> None:
+    response = _build_client().head("/mcp", headers={"accept": BROWSER_ACCEPT})
+    assert response.status_code == 200
+
+
[email protected]("path", PASSTHROUGH_PATHS_TO_TEST)
+def test_browser_get_on_other_routes_passes_through(path: str) -> None:
+    response = _build_client().get(path, headers={"accept": BROWSER_ACCEPT})
+    assert response.status_code == 200
+    assert response.text == f"route {path}"
+    assert HELLO_MARKER not in response.content
+
+
[email protected]("accept", ["application/json", "text/event-stream", 
""])
+def test_non_browser_get_on_mcp_endpoint_passes_through(accept: str) -> None:
+    headers = {"accept": accept} if accept else {}
+    response = _build_client().get("/mcp", headers=headers)
+    assert response.status_code == 405
+    assert response.text == "mcp endpoint"
+
+
+def test_custom_mcp_path_is_respected() -> None:
+    client = _build_client(mcp_path="/custom-mcp")
+    assert (
+        HELLO_MARKER
+        in client.get("/custom-mcp", headers={"accept": 
BROWSER_ACCEPT}).content
+    )
+    response = client.get("/authorize", headers={"accept": BROWSER_ACCEPT})
+    assert response.text == "route /authorize"
+
+
+def test_app_mounted_under_prefix_matches_route_path() -> None:
+    client = TestClient(Starlette(routes=[Mount("/prefix", app=_build_app())]))
+    response = client.get("/prefix/mcp", headers={"accept": BROWSER_ACCEPT})
+    assert response.status_code == 200
+    assert HELLO_MARKER in response.content
+    response = client.get("/prefix/authorize", headers={"accept": 
BROWSER_ACCEPT})
+    assert response.text == "route /prefix/authorize"

Reply via email to