Skip to content

Commit e2d6afa

Browse files
aminghadersohimichael-s-molina
authored andcommitted
fix(mcp): strip json_metadata and position_json from get_dashboard_info response (#39101)
(cherry picked from commit 680cef0)
1 parent 2520447 commit e2d6afa

8 files changed

Lines changed: 590 additions & 34 deletions

File tree

superset/mcp_service/common/schema_discovery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ def _get_sqlalchemy_type_name(col_type: Any) -> str:
169169
"dashboard_title": "Dashboard display title",
170170
"slug": "URL-friendly identifier for the dashboard",
171171
"published": "Whether the dashboard is published and visible",
172-
"position_json": "JSON layout of dashboard components",
173-
"json_metadata": "JSON metadata including filters and settings",
174172
"css": "Custom CSS for the dashboard",
173+
"native_filters": "Native filter configuration (name, type, targets)",
174+
"cross_filters_enabled": "Whether cross-filtering between charts is enabled",
175175
"theme_id": "Theme ID for dashboard styling",
176176
}
177177

superset/mcp_service/dashboard/schemas.py

Lines changed: 205 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
dashboard_title="Sales Dashboard",
2929
published=True,
3030
owners=[UserInfo(id=1, username="admin")],
31-
charts=[ChartInfo(id=1, slice_name="Sales Chart")]
31+
charts=[DashboardChartSummary(id=1, slice_name="Sales Chart")]
3232
)
3333
3434
# For dashboard list responses
@@ -65,6 +65,7 @@
6565

6666
from __future__ import annotations
6767

68+
import logging
6869
from datetime import datetime
6970
from typing import Annotated, Any, Dict, List, Literal, TYPE_CHECKING
7071

@@ -83,7 +84,6 @@
8384
from superset.models.dashboard import Dashboard
8485

8586
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
86-
from superset.mcp_service.chart.schemas import ChartInfo, serialize_chart_object
8787
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
8888
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
8989
from superset.mcp_service.system.schemas import (
@@ -97,6 +97,7 @@
9797
_remove_dangerous_unicode,
9898
_strip_html_tags,
9999
)
100+
from superset.utils.json import loads as json_loads
100101

101102

102103
class DashboardError(BaseModel):
@@ -298,6 +299,43 @@ class GetDashboardInfoRequest(MetadataCacheControl):
298299
)
299300

300301

302+
logger = logging.getLogger(__name__)
303+
304+
305+
class NativeFilterSummary(BaseModel):
306+
"""Lightweight summary of a native filter for LLM consumption.
307+
308+
Extracts only the fields needed to understand what filters are
309+
available on a dashboard: name, type, and which columns they target.
310+
"""
311+
312+
id: str | None = Field(None, description="Filter ID")
313+
name: str | None = Field(None, description="Filter display name")
314+
filter_type: str | None = Field(
315+
None, description="Filter type (e.g. filter_select, filter_range)"
316+
)
317+
targets: List[Dict[str, Any]] = Field(
318+
default_factory=list,
319+
description="Filter targets (column name and dataset ID)",
320+
)
321+
322+
323+
class DashboardChartSummary(BaseModel):
324+
"""Lightweight chart representation for dashboard context.
325+
326+
Contains only the fields needed for LLMs to understand which charts
327+
are on a dashboard, omitting verbose fields like form_data, tags,
328+
owners, and timestamps that bloat the response.
329+
"""
330+
331+
id: int | None = Field(None, description="Chart ID")
332+
slice_name: str | None = Field(None, description="Chart name")
333+
viz_type: str | None = Field(None, description="Visualization type")
334+
datasource_name: str | None = Field(None, description="Datasource name")
335+
url: str | None = Field(None, description="Chart explore page URL")
336+
description: str | None = Field(None, description="Chart description")
337+
338+
301339
class DashboardInfo(BaseModel):
302340
id: int | None = None
303341
dashboard_title: str | None = None
@@ -306,8 +344,6 @@ class DashboardInfo(BaseModel):
306344
css: str | None = None
307345
certified_by: str | None = None
308346
certification_details: str | None = None
309-
json_metadata: str | None = None
310-
position_json: str | None = None
311347
published: bool | None = None
312348
is_managed_externally: bool | None = None
313349
external_url: str | None = None
@@ -323,7 +359,32 @@ class DashboardInfo(BaseModel):
323359
owners: List[UserInfo] = Field(default_factory=list)
324360
tags: List[TagInfo] = Field(default_factory=list)
325361
roles: List[RoleInfo] = Field(default_factory=list)
326-
charts: List[ChartInfo] = Field(default_factory=list)
362+
charts: List[DashboardChartSummary] = Field(default_factory=list)
363+
364+
# Structured filter information extracted from json_metadata
365+
native_filters: List[NativeFilterSummary] = Field(
366+
default_factory=list,
367+
description=(
368+
"Native filters configured on this dashboard. Extracted from "
369+
"json_metadata for LLM consumption — includes filter name, type, "
370+
"and target columns."
371+
),
372+
)
373+
cross_filters_enabled: bool | None = Field(
374+
None,
375+
description="Whether cross-filtering between charts is enabled.",
376+
)
377+
378+
# Omission metadata — tells the agent what was stripped and why
379+
omitted_fields: Dict[str, str] = Field(
380+
default_factory=dict,
381+
description=(
382+
"Fields omitted from this response to reduce size. Keys are field "
383+
"names, values describe what was omitted and how to access the full "
384+
"data. Useful filter information has been extracted into "
385+
"native_filters and cross_filters_enabled above."
386+
),
387+
)
327388

328389
# Fields for permalink/filter state support
329390
permalink_key: str | None = Field(
@@ -473,6 +534,121 @@ class GenerateDashboardResponse(BaseModel):
473534
error: str | None = Field(None, description="Error message, if creation failed")
474535

475536

537+
def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None:
538+
"""Parse json_metadata string into a dict, returning None on any failure.
539+
540+
Handles None/empty input, invalid JSON, and non-dict JSON values
541+
(e.g. ``"[]"``, ``"123"``) by returning None instead of raising.
542+
"""
543+
if not json_metadata_str:
544+
return None
545+
try:
546+
metadata = json_loads(json_metadata_str)
547+
except (ValueError, TypeError):
548+
return None
549+
if not isinstance(metadata, dict):
550+
return None
551+
return metadata
552+
553+
554+
def _extract_native_filters(json_metadata_str: str | None) -> List[NativeFilterSummary]:
555+
"""Extract native filter summaries from raw json_metadata string.
556+
557+
Parses the json_metadata JSON blob and pulls out only the filter
558+
name, type, and targets — dropping verbose fields like controlValues,
559+
defaultDataMask, scope, and cascadeParentIds.
560+
"""
561+
metadata = _parse_json_metadata(json_metadata_str)
562+
if metadata is None:
563+
return []
564+
565+
native_filters = metadata.get("native_filter_configuration", [])
566+
if not isinstance(native_filters, list):
567+
return []
568+
569+
summaries: List[NativeFilterSummary] = []
570+
for f in native_filters:
571+
if not isinstance(f, dict):
572+
continue
573+
raw_targets = f.get("targets", [])
574+
if not isinstance(raw_targets, list):
575+
raw_targets = []
576+
targets = [t for t in raw_targets if isinstance(t, dict)]
577+
summaries.append(
578+
NativeFilterSummary(
579+
id=f.get("id"),
580+
name=f.get("name"),
581+
filter_type=f.get("filterType"),
582+
targets=targets,
583+
)
584+
)
585+
return summaries
586+
587+
588+
def _extract_cross_filters_enabled(json_metadata_str: str | None) -> bool | None:
589+
"""Extract the cross_filters_enabled flag from json_metadata."""
590+
metadata = _parse_json_metadata(json_metadata_str)
591+
if metadata is None:
592+
return None
593+
cross_filters = metadata.get("cross_filters_enabled")
594+
if isinstance(cross_filters, bool):
595+
return cross_filters
596+
return None
597+
598+
599+
def _build_omitted_fields(
600+
json_metadata_str: str | None, position_json_str: str | None
601+
) -> Dict[str, str]:
602+
"""Build omission metadata describing which fields were stripped and why.
603+
604+
Uses the shared OmittedFieldsBuilder utility so the pattern is consistent
605+
across all MCP tool serializers.
606+
"""
607+
from superset.mcp_service.utils.response_utils import OmittedFieldsBuilder
608+
609+
return (
610+
OmittedFieldsBuilder()
611+
.add_raw_field(
612+
"position_json",
613+
raw_value=position_json_str,
614+
reason=(
615+
"Internal layout tree with component positions/hierarchy. "
616+
"Not useful for analysis or LLM context."
617+
),
618+
)
619+
.add_extracted_field(
620+
"json_metadata",
621+
raw_value=json_metadata_str,
622+
reason=(
623+
"native_filters and cross_filters_enabled extracted into "
624+
"dedicated fields above."
625+
),
626+
)
627+
.build()
628+
)
629+
630+
631+
def serialize_chart_summary(chart: Any) -> DashboardChartSummary | None:
632+
"""Serialize a chart to a lightweight summary for dashboard context."""
633+
if not chart:
634+
return None
635+
from superset.mcp_service.utils.url_utils import get_superset_base_url
636+
637+
chart_id = getattr(chart, "id", None)
638+
chart_url = None
639+
if chart_id is not None:
640+
chart_url = f"{get_superset_base_url()}/explore/?slice_id={chart_id}"
641+
642+
return DashboardChartSummary(
643+
id=chart_id,
644+
slice_name=getattr(chart, "slice_name", None),
645+
viz_type=getattr(chart, "viz_type", None),
646+
datasource_name=getattr(chart, "datasource_name", None),
647+
url=chart_url,
648+
description=getattr(chart, "description", None),
649+
)
650+
651+
476652
def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
477653
from superset.mcp_service.utils.url_utils import get_superset_base_url
478654

@@ -488,8 +664,6 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
488664
css=dashboard.css,
489665
certified_by=dashboard.certified_by,
490666
certification_details=dashboard.certification_details,
491-
json_metadata=dashboard.json_metadata,
492-
position_json=dashboard.position_json,
493667
published=dashboard.published,
494668
is_managed_externally=dashboard.is_managed_externally,
495669
external_url=dashboard.external_url,
@@ -506,6 +680,16 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
506680
created_on_humanized=dashboard.created_on_humanized,
507681
changed_on_humanized=dashboard.changed_on_humanized,
508682
chart_count=len(dashboard.slices) if dashboard.slices else 0,
683+
native_filters=_extract_native_filters(
684+
getattr(dashboard, "json_metadata", None)
685+
),
686+
cross_filters_enabled=_extract_cross_filters_enabled(
687+
getattr(dashboard, "json_metadata", None)
688+
),
689+
omitted_fields=_build_omitted_fields(
690+
getattr(dashboard, "json_metadata", None),
691+
getattr(dashboard, "position_json", None),
692+
),
509693
owners=[
510694
info
511695
for owner in dashboard.owners
@@ -524,7 +708,11 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
524708
]
525709
if dashboard.roles
526710
else [],
527-
charts=[serialize_chart_object(chart) for chart in dashboard.slices]
711+
charts=[
712+
summary
713+
for chart in dashboard.slices
714+
if (summary := serialize_chart_summary(chart)) is not None
715+
]
528716
if dashboard.slices
529717
else [],
530718
)
@@ -551,6 +739,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
551739
f"{get_superset_base_url()}/superset/dashboard/{slug or dashboard_id}/"
552740
)
553741

742+
json_metadata_str = getattr(dashboard, "json_metadata", None)
743+
position_json_str = getattr(dashboard, "position_json", None)
744+
554745
return DashboardInfo(
555746
id=dashboard_id,
556747
dashboard_title=getattr(dashboard, "dashboard_title", None),
@@ -571,8 +762,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
571762
css=getattr(dashboard, "css", None),
572763
certified_by=getattr(dashboard, "certified_by", None),
573764
certification_details=getattr(dashboard, "certification_details", None),
574-
json_metadata=getattr(dashboard, "json_metadata", None),
575-
position_json=getattr(dashboard, "position_json", None),
765+
native_filters=_extract_native_filters(json_metadata_str),
766+
cross_filters_enabled=_extract_cross_filters_enabled(json_metadata_str),
767+
omitted_fields=_build_omitted_fields(json_metadata_str, position_json_str),
576768
is_managed_externally=getattr(dashboard, "is_managed_externally", None),
577769
external_url=getattr(dashboard, "external_url", None),
578770
uuid=str(getattr(dashboard, "uuid", ""))
@@ -599,7 +791,9 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
599791
if getattr(dashboard, "roles", None)
600792
else [],
601793
charts=[
602-
serialize_chart_object(chart) for chart in getattr(dashboard, "slices", [])
794+
summary
795+
for chart in getattr(dashboard, "slices", [])
796+
if (summary := serialize_chart_summary(chart)) is not None
603797
]
604798
if getattr(dashboard, "slices", None)
605799
else [],

superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131

3232
from superset.commands.exceptions import CommandException
3333
from superset.extensions import event_logger
34-
from superset.mcp_service.chart.schemas import serialize_chart_object
3534
from superset.mcp_service.dashboard.constants import (
3635
generate_id,
3736
GRID_COLUMN_COUNT,
@@ -41,6 +40,7 @@
4140
AddChartToDashboardRequest,
4241
AddChartToDashboardResponse,
4342
DashboardInfo,
43+
serialize_chart_summary,
4444
)
4545
from superset.mcp_service.utils.url_utils import get_superset_base_url
4646
from superset.utils import json
@@ -525,7 +525,7 @@ def add_chart_to_existing_dashboard(
525525
charts=[
526526
obj
527527
for chart in getattr(updated_dashboard, "slices", [])
528-
if (obj := serialize_chart_object(chart)) is not None
528+
if (obj := serialize_chart_summary(chart)) is not None
529529
],
530530
)
531531

superset/mcp_service/dashboard/tool/generate_dashboard.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from superset_core.mcp.decorators import tool, ToolAnnotations
3030

3131
from superset.extensions import event_logger
32-
from superset.mcp_service.chart.schemas import serialize_chart_object
3332
from superset.mcp_service.dashboard.constants import (
3433
generate_id,
3534
GRID_COLUMN_COUNT,
@@ -395,6 +394,7 @@ def generate_dashboard( # noqa: C901
395394

396395
# Convert to our response format
397396
from superset.mcp_service.dashboard.schemas import (
397+
serialize_chart_summary,
398398
serialize_tag_object,
399399
serialize_user_object,
400400
)
@@ -426,7 +426,7 @@ def generate_dashboard( # noqa: C901
426426
charts=[
427427
obj
428428
for chart in getattr(dashboard, "slices", [])
429-
if (obj := serialize_chart_object(chart)) is not None
429+
if (obj := serialize_chart_summary(chart)) is not None
430430
],
431431
)
432432

superset/mcp_service/utils/permissions_utils.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@
4444
"created_by_fk", # Internal user references
4545
},
4646
"dashboard": {
47-
"json_metadata", # May contain sensitive configuration
48-
"position_json", # Internal layout data
4947
"css", # May contain sensitive styling info
5048
"changed_by_fk", # Internal user references
5149
"created_by_fk", # Internal user references
@@ -64,8 +62,6 @@
6462
"database_id": "can_this_form_get", # Database access permissions
6563
"query_context": "can_explore_json", # Explore permissions
6664
"cache_key": "can_warm_up_cache", # Cache management permissions
67-
"json_metadata": "can_this_form_get", # Advanced dashboard permissions
68-
"position_json": "can_this_form_get", # Dashboard edit permissions
6965
"css": "can_this_form_get", # Dashboard styling permissions
7066
}
7167

0 commit comments

Comments
 (0)