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"