Compare commits

...

1 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
f2d4e9c0be feat(mcp): make config optional in generate_explore_link
Allow callers to omit the chart config and get a default explore URL
that opens the dataset in Superset without a preconfigured chart. This
removes the need to construct a chart config when the user just wants
to explore a dataset.
2026-04-22 13:21:15 +03:00
3 changed files with 91 additions and 8 deletions

View File

@@ -1378,11 +1378,20 @@ class GenerateChartRequest(QueryCacheControl):
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)

View File

@@ -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
@@ -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
@@ -88,9 +95,12 @@ 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"
@@ -98,9 +108,6 @@ async def generate_explore_link(
)
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
@@ -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:
@@ -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),
)

View File

@@ -746,6 +746,50 @@ class TestGenerateExploreLink:
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(