Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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 *
Expand Down
115 changes: 115 additions & 0 deletions superset/mcp_service/hello.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!--
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>
78 changes: 67 additions & 11 deletions superset/mcp_service/jwt_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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 (
Expand All @@ -45,7 +46,7 @@
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import HTTPConnection
from starlette.responses import JSONResponse
from starlette.responses import HTMLResponse, JSONResponse, Response

from superset.utils import json

Expand All @@ -61,21 +62,54 @@
"_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.

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.
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.

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)

Expand All @@ -91,6 +125,28 @@ def _json_auth_error_handler(
)


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
Expand Down Expand Up @@ -124,7 +180,7 @@ async def authenticate(self, conn: HTTPConnection) -> tuple[Any, Any] | None:
return None


class DetailedJWTVerifier(JWTVerifier):
class DetailedJWTVerifier(MCPJWTVerifier):
"""
JWT verifier with tiered server-side logging for each validation step.

Expand Down Expand Up @@ -300,7 +356,7 @@ def get_middleware(self) -> list[Any]:
Middleware(
AuthenticationMiddleware,
backend=DetailedBearerAuthBackend(self),
on_error=_json_auth_error_handler,
on_error=_auth_error_handler,
),
Middleware(AuthContextMiddleware),
]
Expand Down
6 changes: 3 additions & 3 deletions superset/mcp_service/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,10 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:

auth_provider = DetailedJWTVerifier(**common_kwargs)
else:
# Default JWTVerifier: minimal logging, generic error responses.
from fastmcp.server.auth.providers.jwt import JWTVerifier
# MCPJWTVerifier: minimal logging + browser-friendly error page.
from superset.mcp_service.jwt_verifier import MCPJWTVerifier

auth_provider = JWTVerifier(**common_kwargs)
auth_provider = MCPJWTVerifier(**common_kwargs)

return auth_provider
except Exception:
Expand Down
6 changes: 3 additions & 3 deletions tests/unit_tests/mcp_service/test_jwt_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from authlib.jose.errors import BadSignatureError, DecodeError, ExpiredTokenError

from superset.mcp_service.jwt_verifier import (
_json_auth_error_handler,
_auth_error_handler,
_jwt_failure_reason,
DetailedBearerAuthBackend,
DetailedJWTVerifier,
Expand Down Expand Up @@ -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 _json_auth_error_handler
assert auth_middleware.kwargs["on_error"] is _auth_error_handler


class _FakeHeaders(dict[str, str]):
Expand Down Expand Up @@ -496,7 +496,7 @@ def test_error_handler_never_leaks_jwt_details():

for reason in sensitive_reasons:
exc = AuthenticationError(reason)
response = _json_auth_error_handler(mock_conn, exc)
response = _auth_error_handler(mock_conn, exc)

assert response.status_code == 401

Expand Down
Loading
Loading