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
9 changes: 8 additions & 1 deletion superset/mcp_service/chart/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1703,7 +1703,14 @@ def validate_save_or_preview(self) -> "GenerateChartRequest":

class GenerateExploreLinkRequest(FormDataCacheControl):
dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)")
config: ChartConfig = Field(..., description="Chart configuration")
config: ChartConfig | None = Field(
None,
description=(
"Chart configuration. Optional; omit to get a default "
"explore URL that opens the dataset in Superset without a "
"preconfigured chart."
),
)


class UpdateChartRequest(QueryCacheControl):
Expand Down
41 changes: 35 additions & 6 deletions superset/mcp_service/explore/tool/generate_explore_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,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 @@ -83,6 +85,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 @@ -93,19 +100,17 @@ async def generate_explore_link(

Returns explore URL for immediate use.
"""
chart_type = request.config.chart_type if request.config else "none"
await ctx.info(
"Generating explore link for dataset_id=%s, chart_type=%s"
% (request.dataset_id, request.config.chart_type)
% (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:
# config is already a typed ChartConfig (validated by Pydantic)
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 @@ -157,8 +162,32 @@ 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,
"chart_type_label": 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"):
# config is already a typed ChartConfig (validated by Pydantic)
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 @@ -256,7 +285,7 @@ async def generate_explore_link(
"Explore link generation failed for dataset_id=%s, chart_type=%s: %s: %s"
% (
request.dataset_id,
request.config.chart_type,
chart_type,
type(e).__name__,
str(e),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,52 @@ 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
assert result.data["chart_type_label"] 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 result.data["chart_type_label"] 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