diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 8e5e13d0c44f..9cd81f2b68af 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -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) diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py index 9df6e030e975..a049782143a5 100644 --- a/superset/mcp_service/explore/tool/generate_explore_link.py +++ b/superset/mcp_service/explore/tool/generate_explore_link.py @@ -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), ) diff --git a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py index fb8aee539b04..82b50c8a685d 100644 --- a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py +++ b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py @@ -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(