Skip to content
Open
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
13 changes: 11 additions & 2 deletions superset/mcp_service/chart/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,11 +1378,20 @@ def validate_save_or_preview(self) -> "GenerateChartRequest":

class GenerateExploreLinkRequest(FormDataCacheControl):
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
config: Dict[str, Any] = Field(..., description=_CHART_CONFIG_DESCRIPTION)
config: Dict[str, Any] | None = Field(
None,
description=(
f"{_CHART_CONFIG_DESCRIPTION} Optional; omit to get a default "
"explore URL that opens the dataset in Superset without a "
"preconfigured chart."
),
)

@field_validator("config", mode="before")
@classmethod
def coerce_config(cls, v: Any) -> Dict[str, Any]:
def coerce_config(cls, v: Any) -> Dict[str, Any] | None:
if v is None:
return None
return _coerce_config_to_dict(v)


Expand Down
42 changes: 36 additions & 6 deletions superset/mcp_service/explore/tool/generate_explore_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ async def generate_explore_link(
- "Visualize [data]"
- General data exploration
- When user wants to SEE data visually
- Opening a dataset in Explore without a preconfigured chart (omit config)

IMPORTANT:
- Use numeric dataset ID or UUID (NOT schema.table_name format)
- MUST include chart_type in config (either 'xy' or 'table')
- When config is provided, MUST include chart_type (e.g. 'xy' or 'table')
- Omit config entirely to return a default explore URL for the dataset

Example usage:
```json
Expand All @@ -78,6 +80,11 @@ async def generate_explore_link(
}
```

Or with no config to simply open the dataset in Explore:
```json
{"dataset_id": 123}
```

Better UX because:
- Users can interact with chart before saving
- Easy to modify parameters instantly
Expand All @@ -88,19 +95,19 @@ async def generate_explore_link(

Returns explore URL for immediate use.
"""
chart_type = (
request.config.get("chart_type", "unknown") if request.config else "none"
)
await ctx.info(
"Generating explore link for dataset_id=%s, chart_type=%s"
% (request.dataset_id, request.config.get("chart_type", "unknown"))
% (request.dataset_id, chart_type)
)
await ctx.debug(
"Configuration details: use_cache=%s, force_refresh=%s, cache_form_data=%s"
% (request.use_cache, request.force_refresh, request.cache_form_data)
)

try:
# Parse the raw config dict into a typed ChartConfig
config = parse_chart_config(request.config)

await ctx.report_progress(1, 4, "Validating dataset exists")
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
from superset.daos.dataset import DatasetDAO
Expand Down Expand Up @@ -132,8 +139,31 @@ async def generate_explore_link(
),
}

# When no config is provided, return a default explore URL that opens
# the dataset in Superset without a preconfigured chart.
if request.config is None:
await ctx.report_progress(4, 4, "URL generation complete")
from superset.mcp_service.utils.url_utils import get_superset_base_url

base_url = get_superset_base_url()
default_url = (
f"{base_url}/explore/?datasource_type=table&datasource_id={dataset.id}"
)
await ctx.info(
"Default explore link generated: dataset_id=%s" % (request.dataset_id,)
)
return {
"url": default_url,
"form_data": {},
"form_data_key": None,
"error": None,
}

await ctx.report_progress(2, 4, "Converting configuration to form data")
with event_logger.log_context(action="mcp.generate_explore_link.form_data"):
# Parse the raw config dict into a typed ChartConfig
config = parse_chart_config(request.config)

# Normalize column names to match canonical dataset column names
# This fixes case sensitivity issues (e.g., 'order_date' vs 'OrderDate')
try:
Expand Down Expand Up @@ -203,7 +233,7 @@ async def generate_explore_link(
"Explore link generation failed for dataset_id=%s, chart_type=%s: %s: %s"
% (
request.dataset_id,
request.config.get("chart_type", "unknown"),
chart_type,
type(e).__name__,
str(e),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,50 @@ async def test_generate_explore_link_nonexistent_dataset(
assert "Dataset not found: 99999" in result.data["error"]
assert "list_datasets" in result.data["error"]

@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_generate_explore_link_without_config(
self, mock_find_dataset, mcp_server
):
"""Omitting config returns a default dataset explore URL."""
mock_find_dataset.return_value = _mock_dataset(id=42)

request = GenerateExploreLinkRequest(dataset_id="42")

async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_explore_link", {"request": request.model_dump()}
)

assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?datasource_type=table"
"&datasource_id=42"
)
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None

@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_generate_explore_link_without_config_missing_dataset(
self, mock_find_dataset, mcp_server
):
"""Omitting config still surfaces a dataset-not-found error."""
mock_find_dataset.return_value = None

request = GenerateExploreLinkRequest(dataset_id="99999")

async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_explore_link", {"request": request.model_dump()}
)

assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert "Dataset not found: 99999" in result.data["error"]

@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_generate_explore_link_nonexistent_uuid_dataset(
Expand Down
Loading