fix(mcp): batch fix for execute_sql crashes, null timestamps, and deck.gl errors (#38977)

This commit is contained in:
Amin Ghadersohi
2026-03-31 18:50:20 +02:00
committed by GitHub
parent c37a3ec292
commit daefedebcd
10 changed files with 92 additions and 27 deletions

View File

@@ -25,6 +25,7 @@ import difflib
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Any, Dict, List, Literal, Protocol from typing import Annotated, Any, Dict, List, Literal, Protocol
import humanize
from pydantic import ( from pydantic import (
AliasChoices, AliasChoices,
AliasPath, AliasPath,
@@ -272,6 +273,13 @@ class GetChartInfoRequest(BaseModel):
return self return self
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None: def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
if not chart: if not chart:
return None return None
@@ -297,11 +305,11 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None), or (str(chart.changed_by) if getattr(chart, "changed_by", None) else None),
changed_by_name=getattr(chart, "changed_by_name", None), changed_by_name=getattr(chart, "changed_by_name", None),
changed_on=getattr(chart, "changed_on", None), changed_on=getattr(chart, "changed_on", None),
changed_on_humanized=getattr(chart, "changed_on_humanized", None), changed_on_humanized=_humanize_timestamp(getattr(chart, "changed_on", None)),
created_by=getattr(chart, "created_by_name", None) created_by=getattr(chart, "created_by_name", None)
or (str(chart.created_by) if getattr(chart, "created_by", None) else None), or (str(chart.created_by) if getattr(chart, "created_by", None) else None),
created_on=getattr(chart, "created_on", None), created_on=getattr(chart, "created_on", None),
created_on_humanized=getattr(chart, "created_on_humanized", None), created_on_humanized=_humanize_timestamp(getattr(chart, "created_on", None)),
uuid=str(getattr(chart, "uuid", "")) if getattr(chart, "uuid", None) else None, uuid=str(getattr(chart, "uuid", "")) if getattr(chart, "uuid", None) else None,
tags=[ tags=[
TagInfo.model_validate(tag, from_attributes=True) TagInfo.model_validate(tag, from_attributes=True)

View File

@@ -369,6 +369,29 @@ async def get_chart_data( # noqa: C901
# Bubble charts use x/y/size as separate metric fields. # Bubble charts use x/y/size as separate metric fields.
viz_type = chart.viz_type or "" viz_type = chart.viz_type or ""
# Deck.gl chart types store spatial data (lat/lon)
# rather than traditional metrics/groupby. They
# require a saved query_context to retrieve data.
# Match by prefix to cover all current and future
# deck.gl viz types (deck_arc, deck_scatter, etc.).
if viz_type.startswith("deck_"):
await ctx.warning(
"Chart %s is a deck.gl visualization (%s) with no "
"saved query_context. Data retrieval requires "
"re-saving the chart in Superset." % (chart.id, viz_type)
)
return ChartError(
error=(
f"Chart {chart.id} is a deck.gl visualization "
f"(type: {viz_type}) with no saved query_context. "
f"Deck.gl charts use spatial data (lat/lon) that "
f"cannot be reconstructed from form_data alone. "
f"Please open this chart in Superset and re-save "
f"it to generate a query_context."
),
error_type="MissingQueryContext",
)
singular_metric_no_groupby = ( singular_metric_no_groupby = (
"big_number", "big_number",
"big_number_total", "big_number_total",

View File

@@ -47,6 +47,7 @@ DEFAULT_CHART_COLUMNS = [
"slice_name", "slice_name",
"viz_type", "viz_type",
"url", "url",
"changed_on",
"changed_on_humanized", "changed_on_humanized",
] ]

View File

@@ -68,6 +68,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, TYPE_CHECKING from typing import Annotated, Any, Dict, List, Literal, TYPE_CHECKING
import humanize
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
@@ -515,6 +516,13 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
) )
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
"""Simple dashboard serializer that safely handles object attributes.""" """Simple dashboard serializer that safely handles object attributes."""
from superset.mcp_service.utils.url_utils import get_superset_base_url from superset.mcp_service.utils.url_utils import get_superset_base_url
@@ -537,10 +545,14 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
published=getattr(dashboard, "published", None), published=getattr(dashboard, "published", None),
changed_by=getattr(dashboard, "changed_by_name", None), changed_by=getattr(dashboard, "changed_by_name", None),
changed_on=getattr(dashboard, "changed_on", None), changed_on=getattr(dashboard, "changed_on", None),
changed_on_humanized=getattr(dashboard, "changed_on_humanized", None), changed_on_humanized=_humanize_timestamp(
getattr(dashboard, "changed_on", None)
),
created_by=getattr(dashboard, "created_by_name", None), created_by=getattr(dashboard, "created_by_name", None),
created_on=getattr(dashboard, "created_on", None), created_on=getattr(dashboard, "created_on", None),
created_on_humanized=getattr(dashboard, "created_on_humanized", None), created_on_humanized=_humanize_timestamp(
getattr(dashboard, "created_on", None)
),
description=getattr(dashboard, "description", None), description=getattr(dashboard, "description", None),
css=getattr(dashboard, "css", None), css=getattr(dashboard, "css", None),
certified_by=getattr(dashboard, "certified_by", None), certified_by=getattr(dashboard, "certified_by", None),

View File

@@ -49,6 +49,7 @@ DEFAULT_DASHBOARD_COLUMNS = [
"dashboard_title", "dashboard_title",
"slug", "slug",
"url", "url",
"changed_on",
"changed_on_humanized", "changed_on_humanized",
] ]

View File

@@ -24,6 +24,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal from typing import Annotated, Any, Dict, List, Literal
import humanize
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
@@ -307,6 +308,13 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
return value return value
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
if not dataset: if not dataset:
return None return None
@@ -349,11 +357,11 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
changed_by=getattr(dataset, "changed_by_name", None) changed_by=getattr(dataset, "changed_by_name", None)
or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None), or (str(dataset.changed_by) if getattr(dataset, "changed_by", None) else None),
changed_on=getattr(dataset, "changed_on", None), changed_on=getattr(dataset, "changed_on", None),
changed_on_humanized=getattr(dataset, "changed_on_humanized", None), changed_on_humanized=_humanize_timestamp(getattr(dataset, "changed_on", None)),
created_by=getattr(dataset, "created_by_name", None) created_by=getattr(dataset, "created_by_name", None)
or (str(dataset.created_by) if getattr(dataset, "created_by", None) else None), or (str(dataset.created_by) if getattr(dataset, "created_by", None) else None),
created_on=getattr(dataset, "created_on", None), created_on=getattr(dataset, "created_on", None),
created_on_humanized=getattr(dataset, "created_on_humanized", None), created_on_humanized=_humanize_timestamp(getattr(dataset, "created_on", None)),
tags=[ tags=[
TagInfo.model_validate(tag, from_attributes=True) TagInfo.model_validate(tag, from_attributes=True)
for tag in getattr(dataset, "tags", []) for tag in getattr(dataset, "tags", [])

View File

@@ -48,6 +48,7 @@ DEFAULT_DATASET_COLUMNS = [
"id", "id",
"table_name", "table_name",
"schema", "schema",
"changed_on",
"changed_on_humanized", "changed_on_humanized",
] ]

View File

@@ -36,8 +36,7 @@ from superset_core.queries.types import (
QueryStatus, QueryStatus,
) )
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import SupersetErrorType
from superset.exceptions import SupersetErrorException, SupersetSecurityException
from superset.extensions import event_logger from superset.extensions import event_logger
from superset.mcp_service.sql_lab.schemas import ( from superset.mcp_service.sql_lab.schemas import (
ColumnInfo, ColumnInfo,
@@ -91,21 +90,23 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
db.session.query(Database).filter_by(id=request.database_id).first() db.session.query(Database).filter_by(id=request.database_id).first()
) )
if not database: if not database:
raise SupersetErrorException( await ctx.error(
SupersetError( "Database not found: database_id=%s" % request.database_id
message=f"Database with ID {request.database_id} not found", )
error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR, return ExecuteSqlResponse(
level=ErrorLevel.ERROR, success=False,
) error=f"Database with ID {request.database_id} not found",
error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR.value,
) )
if not security_manager.can_access_database(database): if not security_manager.can_access_database(database):
raise SupersetSecurityException( await ctx.error(
SupersetError( "Access denied to database: %s" % database.database_name
message=(f"Access denied to database {database.database_name}"), )
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR, return ExecuteSqlResponse(
level=ErrorLevel.ERROR, success=False,
) error=f"Access denied to database {database.database_name}",
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value,
) )
# 2. Build QueryOptions and execute query # 2. Build QueryOptions and execute query

View File

@@ -1236,6 +1236,7 @@ class TestDatasetDefaultColumnFiltering:
"id", "id",
"table_name", "table_name",
"schema", "schema",
"changed_on",
"changed_on_humanized", "changed_on_humanized",
} }
@@ -1319,7 +1320,13 @@ class TestDatasetDefaultColumnFiltering:
dataset_item = data["datasets"][0] dataset_item = data["datasets"][0]
# Verify ONLY default columns are present in the response item # Verify ONLY default columns are present in the response item
expected_keys = {"id", "table_name", "schema", "changed_on_humanized"} expected_keys = {
"id",
"table_name",
"schema",
"changed_on",
"changed_on_humanized",
}
actual_keys = set(dataset_item.keys()) actual_keys = set(dataset_item.keys())
# The response should only contain the default columns, NOT all columns # The response should only contain the default columns, NOT all columns
@@ -1335,7 +1342,6 @@ class TestDatasetDefaultColumnFiltering:
"description", "description",
"database_name", "database_name",
"changed_by", "changed_by",
"changed_on",
"columns", "columns",
"metrics", "metrics",
] ]

View File

@@ -237,7 +237,7 @@ class TestExecuteSql:
mock_security_manager, # noqa: PT019 mock_security_manager, # noqa: PT019
mcp_server, mcp_server,
): ):
"""Test error when database is not found.""" """Test graceful error when database is not found."""
# mock_security_manager is patched but not used (error happens first) # mock_security_manager is patched but not used (error happens first)
del mock_security_manager # Silence unused variable warning del mock_security_manager # Silence unused variable warning
mock_db.session.query.return_value.filter_by.return_value.first.return_value = ( mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
@@ -251,8 +251,10 @@ class TestExecuteSql:
} }
async with Client(mcp_server) as client: async with Client(mcp_server) as client:
with pytest.raises(ToolError, match="Database with ID 999 not found"): result = await client.call_tool("execute_sql", {"request": request})
await client.call_tool("execute_sql", {"request": request}) data = result.structured_content
assert data["success"] is False
assert "Database with ID 999 not found" in data["error"]
@patch("superset.security_manager", new_callable=MagicMock) @patch("superset.security_manager", new_callable=MagicMock)
@patch("superset.db") @patch("superset.db")
@@ -274,8 +276,10 @@ class TestExecuteSql:
} }
async with Client(mcp_server) as client: async with Client(mcp_server) as client:
with pytest.raises(ToolError, match="Access denied to database"): result = await client.call_tool("execute_sql", {"request": request})
await client.call_tool("execute_sql", {"request": request}) data = result.structured_content
assert data["success"] is False
assert "Access denied to database" in data["error"]
@patch("superset.security_manager") @patch("superset.security_manager")
@patch("superset.db") @patch("superset.db")