mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
1231 lines
46 KiB
Python
1231 lines
46 KiB
Python
# 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.
|
|
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from contextvars import ContextVar
|
|
from typing import Any, Awaitable, Callable, Sequence
|
|
|
|
import mcp.types as mt
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
from fastmcp.server.middleware.middleware import CallNext
|
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
from flask import g, has_app_context
|
|
from pydantic import ValidationError
|
|
from sqlalchemy.exc import OperationalError, TimeoutError
|
|
from starlette.exceptions import HTTPException
|
|
|
|
from superset.commands.exceptions import (
|
|
CommandInvalidError,
|
|
ForbiddenError,
|
|
ObjectNotFoundError,
|
|
)
|
|
from superset.exceptions import SupersetException, SupersetSecurityException
|
|
from superset.extensions import event_logger
|
|
from superset.mcp_service.auth import (
|
|
_get_app_context_manager,
|
|
get_user_from_request,
|
|
is_tool_visible_to_current_user,
|
|
MCPNoAuthSourceError,
|
|
MCPPermissionDeniedError,
|
|
)
|
|
from superset.mcp_service.constants import (
|
|
DEFAULT_MAX_LIST_ITEMS,
|
|
DEFAULT_TOKEN_LIMIT,
|
|
DEFAULT_WARN_THRESHOLD_PCT,
|
|
)
|
|
from superset.mcp_service.utils.token_utils import (
|
|
DATA_QUERY_TOOLS,
|
|
estimate_response_tokens,
|
|
format_size_limit_error,
|
|
INFO_TOOLS,
|
|
truncate_oversized_response,
|
|
truncate_query_result,
|
|
)
|
|
from superset.utils.core import get_user_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_mcp_call_id_var: ContextVar[str | None] = ContextVar("mcp_call_id", default=None)
|
|
|
|
|
|
def _sanitize_error_for_logging(error: Exception) -> str:
|
|
"""Sanitize error messages to prevent information disclosure in logs."""
|
|
error_str = str(error)
|
|
|
|
# SECURITY FIX: Limit error message length FIRST to prevent ReDoS attacks
|
|
if len(error_str) > 500:
|
|
error_str = error_str[:500] + "...[truncated]"
|
|
|
|
# SECURITY FIX: Use bounded patterns to prevent ReDoS
|
|
import re
|
|
|
|
# Database connection strings - bounded patterns with word boundaries
|
|
# Use case-insensitive flag to handle both cases
|
|
error_str = re.sub(
|
|
r"\bpostgresql://[^@\s]{1,100}@[^/\s]{1,100}/[^\s]{0,100}",
|
|
"postgresql://[REDACTED]@[REDACTED]/[REDACTED]",
|
|
error_str,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
error_str = re.sub(
|
|
r"\bmysql://[^@\s]{1,100}@[^/\s]{1,100}/[^\s]{0,100}",
|
|
"mysql://[REDACTED]@[REDACTED]/[REDACTED]",
|
|
error_str,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
# API keys and tokens - bounded patterns
|
|
error_str = re.sub(
|
|
r"[Aa]pi[_-]?[Kk]ey[:\s]{0,5}[^\s'\"]{1,100}",
|
|
"ApiKey: [REDACTED]",
|
|
error_str,
|
|
)
|
|
error_str = re.sub(
|
|
r"[Tt]oken[:\s]{0,5}[^\s'\"]{1,100}", "Token: [REDACTED]", error_str
|
|
)
|
|
|
|
# File paths - bounded pattern
|
|
error_str = re.sub(
|
|
r"/[a-zA-Z0-9_\-/.]{1,200}/superset/", "/[REDACTED]/superset/", error_str
|
|
)
|
|
|
|
# Generic database connection URIs (redis, snowflake, bigquery, mssql, etc.)
|
|
error_str = re.sub(
|
|
r"\b\w+://[^@\s]{1,100}@[^/\s]{1,100}/[^\s]{0,100}",
|
|
"[SCHEME]://[REDACTED]@[REDACTED]/[REDACTED]",
|
|
error_str,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
# Email addresses
|
|
error_str = re.sub(
|
|
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
|
|
"[EMAIL-REDACTED]",
|
|
error_str,
|
|
)
|
|
|
|
# IP addresses - already safe pattern, keep as-is
|
|
error_str = re.sub(r"\b(\d+)\.\d+\.\d+\.\d+\b", r"\1.xxx.xxx.xxx", error_str)
|
|
|
|
# For certain error types, provide generic messages
|
|
if isinstance(error, (OperationalError, TimeoutError)):
|
|
return "Database operation failed"
|
|
elif isinstance(error, PermissionError):
|
|
return "Access denied"
|
|
elif isinstance(error, ValidationError):
|
|
return "Request validation failed"
|
|
|
|
return error_str
|
|
|
|
|
|
# Errors caused by the LLM/user — expected in normal MCP operation.
|
|
# Agents send bad params, try tools they lack access to, request nonexistent
|
|
# resources. These are 400-class errors and should be logged at WARNING.
|
|
_USER_ERROR_TYPES = (
|
|
ToolError,
|
|
ValidationError,
|
|
PermissionError,
|
|
MCPPermissionDeniedError,
|
|
ValueError,
|
|
FileNotFoundError,
|
|
CommandInvalidError,
|
|
ObjectNotFoundError,
|
|
ForbiddenError,
|
|
SupersetSecurityException,
|
|
)
|
|
|
|
|
|
def _is_user_error(error: Exception) -> bool:
|
|
"""Classify whether an error is user-caused (WARNING) or system-caused (ERROR).
|
|
|
|
User errors are expected in normal MCP operation — agents send bad params,
|
|
try tools they lack access to, request nonexistent resources. These are
|
|
400-class errors and should be logged at WARNING.
|
|
|
|
System errors are unexpected — database down, unexpected exceptions,
|
|
infrastructure failures. These are 500-class and should be logged at ERROR.
|
|
"""
|
|
if isinstance(error, _USER_ERROR_TYPES):
|
|
return True
|
|
# SupersetException and CommandException have a .status attribute.
|
|
# 4xx = user error, 5xx = system error.
|
|
if isinstance(error, SupersetException):
|
|
return error.status < 500
|
|
# HTTPException: Starlette uses status_code, werkzeug uses code.
|
|
if isinstance(error, HTTPException):
|
|
status = getattr(error, "status_code", getattr(error, "code", 500))
|
|
return status < 500
|
|
return False
|
|
|
|
|
|
_SENSITIVE_PARAM_KEYS = frozenset(
|
|
{
|
|
"password",
|
|
"token",
|
|
"api_key",
|
|
"secret",
|
|
"credentials",
|
|
"authorization",
|
|
"cookie",
|
|
}
|
|
)
|
|
|
|
|
|
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Remove sensitive fields from params before logging."""
|
|
if not isinstance(params, dict):
|
|
return params
|
|
result: dict[str, Any] = {}
|
|
for k, v in params.items():
|
|
if k.lower() in _SENSITIVE_PARAM_KEYS:
|
|
result[k] = "[REDACTED]"
|
|
elif k == "arguments" and isinstance(v, dict):
|
|
result[k] = _sanitize_params(v)
|
|
else:
|
|
result[k] = v
|
|
return result
|
|
|
|
|
|
class LoggingMiddleware(Middleware):
|
|
"""
|
|
Middleware that logs every MCP message (request and response) using the
|
|
event logger. This matches the core audit log system (Action Log UI,
|
|
logs table, custom loggers). Also attempts to log dashboard_id, chart_id
|
|
(slice_id), and dataset_id if present in tool params.
|
|
|
|
Tool calls are handled in on_call_tool() which wraps execution to capture
|
|
duration_ms. Non-tool messages (resource reads, prompts, etc.) are handled
|
|
in on_message().
|
|
|
|
When tool search is enabled (progressive discovery), the MCP client calls
|
|
``call_tool`` proxies instead of individual tools. This middleware resolves
|
|
the underlying tool name from ``call_tool`` arguments so that analytics
|
|
queries can filter by the actual tool (stored as ``mcp_tool`` in the curated
|
|
payload).
|
|
"""
|
|
|
|
#: Proxy name used by FastMCP tool-search transforms.
|
|
_CALL_TOOL_PROXY = "call_tool"
|
|
|
|
def _is_error_response(self, result: ToolResult) -> bool:
|
|
"""Check if a tool result contains an error schema response.
|
|
|
|
MCP tools return error schemas (ChartError, DashboardError, etc.)
|
|
instead of raising exceptions. These serialize to JSON containing
|
|
an "error_type" field.
|
|
"""
|
|
try:
|
|
return '"error_type"' in result.content[0].text
|
|
except (AttributeError, IndexError):
|
|
return False
|
|
|
|
def _extract_context_info(
|
|
self, context: MiddlewareContext
|
|
) -> tuple[
|
|
str | None, int | None, int | None, int | None, int | None, dict[str, Any]
|
|
]:
|
|
"""Extract agent_id, user_id, and entity IDs from context."""
|
|
agent_id = None
|
|
user_id = None
|
|
dashboard_id = None
|
|
slice_id = None
|
|
dataset_id = None
|
|
params = getattr(context.message, "params", {}) or {}
|
|
if hasattr(context, "metadata") and context.metadata:
|
|
agent_id = context.metadata.get("agent_id")
|
|
if not agent_id and hasattr(context, "session") and context.session:
|
|
agent_id = getattr(context.session, "agent_id", None)
|
|
try:
|
|
user_id = get_user_id()
|
|
except (RuntimeError, AttributeError):
|
|
user_id = None
|
|
if isinstance(params, dict):
|
|
dashboard_id = params.get("dashboard_id")
|
|
slice_id = params.get("chart_id") or params.get("slice_id")
|
|
dataset_id = params.get("dataset_id")
|
|
return agent_id, user_id, dashboard_id, slice_id, dataset_id, params
|
|
|
|
def _extract_output_ids(self, result: ToolResult) -> tuple[int | None, int | None]:
|
|
"""Extract dashboard/chart IDs created by the tool from its response.
|
|
|
|
Create-style tools (generate_chart, generate_dashboard) don't take
|
|
chart_id/dashboard_id as input, so _extract_context_info never sees
|
|
them and every retry logs slice_id/dashboard_id=None even on the
|
|
attempt that actually persisted the object. Look at the response
|
|
body instead, since that's the only place the new ID appears.
|
|
Supports both flat ("chart_id"/"dashboard_id") and nested
|
|
("chart"/"dashboard" objects with an "id" field) response shapes.
|
|
"""
|
|
from superset.utils.json import loads as json_loads
|
|
|
|
try:
|
|
data = json_loads(result.content[0].text)
|
|
except (AttributeError, IndexError, ValueError, TypeError):
|
|
return None, None
|
|
if not isinstance(data, dict):
|
|
return None, None
|
|
|
|
slice_id = None
|
|
chart = data.get("chart")
|
|
if isinstance(chart, dict):
|
|
slice_id = chart.get("id")
|
|
if slice_id is None:
|
|
slice_id = data.get("chart_id")
|
|
|
|
dashboard_id = None
|
|
dashboard = data.get("dashboard")
|
|
if isinstance(dashboard, dict):
|
|
dashboard_id = dashboard.get("id")
|
|
if dashboard_id is None:
|
|
dashboard_id = data.get("dashboard_id")
|
|
|
|
return dashboard_id, slice_id
|
|
|
|
@staticmethod
|
|
def _resolve_tool_name(tool_name: str | None, params: Any) -> str | None:
|
|
"""Resolve the underlying tool name from call_tool proxy arguments.
|
|
|
|
When tool search is enabled, the MCP client uses the ``call_tool``
|
|
proxy and passes the real tool name as the ``name`` argument. This
|
|
helper extracts that value so we can log which tool was actually
|
|
executed rather than just ``"call_tool"``.
|
|
|
|
Returns:
|
|
The resolved tool name if *tool_name* is the call_tool proxy and
|
|
``params["name"]`` is a non-empty string, otherwise ``None``.
|
|
"""
|
|
if (
|
|
tool_name == LoggingMiddleware._CALL_TOOL_PROXY
|
|
and isinstance(params, dict)
|
|
and isinstance(params.get("name"), str)
|
|
and params["name"]
|
|
):
|
|
return params["name"]
|
|
return None
|
|
|
|
def _backfill_output_ids(
|
|
self,
|
|
success: bool,
|
|
result: Any,
|
|
dashboard_id: int | None,
|
|
slice_id: int | None,
|
|
) -> tuple[int | None, int | None]:
|
|
"""Fill in missing ids from a create tool's response on success.
|
|
|
|
Create-style tools (generate_chart, generate_dashboard) don't take
|
|
the new object's ID as input, so it's missing from params. On a
|
|
successful call, pull it from the response instead so retried
|
|
creates are distinguishable.
|
|
"""
|
|
if not success or not isinstance(result, ToolResult):
|
|
return dashboard_id, slice_id
|
|
output_dashboard_id, output_slice_id = self._extract_output_ids(result)
|
|
if dashboard_id is None:
|
|
dashboard_id = output_dashboard_id
|
|
if slice_id is None:
|
|
slice_id = output_slice_id
|
|
return dashboard_id, slice_id
|
|
|
|
@staticmethod
|
|
def _build_call_tool_payload(
|
|
*,
|
|
mcp_call_id: str,
|
|
tool_name: str | None,
|
|
agent_id: str | None,
|
|
params: Any,
|
|
method: str,
|
|
dashboard_id: int | None,
|
|
slice_id: int | None,
|
|
dataset_id: int | None,
|
|
success: bool,
|
|
mcp_tool: str | None,
|
|
error_type: str | None,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"mcp_call_id": mcp_call_id,
|
|
"tool": tool_name,
|
|
"agent_id": agent_id,
|
|
"params": _sanitize_params(params),
|
|
"method": method,
|
|
"dashboard_id": dashboard_id,
|
|
"slice_id": slice_id,
|
|
"dataset_id": dataset_id,
|
|
"success": success,
|
|
}
|
|
if mcp_tool is not None:
|
|
payload["mcp_tool"] = mcp_tool
|
|
if error_type is not None:
|
|
payload["error_type"] = error_type
|
|
return payload
|
|
|
|
def _log_call_tool_result(
|
|
self,
|
|
*,
|
|
context: MiddlewareContext,
|
|
tool_name: str | None,
|
|
mcp_tool: str | None,
|
|
mcp_call_id: str,
|
|
agent_id: str | None,
|
|
user_id: int | None,
|
|
dashboard_id: int | None,
|
|
slice_id: int | None,
|
|
dataset_id: int | None,
|
|
params: Any,
|
|
success: bool,
|
|
error_type: str | None,
|
|
result: Any,
|
|
start_time: float,
|
|
) -> None:
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
dashboard_id, slice_id = self._backfill_output_ids(
|
|
success, result, dashboard_id, slice_id
|
|
)
|
|
payload = self._build_call_tool_payload(
|
|
mcp_call_id=mcp_call_id,
|
|
tool_name=tool_name,
|
|
agent_id=agent_id,
|
|
params=params,
|
|
method=context.method,
|
|
dashboard_id=dashboard_id,
|
|
slice_id=slice_id,
|
|
dataset_id=dataset_id,
|
|
success=success,
|
|
mcp_tool=mcp_tool,
|
|
error_type=error_type,
|
|
)
|
|
if has_app_context():
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_tool_call",
|
|
dashboard_id=dashboard_id,
|
|
duration_ms=duration_ms,
|
|
slice_id=slice_id,
|
|
referrer=None,
|
|
curated_payload=payload,
|
|
)
|
|
extra_parts = []
|
|
if mcp_tool is not None:
|
|
extra_parts.append(f"mcp_tool={mcp_tool}")
|
|
if error_type is not None:
|
|
extra_parts.append(f"error_type={error_type}")
|
|
extra = (", " + ", ".join(extra_parts)) if extra_parts else ""
|
|
logger.info(
|
|
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
|
|
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
|
|
"success=%s, mcp_call_id=%s%s",
|
|
tool_name,
|
|
agent_id,
|
|
user_id,
|
|
context.method,
|
|
dashboard_id,
|
|
slice_id,
|
|
dataset_id,
|
|
duration_ms,
|
|
success,
|
|
mcp_call_id,
|
|
extra,
|
|
)
|
|
|
|
async def on_call_tool(
|
|
self,
|
|
context: MiddlewareContext,
|
|
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
|
|
) -> Any:
|
|
"""Log tool calls with duration tracking."""
|
|
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
|
|
self._extract_context_info(context)
|
|
)
|
|
tool_name = getattr(context.message, "name", None)
|
|
mcp_tool = self._resolve_tool_name(tool_name, params)
|
|
|
|
mcp_call_id = secrets.token_hex(16)
|
|
_mcp_call_id_var.set(mcp_call_id)
|
|
start_time = time.time()
|
|
success = False
|
|
error_type: str | None = None
|
|
result: Any = None
|
|
try:
|
|
result = await call_next(context)
|
|
success = not self._is_error_response(result)
|
|
if isinstance(result, ToolResult):
|
|
existing_meta = result.meta or {}
|
|
result = ToolResult(
|
|
content=result.content,
|
|
meta={**existing_meta, "mcp_call_id": mcp_call_id},
|
|
structured_content=result.structured_content,
|
|
)
|
|
return result
|
|
except Exception as exc:
|
|
error_type = type(exc).__name__
|
|
success = False
|
|
raise
|
|
finally:
|
|
self._log_call_tool_result(
|
|
context=context,
|
|
tool_name=tool_name,
|
|
mcp_tool=mcp_tool,
|
|
mcp_call_id=mcp_call_id,
|
|
agent_id=agent_id,
|
|
user_id=user_id,
|
|
dashboard_id=dashboard_id,
|
|
slice_id=slice_id,
|
|
dataset_id=dataset_id,
|
|
params=params,
|
|
success=success,
|
|
error_type=error_type,
|
|
result=result,
|
|
start_time=start_time,
|
|
)
|
|
|
|
async def on_message(
|
|
self,
|
|
context: MiddlewareContext,
|
|
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
|
|
) -> Any:
|
|
"""Log non-tool messages (resource reads, prompts, etc.)."""
|
|
agent_id, user_id, dashboard_id, slice_id, dataset_id, params = (
|
|
self._extract_context_info(context)
|
|
)
|
|
if has_app_context():
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_message",
|
|
dashboard_id=dashboard_id,
|
|
duration_ms=None,
|
|
slice_id=slice_id,
|
|
referrer=None,
|
|
curated_payload={
|
|
"tool": getattr(context.message, "name", None),
|
|
"agent_id": agent_id,
|
|
"params": _sanitize_params(params),
|
|
"method": context.method,
|
|
"dashboard_id": dashboard_id,
|
|
"slice_id": slice_id,
|
|
"dataset_id": dataset_id,
|
|
},
|
|
)
|
|
logger.info(
|
|
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
|
getattr(context.message, "name", None),
|
|
agent_id,
|
|
user_id,
|
|
context.method,
|
|
)
|
|
return await call_next(context)
|
|
|
|
|
|
class StructuredContentStripperMiddleware(Middleware):
|
|
"""Strip ``outputSchema`` and ``structured_content`` to prevent encoding errors.
|
|
|
|
FastMCP 3.x auto-generates ``outputSchema`` in tool definitions
|
|
(``tools/list``) and ``structuredContent`` in tool call responses
|
|
(``tools/call``) when the tool has a typed return annotation.
|
|
|
|
Some MCP client transports (e.g. Claude.ai's MCP bridge) cannot handle
|
|
``structuredContent`` dicts, causing ``TypeError: encoding without a
|
|
string argument``. Additionally, if ``outputSchema`` is advertised but
|
|
``structuredContent`` is stripped from the response, clients may raise
|
|
``Output validation error: outputSchema defined but no structured output
|
|
returned``.
|
|
|
|
This middleware handles both sides:
|
|
- ``on_list_tools``: removes ``output_schema`` from every tool definition
|
|
- ``on_call_tool``: removes ``structured_content`` from every tool result
|
|
"""
|
|
|
|
async def on_list_tools(
|
|
self,
|
|
context: MiddlewareContext[mt.ListToolsRequest],
|
|
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
|
|
) -> Sequence[Tool]:
|
|
try:
|
|
tools = await call_next(context)
|
|
except Exception:
|
|
# ToolError raised by inner middleware (e.g. GlobalErrorHandlerMiddleware)
|
|
# cannot be encoded by the MCP SDK in a tools/list response — it expects a
|
|
# list, not an error object — causing "encoding without a string argument".
|
|
# Return an empty list; GlobalErrorHandlerMiddleware already logged it.
|
|
return []
|
|
return [
|
|
t.model_copy(update={"output_schema": None})
|
|
if t.output_schema is not None
|
|
else t
|
|
for t in tools
|
|
]
|
|
|
|
async def on_call_tool(
|
|
self,
|
|
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
call_next: Callable[[MiddlewareContext], Awaitable[ToolResult]],
|
|
) -> ToolResult:
|
|
try:
|
|
result = await call_next(context)
|
|
except Exception as e:
|
|
# When exceptions propagate past the middleware chain to the
|
|
# MCP SDK layer, they become CallToolResult(isError=True).
|
|
# Some transports (Claude.ai's MCP bridge) cannot encode these
|
|
# error responses, producing "encoding without a string argument".
|
|
# Catch ALL exceptions (not just specific types) because any
|
|
# unhandled exception — including ToolError from
|
|
# GlobalErrorHandlerMiddleware, ValueError, TypeError, etc. —
|
|
# will cause encoding failures on the wire.
|
|
mcp_call_id = _mcp_call_id_var.get(None)
|
|
return ToolResult(
|
|
content=[mt.TextContent(type="text", text=f"Error: {e}")],
|
|
meta={"mcp_call_id": mcp_call_id} if mcp_call_id else None,
|
|
)
|
|
if isinstance(result, ToolResult) and result.structured_content is not None:
|
|
result = ToolResult(content=result.content, meta=result.meta)
|
|
return result
|
|
|
|
|
|
class RBACToolVisibilityMiddleware(Middleware):
|
|
"""Filter tools/list response based on current user's RBAC permissions.
|
|
|
|
Intercepts every ``tools/list`` request and removes tools the calling user
|
|
is not permitted to execute. Public tools (no ``class_permission_name``) and
|
|
tools whose permission check passes are included; all others are hidden.
|
|
|
|
Fail-open vs fail-closed behaviour:
|
|
- No auth context at all (no Flask context, no auth header, no dev user
|
|
configured) → fail open (return all tools). Call-time RBAC enforces.
|
|
- Auth was attempted but credentials are invalid (bad API key, dev
|
|
username not in DB, etc.) → fail closed (return empty list).
|
|
- Unexpected errors → fail open. Call-time RBAC still enforces.
|
|
"""
|
|
|
|
async def on_list_tools(
|
|
self,
|
|
context: MiddlewareContext[mt.ListToolsRequest],
|
|
call_next: CallNext[mt.ListToolsRequest, list[Tool]],
|
|
) -> list[Tool]:
|
|
tools = await call_next(context)
|
|
|
|
try:
|
|
with _get_app_context_manager():
|
|
# Use get_user_from_request directly rather than
|
|
# _setup_user_context, which carries per-call execution
|
|
# overhead (retry loop, session management, error logging)
|
|
# that is unnecessary and noisy during tools/list.
|
|
try:
|
|
user = get_user_from_request()
|
|
except ValueError as exc:
|
|
if isinstance(exc, MCPNoAuthSourceError):
|
|
# No auth source configured at all → fail open.
|
|
# No log: this is expected in dev/internal deployments.
|
|
return tools
|
|
# Auth was attempted (e.g. MCP_DEV_USERNAME set) but the
|
|
# user was not found in the DB → fail closed
|
|
logger.warning(
|
|
"MCP tool list: credential failure, hiding all tools: %s",
|
|
exc,
|
|
)
|
|
return []
|
|
except PermissionError as exc:
|
|
# API key present but invalid/expired → fail closed
|
|
logger.warning(
|
|
"MCP tool list: credential failure, hiding all tools: %s",
|
|
exc,
|
|
)
|
|
return []
|
|
|
|
if user is None:
|
|
return tools # no Flask app context → fail open
|
|
g.user = user
|
|
return [t for t in tools if is_tool_visible_to_current_user(t)]
|
|
except Exception: # noqa: BLE001
|
|
# Unexpected setup errors (ImportError, etc.) → fail open.
|
|
# Call-time RBAC still enforces permissions.
|
|
return tools
|
|
|
|
|
|
class GlobalErrorHandlerMiddleware(Middleware):
|
|
"""
|
|
Global error handler middleware that provides consistent error responses
|
|
and proper error logging for all MCP tool calls.
|
|
"""
|
|
|
|
async def on_message(
|
|
self,
|
|
context: MiddlewareContext,
|
|
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
|
|
) -> Any:
|
|
"""Handle all message types with consistent error handling"""
|
|
start_time = time.time()
|
|
tool_name = getattr(context.message, "name", "unknown")
|
|
|
|
try:
|
|
return await call_next(context)
|
|
except Exception as e:
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
return await self._handle_error(e, context, tool_name, duration_ms)
|
|
|
|
async def _handle_error( # noqa: C901
|
|
self,
|
|
error: Exception,
|
|
context: MiddlewareContext,
|
|
tool_name: str,
|
|
duration_ms: int,
|
|
) -> None:
|
|
"""Handle different types of errors with appropriate responses"""
|
|
# Extract user context for logging
|
|
user_id = None
|
|
try:
|
|
user_id = get_user_id()
|
|
except Exception:
|
|
user_id = None # User not authenticated
|
|
|
|
# Log with appropriate level: user errors (expected) → WARNING,
|
|
# system errors (unexpected) → ERROR
|
|
sanitized_error = _sanitize_error_for_logging(error)
|
|
is_user = _is_user_error(error)
|
|
log_fn = logger.warning if is_user else logger.error
|
|
log_fn(
|
|
"MCP tool call failed: tool=%s, user_id=%s, "
|
|
"duration_ms=%s, error_type=%s, error=%s",
|
|
tool_name,
|
|
user_id,
|
|
duration_ms,
|
|
type(error).__name__,
|
|
sanitized_error,
|
|
exc_info=not is_user,
|
|
)
|
|
|
|
# Log to Superset's event system
|
|
try:
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_tool_error",
|
|
duration_ms=duration_ms,
|
|
curated_payload={
|
|
"tool": tool_name,
|
|
"error_type": type(error).__name__,
|
|
"error_message": sanitized_error,
|
|
"method": context.method,
|
|
"severity": "warning" if is_user else "error",
|
|
},
|
|
)
|
|
except Exception as log_error:
|
|
logger.warning("Failed to log error event: %s", log_error)
|
|
|
|
# Handle specific error types with appropriate responses
|
|
if isinstance(error, ToolError):
|
|
# Tool errors are already formatted for MCP
|
|
raise error
|
|
elif isinstance(error, ValidationError):
|
|
# Pydantic validation errors
|
|
validation_details = []
|
|
for err in error.errors():
|
|
field = " -> ".join(str(loc) for loc in err["loc"])
|
|
validation_details.append(f"{field}: {err['msg']}")
|
|
|
|
raise ToolError(
|
|
f"Validation error in {tool_name}: {'; '.join(validation_details)}"
|
|
) from error
|
|
elif isinstance(error, (OperationalError, TimeoutError)):
|
|
# Database errors
|
|
raise ToolError(
|
|
f"Database error in {tool_name}: Service temporarily unavailable. "
|
|
f"Please try again in a few moments."
|
|
) from error
|
|
elif isinstance(error, HTTPException):
|
|
# HTTP errors from screenshot endpoints or API calls
|
|
raise ToolError(f"Service error in {tool_name}: {error.detail}") from error
|
|
elif isinstance(error, MCPPermissionDeniedError):
|
|
# MCP RBAC permission denied — convert to structured ToolError.
|
|
# Must come before the generic PermissionError branch because
|
|
# MCPPermissionDeniedError inherits from PermissionError.
|
|
raise ToolError(str(error)) from error
|
|
elif isinstance(error, PermissionError):
|
|
# Permission/authorization errors
|
|
raise ToolError(
|
|
f"Permission denied for {tool_name}: "
|
|
f"You don't have access to this resource."
|
|
) from error
|
|
elif isinstance(error, ValueError):
|
|
# Value/parameter errors from tool code
|
|
raise ToolError(
|
|
f"Invalid parameter in {tool_name}: {str(error)}"
|
|
) from error
|
|
elif isinstance(error, (ObjectNotFoundError, CommandInvalidError)):
|
|
# Superset command: not found (404) or validation (422)
|
|
raise ToolError(
|
|
f"Invalid request for {tool_name}: {_sanitize_error_for_logging(error)}"
|
|
) from error
|
|
elif isinstance(error, (ForbiddenError, SupersetSecurityException)):
|
|
# Superset access denied — agent tried a tool it can't use
|
|
raise ToolError(
|
|
f"Permission denied for {tool_name}: "
|
|
f"{_sanitize_error_for_logging(error)}"
|
|
) from error
|
|
elif isinstance(error, SupersetException):
|
|
# Other Superset errors — .status determines severity (already
|
|
# classified by _is_user_error above for log level)
|
|
msg = "Invalid request" if error.status < 500 else "Internal error"
|
|
raise ToolError(
|
|
f"{msg} in {tool_name}: {_sanitize_error_for_logging(error)}"
|
|
) from error
|
|
elif isinstance(error, ConnectionError):
|
|
# Network errors — transient, expected during pod restarts
|
|
# (ConnectionRefusedError, ConnectionResetError, BrokenPipeError
|
|
# are all subclasses of ConnectionError)
|
|
raise ToolError(
|
|
f"Connection error in {tool_name}: {_sanitize_error_for_logging(error)}"
|
|
) from error
|
|
else:
|
|
# Generic internal errors — truly unexpected
|
|
error_id = f"err_{int(time.time())}"
|
|
logger.error("Unexpected error [%s] in %s: %s", error_id, tool_name, error)
|
|
|
|
raise ToolError(
|
|
f"Internal error in {tool_name}: An unexpected error occurred. "
|
|
f"Error ID: {error_id}. Please contact support if this persists."
|
|
) from error
|
|
|
|
|
|
class ResponseSizeGuardMiddleware(Middleware):
|
|
"""
|
|
Middleware that prevents oversized responses from overwhelming LLM clients.
|
|
|
|
When a tool response exceeds the configured token limit, this middleware
|
|
intercepts it and returns a helpful error message with suggestions for
|
|
reducing the response size.
|
|
|
|
This is critical for protecting LLM clients like Claude Desktop which can
|
|
crash or become unresponsive when receiving extremely large responses.
|
|
|
|
Configuration via MCP_RESPONSE_SIZE_CONFIG in superset_config.py:
|
|
- enabled: Toggle the guard on/off (default: True)
|
|
- token_limit: Maximum estimated tokens per response (default: 25,000)
|
|
- warn_threshold_pct: Log warnings above this % of limit (default: 80%)
|
|
- max_list_items: Cap for list fields during dynamic truncation (default: 100)
|
|
- excluded_tools: Tools to skip checking
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
token_limit: int = DEFAULT_TOKEN_LIMIT,
|
|
warn_threshold_pct: int = DEFAULT_WARN_THRESHOLD_PCT,
|
|
excluded_tools: list[str] | str | None = None,
|
|
max_list_items: int = DEFAULT_MAX_LIST_ITEMS,
|
|
) -> None:
|
|
self.token_limit = token_limit
|
|
self.warn_threshold_pct = warn_threshold_pct
|
|
self.warn_threshold = int(token_limit * warn_threshold_pct / 100)
|
|
if isinstance(excluded_tools, str):
|
|
excluded_tools = [excluded_tools]
|
|
self.excluded_tools = set(excluded_tools or [])
|
|
self.max_list_items = max(1, max_list_items)
|
|
|
|
@staticmethod
|
|
def _extract_payload_from_tool_result(
|
|
response: Any,
|
|
) -> dict[str, Any] | None:
|
|
"""Extract the JSON payload dict from a ToolResult's content[0].text.
|
|
|
|
FastMCP converts tool return values into ToolResult before middleware
|
|
sees them. The actual data (e.g. DashboardInfo dict) is serialized
|
|
as a JSON string inside ``content[0].text``. Truncation must operate
|
|
on that parsed dict — not on the ToolResult wrapper — otherwise
|
|
phases like "truncate charts list" never find the right keys.
|
|
|
|
Returns the payload dict when extraction succeeds, or ``None`` when
|
|
the response is not a ToolResult or cannot be parsed.
|
|
"""
|
|
from fastmcp.tools.tool import ToolResult
|
|
|
|
from superset.utils.json import loads as json_loads
|
|
|
|
if not isinstance(response, ToolResult):
|
|
return None
|
|
|
|
if (
|
|
not response.content
|
|
or not hasattr(response.content[0], "text")
|
|
or not response.content[0].text
|
|
):
|
|
return None
|
|
|
|
try:
|
|
payload = json_loads(response.content[0].text)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _rewrap_as_tool_result(payload: dict[str, Any], original: Any) -> Any:
|
|
"""Re-serialize a truncated payload dict back into a ToolResult."""
|
|
from fastmcp.tools.tool import ToolResult
|
|
from mcp.types import TextContent
|
|
|
|
from superset.utils.json import dumps as json_dumps
|
|
|
|
text = json_dumps(payload)
|
|
return ToolResult(
|
|
content=[TextContent(type="text", text=text)],
|
|
meta=original.meta if isinstance(original, ToolResult) else None,
|
|
)
|
|
|
|
def _try_truncate_info_response(
|
|
self,
|
|
tool_name: str,
|
|
response: Any,
|
|
estimated_tokens: int,
|
|
) -> Any | None:
|
|
"""Attempt to dynamically truncate an info tool response to fit the limit.
|
|
|
|
Returns the truncated response if successful, None otherwise.
|
|
|
|
When the response is a ToolResult (the normal case — FastMCP wraps
|
|
every tool return value), the actual data lives inside
|
|
``content[0].text`` as a JSON string. We parse that string, run the
|
|
truncation phases on the resulting dict, then re-wrap the result.
|
|
"""
|
|
# Unwrap ToolResult so truncation operates on the real payload
|
|
extracted = self._extract_payload_from_tool_result(response)
|
|
if extracted is not None:
|
|
truncation_target = extracted
|
|
else:
|
|
logger.debug(
|
|
"Could not extract dict payload from response for %s; "
|
|
"falling back to truncating the raw response object",
|
|
tool_name,
|
|
)
|
|
truncation_target = response
|
|
|
|
try:
|
|
truncated, was_truncated, notes = truncate_oversized_response(
|
|
truncation_target,
|
|
self.token_limit,
|
|
max_list_items=self.max_list_items,
|
|
)
|
|
except (MemoryError, RecursionError) as trunc_error:
|
|
logger.warning(
|
|
"Truncation failed for %s due to %s: %s",
|
|
tool_name,
|
|
type(trunc_error).__name__,
|
|
trunc_error,
|
|
)
|
|
return None
|
|
|
|
if not was_truncated:
|
|
return None
|
|
|
|
truncated_tokens = estimate_response_tokens(truncated)
|
|
if truncated_tokens > self.token_limit:
|
|
return None
|
|
|
|
logger.warning(
|
|
"Response for %s truncated from ~%d to ~%d tokens (limit: %d). Fields: %s",
|
|
tool_name,
|
|
estimated_tokens,
|
|
truncated_tokens,
|
|
self.token_limit,
|
|
"; ".join(notes),
|
|
)
|
|
|
|
try:
|
|
user_id = get_user_id()
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_response_truncated",
|
|
curated_payload={
|
|
"tool": tool_name,
|
|
"original_tokens": estimated_tokens,
|
|
"truncated_tokens": truncated_tokens,
|
|
"token_limit": self.token_limit,
|
|
"truncation_notes": notes,
|
|
},
|
|
)
|
|
except Exception as log_error: # noqa: BLE001
|
|
logger.warning("Failed to log truncation event: %s", log_error)
|
|
|
|
if isinstance(truncated, dict):
|
|
truncated["_response_truncated"] = True
|
|
truncated["_truncation_notes"] = notes
|
|
|
|
# Re-wrap into ToolResult if we unwrapped one
|
|
if extracted is not None and isinstance(truncated, dict):
|
|
return self._rewrap_as_tool_result(truncated, response)
|
|
|
|
return truncated
|
|
|
|
def _try_truncate_data_query_response(
|
|
self,
|
|
tool_name: str,
|
|
response: Any,
|
|
estimated_tokens: int,
|
|
) -> Any | None:
|
|
"""Attempt to truncate a data-query tool response by dropping tail rows.
|
|
|
|
Returns the truncated response if successful, None otherwise.
|
|
"""
|
|
extracted = self._extract_payload_from_tool_result(response)
|
|
truncation_target = extracted if extracted is not None else response
|
|
|
|
try:
|
|
truncated, was_truncated, notes = truncate_query_result(
|
|
truncation_target, self.token_limit, tool_name=tool_name
|
|
)
|
|
except Exception as trunc_error: # noqa: BLE001
|
|
logger.warning(
|
|
"Query result truncation failed for %s due to %s: %s",
|
|
tool_name,
|
|
type(trunc_error).__name__,
|
|
trunc_error,
|
|
)
|
|
return None
|
|
|
|
if not was_truncated:
|
|
return None
|
|
|
|
# Mirror the info-tool path: if truncation couldn't bring the
|
|
# response back under the limit (e.g. a single row/scalar field
|
|
# alone exceeds it), fall back to the hard size-limit error instead
|
|
# of shipping an over-budget response.
|
|
truncated_tokens = estimate_response_tokens(truncated)
|
|
if truncated_tokens > self.token_limit:
|
|
return None
|
|
|
|
logger.warning(
|
|
"Query result for %s truncated from ~%d to ~%d tokens (limit: %d). %s",
|
|
tool_name,
|
|
estimated_tokens,
|
|
truncated_tokens,
|
|
self.token_limit,
|
|
"; ".join(notes),
|
|
)
|
|
|
|
try:
|
|
user_id = get_user_id()
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_response_truncated",
|
|
curated_payload={
|
|
"tool": tool_name,
|
|
"original_tokens": estimated_tokens,
|
|
"truncated_tokens": truncated_tokens,
|
|
"token_limit": self.token_limit,
|
|
"truncation_notes": notes,
|
|
},
|
|
)
|
|
except Exception as log_error: # noqa: BLE001
|
|
logger.warning("Failed to log truncation event: %s", log_error)
|
|
|
|
if extracted is not None and isinstance(truncated, dict):
|
|
return self._rewrap_as_tool_result(truncated, response)
|
|
|
|
return truncated
|
|
|
|
def _handle_oversized_response(
|
|
self,
|
|
tool_name: str,
|
|
response: Any,
|
|
estimated_tokens: int,
|
|
params: dict[str, Any],
|
|
) -> Any:
|
|
"""Attempt truncation for known tool categories; block everything else.
|
|
|
|
For info tools (``INFO_TOOLS``) and data-query tools
|
|
(``DATA_QUERY_TOOLS``), tries dynamic truncation first and returns
|
|
the truncated result if successful. Falls through to a hard
|
|
``ToolError`` for all other tools, or when truncation cannot reduce
|
|
the response to fit the limit.
|
|
|
|
Raises:
|
|
ToolError: When the response exceeds the limit and cannot be
|
|
truncated.
|
|
"""
|
|
# Info tools: field-level truncation (strings, lists, dicts).
|
|
if tool_name in INFO_TOOLS:
|
|
truncated = self._try_truncate_info_response(
|
|
tool_name, response, estimated_tokens
|
|
)
|
|
if truncated is not None:
|
|
return truncated
|
|
|
|
# Data-query tools: row-level truncation.
|
|
if tool_name in DATA_QUERY_TOOLS:
|
|
truncated = self._try_truncate_data_query_response(
|
|
tool_name, response, estimated_tokens
|
|
)
|
|
if truncated is not None:
|
|
return truncated
|
|
|
|
# Log the blocked response (user-caused: requested too much data)
|
|
logger.warning(
|
|
"Response blocked for %s: ~%d tokens exceeds limit of %d",
|
|
tool_name,
|
|
estimated_tokens,
|
|
self.token_limit,
|
|
)
|
|
|
|
try:
|
|
user_id = get_user_id()
|
|
event_logger.log(
|
|
user_id=user_id,
|
|
action="mcp_response_size_exceeded",
|
|
curated_payload={
|
|
"tool": tool_name,
|
|
"estimated_tokens": estimated_tokens,
|
|
"token_limit": self.token_limit,
|
|
"params": _sanitize_params(params),
|
|
},
|
|
)
|
|
except Exception as log_error: # noqa: BLE001
|
|
logger.warning("Failed to log size exceeded event: %s", log_error)
|
|
|
|
raise ToolError(
|
|
format_size_limit_error(
|
|
tool_name=tool_name,
|
|
params=params,
|
|
estimated_tokens=estimated_tokens,
|
|
token_limit=self.token_limit,
|
|
response=None,
|
|
)
|
|
)
|
|
|
|
async def on_call_tool(
|
|
self,
|
|
context: MiddlewareContext,
|
|
call_next: Callable[[MiddlewareContext], Awaitable[Any]],
|
|
) -> Any:
|
|
"""Check response size after tool execution."""
|
|
tool_name = getattr(context.message, "name", "unknown")
|
|
|
|
# Skip excluded tools
|
|
if tool_name in self.excluded_tools:
|
|
return await call_next(context)
|
|
|
|
# Execute the tool
|
|
response = await call_next(context)
|
|
|
|
# When the response is a ToolResult, estimate tokens on the actual
|
|
# payload inside content[0].text rather than on the ToolResult
|
|
# wrapper (which would double-serialize the JSON string).
|
|
extracted = self._extract_payload_from_tool_result(response)
|
|
estimation_target = extracted if extracted is not None else response
|
|
|
|
try:
|
|
estimated_tokens = estimate_response_tokens(estimation_target)
|
|
except MemoryError as me:
|
|
logger.warning(
|
|
"MemoryError while estimating tokens for %s: %s", tool_name, me
|
|
)
|
|
# Treat as over limit to avoid further serialization
|
|
estimated_tokens = self.token_limit + 1
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(
|
|
"Failed to estimate response tokens for %s: %s", tool_name, e
|
|
)
|
|
# Conservative fallback: block rather than risk OOM
|
|
estimated_tokens = self.token_limit + 1
|
|
|
|
# Log warning if approaching limit
|
|
if estimated_tokens > self.warn_threshold:
|
|
logger.warning(
|
|
"Response size warning for %s: ~%d tokens (%.0f%% of %d limit)",
|
|
tool_name,
|
|
estimated_tokens,
|
|
(estimated_tokens / self.token_limit * 100) if self.token_limit else 0,
|
|
self.token_limit,
|
|
)
|
|
|
|
if estimated_tokens > self.token_limit:
|
|
params = getattr(context.message, "params", {}) or {}
|
|
return self._handle_oversized_response(
|
|
tool_name, response, estimated_tokens, params
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
def _safe_int_config(config: dict[str, Any], key: str, default: int) -> int:
|
|
"""Best-effort int coercion for MCP_RESPONSE_SIZE_CONFIG values.
|
|
|
|
Falls back to ``default`` (with a warning log) when the configured value
|
|
can't be converted to an int, so a malformed ``superset_config.py``
|
|
setting doesn't crash middleware initialization.
|
|
"""
|
|
value = config.get(key, default)
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
logger.warning(
|
|
"Invalid %s in MCP_RESPONSE_SIZE_CONFIG: %r is not a valid integer; "
|
|
"falling back to default %d",
|
|
key,
|
|
value,
|
|
default,
|
|
)
|
|
return default
|
|
|
|
|
|
def create_response_size_guard_middleware() -> ResponseSizeGuardMiddleware | None:
|
|
"""
|
|
Factory function to create ResponseSizeGuardMiddleware from config.
|
|
|
|
Reads configuration from Flask app's MCP_RESPONSE_SIZE_CONFIG.
|
|
Returns None if the guard is disabled.
|
|
|
|
Returns:
|
|
ResponseSizeGuardMiddleware instance or None if disabled
|
|
"""
|
|
try:
|
|
from superset.mcp_service.flask_singleton import get_flask_app
|
|
from superset.mcp_service.mcp_config import MCP_RESPONSE_SIZE_CONFIG
|
|
|
|
flask_app = get_flask_app()
|
|
|
|
# Get config from Flask app, falling back to defaults
|
|
config = flask_app.config.get(
|
|
"MCP_RESPONSE_SIZE_CONFIG", MCP_RESPONSE_SIZE_CONFIG
|
|
)
|
|
|
|
if not config.get("enabled", True):
|
|
logger.info("Response size guard is disabled")
|
|
return None
|
|
|
|
max_list_items: int = _safe_int_config(
|
|
config, "max_list_items", DEFAULT_MAX_LIST_ITEMS
|
|
)
|
|
|
|
middleware = ResponseSizeGuardMiddleware(
|
|
token_limit=_safe_int_config(config, "token_limit", DEFAULT_TOKEN_LIMIT),
|
|
warn_threshold_pct=_safe_int_config(
|
|
config, "warn_threshold_pct", DEFAULT_WARN_THRESHOLD_PCT
|
|
),
|
|
excluded_tools=config.get("excluded_tools"),
|
|
max_list_items=max_list_items,
|
|
)
|
|
|
|
logger.info(
|
|
"Created ResponseSizeGuardMiddleware with token_limit=%d",
|
|
middleware.token_limit,
|
|
)
|
|
return middleware
|
|
|
|
except (ImportError, AttributeError, KeyError) as e:
|
|
logger.error("Failed to create ResponseSizeGuardMiddleware: %s", e)
|
|
return None
|