chore(mcp): revert browser-friendly hello page for GET /mcp from browsers (#40467)

This commit is contained in:
Amin Ghadersohi
2026-05-27 08:43:01 -07:00
committed by GitHub
parent 207a7bf7f9
commit fb60662353
6 changed files with 17 additions and 315 deletions

View File

@@ -21,7 +21,6 @@ include README.md
include superset-frontend/package.json
recursive-include superset/examples *
recursive-include superset/migrations *
recursive-include superset/mcp_service *.html
recursive-include superset/templates *
recursive-include superset/translations *
recursive-include superset/static *

View File

@@ -1,115 +0,0 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Superset MCP Server</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
margin: 0;
padding: 40px 16px;
line-height: 1.6;
}
.card {
max-width: 640px;
margin: 0 auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
padding: 40px 40px 32px;
}
h1 { font-size: 1.4rem; margin: 0 0 8px; }
.badge {
display: inline-block;
background: #e8f4fd;
color: #0070c0;
font-size: .75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
margin-bottom: 20px;
letter-spacing: .04em;
text-transform: uppercase;
}
p { margin: 0 0 20px; color: #444; }
h2 { font-size: 1rem; margin: 24px 0 8px; color: #1a1a1a; }
pre {
background: #f0f0f0;
border-radius: 6px;
padding: 16px;
font-size: .85rem;
overflow-x: auto;
margin: 0 0 24px;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.note {
font-size: .85rem;
color: #666;
border-left: 3px solid #ddd;
padding-left: 12px;
margin-top: 24px;
}
</style>
</head>
<body>
<div class="card">
<div class="badge">MCP API Endpoint</div>
<h1>Superset MCP Server</h1>
<p>
This is the <strong>Model Context Protocol (MCP)</strong> endpoint for
Apache Superset. It is an API designed for AI coding assistants —
not a web page to browse directly.
</p>
<h2>How to connect</h2>
<p>Add the following to your MCP client configuration,
replacing the URL and API key with your actual values:</p>
<pre><code>{
"mcpServers": {
"superset": {
"url": "&lt;this-url&gt;",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer &lt;your-api-key&gt;"
}
}
}
}</code></pre>
<h2>Supported clients</h2>
<p>This endpoint works with any MCP-compatible client, including:</p>
<ul style="color:#444;margin:0 0 20px;padding-left:20px;">
<li>Claude Desktop</li>
<li>Claude Code (CLI)</li>
<li>Cursor</li>
<li>Any client that supports the <code>streamable-http</code> transport</li>
</ul>
<div class="note">
Replace <code>&lt;this-url&gt;</code> with the full URL of this page and
<code>&lt;your-api-key&gt;</code> with a valid Superset API key or JWT token.
</div>
</div>
</body>
</html>

View File

@@ -29,7 +29,6 @@ import base64
import logging
import time
from contextvars import ContextVar
from importlib.resources import files as _resource_files
from typing import Any, cast
from authlib.jose.errors import (
@@ -46,7 +45,7 @@ from starlette.authentication import AuthenticationError
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import HTTPConnection
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.responses import JSONResponse
from superset.utils import json
@@ -62,54 +61,21 @@ _jwt_failure_reason: ContextVar[str | None] = ContextVar(
"_jwt_failure_reason", default=None
)
_MCP_BROWSER_HELLO_HTML: str | None = None
def _json_auth_error_handler(
conn: HTTPConnection, exc: AuthenticationError
) -> JSONResponse:
"""JSON 401 error handler for authentication failures.
def _get_browser_hello_html() -> str:
global _MCP_BROWSER_HELLO_HTML
if _MCP_BROWSER_HELLO_HTML is None:
_MCP_BROWSER_HELLO_HTML = (
_resource_files("superset.mcp_service")
.joinpath("hello.html")
.read_text(encoding="utf-8")
)
return _MCP_BROWSER_HELLO_HTML
def _prefers_browser_html(conn: HTTPConnection) -> bool:
"""Return True when the request looks like a browser navigation.
Checks both the HTTP method (GET/HEAD only) and the Accept header
(text/html present, application/json and text/event-stream absent).
Case-insensitive to handle unusual but valid header values.
"""
if conn.scope.get("method") not in ("GET", "HEAD"):
return False
accept = conn.headers.get("accept", "").lower()
return (
"text/html" in accept
and "application/json" not in accept
and "text/event-stream" not in accept
)
def _auth_error_handler(conn: HTTPConnection, exc: AuthenticationError) -> Response:
"""Auth error handler for unauthenticated MCP requests.
Returns a friendly HTML page for browser navigation requests so users
who open the MCP URL in a browser see setup instructions instead of a
raw JSON 401.
For all other clients (API, SSE, non-GET methods) returns a standard
JSON 401 per RFC 6750 Section 3.1.
Per RFC 6750 Section 3.1, error responses MUST NOT leak server
configuration or token claim values. Only generic error codes are
returned to clients. Detailed failure reasons are logged server-side
only for debugging.
References:
- RFC 6750 Section 3.1: https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
- CVE-2022-29266, CVE-2019-7644: verbose JWT errors led to exploits
"""
if _prefers_browser_html(conn):
return HTMLResponse(status_code=200, content=_get_browser_hello_html())
# Log detailed reason server-side only
logger.warning("JWT authentication failed: %s", exc)
@@ -125,28 +91,6 @@ def _auth_error_handler(conn: HTTPConnection, exc: AuthenticationError) -> Respo
)
class MCPJWTVerifier(JWTVerifier):
"""JWTVerifier with Superset MCP auth error handling.
Provides browser-friendly HTML responses for unauthenticated browser
navigation requests (GET/HEAD with Accept: text/html), while maintaining
RFC 6750-compliant JSON 401 responses for API and SSE clients.
Use this as the base for all Superset JWT verifiers so the browser hello
page is active regardless of which verifier variant is configured.
"""
def get_middleware(self) -> list[Any]:
return [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(self),
on_error=_auth_error_handler,
),
Middleware(AuthContextMiddleware),
]
class DetailedBearerAuthBackend(BearerAuthBackend):
"""
Bearer auth backend that raises AuthenticationError with specific
@@ -180,7 +124,7 @@ class DetailedBearerAuthBackend(BearerAuthBackend):
return None
class DetailedJWTVerifier(MCPJWTVerifier):
class DetailedJWTVerifier(JWTVerifier):
"""
JWT verifier with tiered server-side logging for each validation step.
@@ -356,7 +300,7 @@ class DetailedJWTVerifier(MCPJWTVerifier):
Middleware(
AuthenticationMiddleware,
backend=DetailedBearerAuthBackend(self),
on_error=_auth_error_handler,
on_error=_json_auth_error_handler,
),
Middleware(AuthContextMiddleware),
]

View File

@@ -343,10 +343,10 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
auth_provider = DetailedJWTVerifier(**common_kwargs)
else:
# MCPJWTVerifier: minimal logging + browser-friendly error page.
from superset.mcp_service.jwt_verifier import MCPJWTVerifier
# Default JWTVerifier: minimal logging, generic error responses.
from fastmcp.server.auth.providers.jwt import JWTVerifier
auth_provider = MCPJWTVerifier(**common_kwargs)
auth_provider = JWTVerifier(**common_kwargs)
return auth_provider
except Exception:

View File

@@ -26,7 +26,7 @@ import pytest
from authlib.jose.errors import BadSignatureError, DecodeError, ExpiredTokenError
from superset.mcp_service.jwt_verifier import (
_auth_error_handler,
_json_auth_error_handler,
_jwt_failure_reason,
DetailedBearerAuthBackend,
DetailedJWTVerifier,
@@ -400,7 +400,7 @@ def test_get_middleware_returns_custom_components(hs256_verifier):
== "DetailedBearerAuthBackend"
)
# on_error should be the RFC 6750-compliant generic handler
assert auth_middleware.kwargs["on_error"] is _auth_error_handler
assert auth_middleware.kwargs["on_error"] is _json_auth_error_handler
class _FakeHeaders(dict[str, str]):
@@ -496,7 +496,7 @@ def test_error_handler_never_leaks_jwt_details():
for reason in sensitive_reasons:
exc = AuthenticationError(reason)
response = _auth_error_handler(mock_conn, exc)
response = _json_auth_error_handler(mock_conn, exc)
assert response.status_code == 401

View File

@@ -1,126 +0,0 @@
# 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 browser-friendly hello page in _auth_error_handler."""
from unittest.mock import MagicMock
from starlette.authentication import AuthenticationError
from starlette.responses import HTMLResponse, JSONResponse
from superset.mcp_service.jwt_verifier import _auth_error_handler
def _make_conn(accept: str, method: str = "GET") -> MagicMock:
conn = MagicMock()
conn.headers = {"accept": accept} if accept else {}
conn.scope = {"method": method}
return conn
def test_browser_accept_returns_html_200() -> None:
conn = _make_conn("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
assert b"MCP" in response.body
assert b"mcpServers" in response.body
def test_browser_accept_html_only_returns_200() -> None:
conn = _make_conn("text/html")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
def test_json_accept_returns_401() -> None:
conn = _make_conn("application/json")
exc = AuthenticationError("bad token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, JSONResponse)
assert response.status_code == 401
def test_event_stream_accept_returns_401() -> None:
conn = _make_conn("text/event-stream")
exc = AuthenticationError("bad token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, JSONResponse)
assert response.status_code == 401
def test_no_accept_header_returns_401() -> None:
conn = MagicMock()
conn.headers = {}
conn.scope = {"method": "GET"}
exc = AuthenticationError("bad token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, JSONResponse)
assert response.status_code == 401
def test_json_401_body_is_rfc6750_compliant() -> None:
conn = _make_conn("application/json")
exc = AuthenticationError("expired")
response = _auth_error_handler(conn, exc)
assert response.status_code == 401
assert response.headers.get("www-authenticate") == 'Bearer error="invalid_token"'
def test_html_accepted_alongside_other_types_but_not_json() -> None:
conn = _make_conn("text/html, */*")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
def test_accept_both_html_and_json_returns_401() -> None:
# When a client lists both, treat it as an API client (application/json wins)
conn = _make_conn("text/html, application/json")
exc = AuthenticationError("bad token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, JSONResponse)
assert response.status_code == 401
def test_post_with_html_accept_returns_401() -> None:
# Non-GET/HEAD methods always get JSON 401, even with text/html Accept
conn = _make_conn("text/html", method="POST")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, JSONResponse)
assert response.status_code == 401
def test_head_with_html_accept_returns_html_200() -> None:
conn = _make_conn("text/html", method="HEAD")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
def test_accept_header_case_insensitive() -> None:
conn = _make_conn("TEXT/HTML")
exc = AuthenticationError("no token")
response = _auth_error_handler(conn, exc)
assert isinstance(response, HTMLResponse)
assert response.status_code == 200