aminghadersohi commented on code in PR #41611:
URL: https://github.com/apache/superset/pull/41611#discussion_r3531407576


##########
superset/mcp_service/semantic_layer/tool/list_metrics.py:
##########
@@ -0,0 +1,294 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""MCP tool: list_metrics
+
+Unified metric discovery across built-in datasets and external semantic views.
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.daos.dataset import DatasetDAO
+from superset.daos.semantic_layer import SemanticViewDAO
+from superset.extensions import db, event_logger
+from superset.mcp_service.privacy import (
+    DATA_MODEL_METADATA_ERROR_TYPE,
+    requires_data_model_metadata_access,
+    user_can_view_data_model_metadata,
+)
+from superset.mcp_service.semantic_layer.schemas import (
+    DimensionInfo,
+    ListMetricsRequest,
+    MetricInfo,
+    MetricList,
+    SemanticLayerError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _matches_search(text: str | None, search: str) -> bool:
+    if not text:
+        return False
+    return search.lower() in text.lower()
+
+
+def _builtin_compatible_dims(dataset: Any) -> list[DimensionInfo]:
+    """Return groupby-enabled columns as compatible dimensions for a built-in 
metric."""
+    return [
+        DimensionInfo(
+            name=col.column_name,
+            verbose_name=col.verbose_name or None,
+            description=col.description or None,
+            type=col.type or None,
+            is_dttm=bool(col.is_dttm),
+            groupby=bool(col.groupby),
+            filterable=bool(col.filterable),
+            source="builtin",
+        )
+        for col in dataset.columns
+        if col.groupby
+    ]
+
+
+def _collect_builtin_metrics(request: ListMetricsRequest) -> list[MetricInfo]:
+    """Collect metrics from built-in SqlaTable datasets."""
+    from sqlalchemy.orm import subqueryload
+
+    from superset.connectors.sqla.models import SqlaTable
+
+    with event_logger.log_context(action="mcp.list_metrics.builtin_query"):
+        if request.dataset_id is not None:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+
+            dataset = DatasetDAO.find_by_id(
+                request.dataset_id,
+                query_options=[
+                    subqueryload(SqlaTable.columns),
+                    subqueryload(SqlaTable.metrics),
+                ],
+            )
+            datasets = [dataset] if dataset else []
+        else:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+
+            # Use _apply_base_filter with explicit eager loading to avoid
+            # N+1 queries when iterating dataset.metrics / dataset.columns.
+            query = db.session.query(SqlaTable).options(
+                subqueryload(SqlaTable.columns),
+                subqueryload(SqlaTable.metrics),
+            )
+            datasets = DatasetDAO._apply_base_filter(query).all()
+
+    results: list[MetricInfo] = []
+    for dataset in datasets:
+        compat_dims = (
+            _builtin_compatible_dims(dataset)
+            if request.include_compatible_dimensions
+            else []
+        )
+        for metric in dataset.metrics:
+            name = metric.metric_name or ""
+            desc = metric.description or ""
+            if request.search and not (
+                _matches_search(name, request.search)
+                or _matches_search(desc, request.search)
+            ):
+                continue
+            results.append(
+                MetricInfo(
+                    name=name,
+                    verbose_name=metric.verbose_name or None,
+                    description=desc or None,
+                    expression=metric.expression or None,
+                    d3format=metric.d3format or None,
+                    warning_text=metric.warning_text or None,
+                    source="builtin",
+                    dataset_id=dataset.id,
+                    dataset_name=dataset.table_name,
+                    compatible_dimensions=compat_dims,
+                )
+            )
+    return results
+
+
+async def _collect_external_metrics(
+    request: ListMetricsRequest,
+    ctx: Context,
+) -> list[MetricInfo]:
+    """Collect metrics from external SemanticView models."""
+    with event_logger.log_context(action="mcp.list_metrics.external_query"):
+        if request.view_id is not None:
+            view = SemanticViewDAO.find_by_id(request.view_id)
+            views = [view] if view else []

Review Comment:
   Fixed — annotated as `view: SemanticView | None` (added top-level import of 
`SemanticView` from `superset.semantic_layers.models`, consistent with the 
existing top-level `SemanticViewDAO` import in this file).



##########
tests/unit_tests/mcp_service/utils/test_query_utils.py:
##########
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Unit tests for MCP service query utilities.
+"""
+
+from superset.mcp_service.utils.query_utils import validate_names
+
+
+class TestValidateNames:
+    """Test validate_names function."""
+
+    def test_all_names_valid_returns_no_errors(self):
+        """Should return an empty list when every name is valid."""
+        errors = validate_names(
+            ["region", "product"], {"region", "product"}, "dimension"
+        )
+        assert errors == []
+
+    def test_empty_requested_returns_no_errors(self):
+        """Should return an empty list when nothing was requested."""
+        errors = validate_names([], {"region"}, "dimension")
+        assert errors == []
+
+    def test_unknown_name_without_close_match(self):
+        """Should report an unknown name with no suggestion when nothing is 
close."""
+        errors = validate_names(["zzz_unknown"], {"region", "product"}, 
"dimension")
+        assert len(errors) == 1
+        assert errors[0] == "Unknown dimension: 'zzz_unknown'"
+
+    def test_unknown_name_with_close_match_suggests_alternatives(self):
+        """Should suggest close matches when available."""
+        errors = validate_names(["regoin"], {"region", "product"}, "dimension")
+        assert len(errors) == 1
+        assert errors[0].startswith("Unknown dimension: 'regoin'")
+        assert "Did you mean: region" in errors[0]
+
+    def test_multiple_unknown_names_produce_multiple_errors(self):
+        """Should produce one error per unknown name."""
+        errors = validate_names(["revenue", "bogus_metric"], {"revenue"}, 
"metric")
+        assert len(errors) == 1
+        assert "bogus_metric" in errors[0]
+
+    def test_error_message_uses_provided_kind(self):

Review Comment:
   Fixed — added `-> None` to all test methods in this class, matching the 
convention already used elsewhere (e.g. `test_get_table.py`).



##########
tests/unit_tests/mcp_service/semantic_layer/tool/test_get_table.py:
##########
@@ -0,0 +1,263 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the get_table MCP tool."""
+
+from __future__ import annotations
+
+import importlib
+from collections.abc import Generator
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client, FastMCP
+
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+get_table_module = importlib.import_module(
+    "superset.mcp_service.semantic_layer.tool.get_table"
+)

Review Comment:
   Fixed — annotated as `ModuleType` (from `types`).



##########
tests/unit_tests/mcp_service/semantic_layer/tool/test_get_compatible_metrics.py:
##########
@@ -0,0 +1,222 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the get_compatible_metrics MCP tool."""
+
+from __future__ import annotations
+
+import importlib
+from collections.abc import Generator
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client, FastMCP
+
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+get_compatible_metrics_module = importlib.import_module(
+    "superset.mcp_service.semantic_layer.tool.get_compatible_metrics"
+)

Review Comment:
   Fixed — annotated as `ModuleType` (from `types`).



##########
superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py:
##########
@@ -0,0 +1,227 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""MCP tool: get_compatible_dimensions
+
+Returns dimensions compatible with the current metric/dimension selection.
+"""
+
+import logging
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.privacy import (
+    DATA_MODEL_METADATA_ERROR_TYPE,
+    requires_data_model_metadata_access,
+    user_can_view_data_model_metadata,
+)
+from superset.mcp_service.semantic_layer.schemas import (
+    CompatibleDimensionsResponse,
+    DimensionInfo,
+    GetCompatibleDimensionsRequest,
+    SemanticLayerError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["data", "semantic"],
+    class_permission_name="Dataset",
+    annotations=ToolAnnotations(
+        title="Get compatible dimensions",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+@requires_data_model_metadata_access
+async def get_compatible_dimensions(
+    request: GetCompatibleDimensionsRequest,
+    ctx: Context,
+) -> CompatibleDimensionsResponse | SemanticLayerError:
+    """Return dimensions compatible with the current metric/dimension 
selection.
+
+    Used to drive progressive disclosure in query builders: after the user
+    selects one or more metrics (and optionally some dimensions), this tool
+    returns the dimensions that can validly be added without breaking the
+    underlying query.
+
+    Provide exactly one of ``dataset_id`` (built-in) or ``view_id`` (external).
+
+    For built-in datasets, returns all groupby-enabled columns from the 
dataset.
+    SQL datasets have no semantic compatibility constraints between metrics and
+    dimensions, so all groupby columns are always returned regardless of the
+    selected metrics.
+
+    For external semantic views, delegates to the view's
+    ``get_compatible_dimensions`` implementation.
+
+    Example:
+    ```json
+    {
+        "selected_metrics": ["revenue"],
+        "selected_dimensions": [],
+        "view_id": 5
+    }
+    ```
+    """
+    await ctx.info(
+        "Getting compatible dimensions: dataset_id=%s, view_id=%s, "
+        "metrics=%s, dims=%s"
+        % (
+            request.dataset_id,
+            request.view_id,
+            request.selected_metrics,
+            request.selected_dimensions,
+        )
+    )
+
+    if not user_can_view_data_model_metadata():
+        return SemanticLayerError.create(
+            error="You don't have permission to access dataset details for 
your role.",
+            error_type=DATA_MODEL_METADATA_ERROR_TYPE,
+        )
+
+    if request.dataset_id is None and request.view_id is None:
+        return SemanticLayerError.create(
+            error="Provide either dataset_id (built-in) or view_id 
(external).",
+            error_type="ValidationError",
+        )
+    if request.dataset_id is not None and request.view_id is not None:
+        return SemanticLayerError.create(
+            error="Provide only one of dataset_id or view_id, not both.",
+            error_type="ValidationError",
+        )
+
+    try:
+        # ------------------------------------------------------------------
+        # Built-in dataset path
+        # ------------------------------------------------------------------
+        if request.dataset_id is not None:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+            from superset.daos.dataset import DatasetDAO
+
+            dataset_id: int = request.dataset_id
+            with event_logger.log_context(
+                action="mcp.get_compatible_dimensions.builtin"
+            ):
+                dataset = DatasetDAO.find_by_id(
+                    dataset_id,
+                    query_options=[
+                        subqueryload(SqlaTable.columns),
+                        subqueryload(SqlaTable.metrics),
+                    ],
+                )
+
+            if dataset is None:
+                return SemanticLayerError.create(
+                    error=f"No dataset found with id: {request.dataset_id}.",
+                    error_type="NotFound",
+                )
+
+            # For built-in datasets all groupby columns are always compatible;
+            # there's no per-metric compatibility constraint at the SQL level.
+            dims = [
+                DimensionInfo(
+                    name=col.column_name,
+                    verbose_name=col.verbose_name or None,
+                    description=col.description or None,
+                    type=col.type or None,
+                    is_dttm=bool(col.is_dttm),
+                    groupby=bool(col.groupby),
+                    filterable=bool(col.filterable),
+                    source="builtin",
+                )
+                for col in dataset.columns
+                if col.groupby
+            ]
+
+            await ctx.info("Compatible dimensions (builtin): count=%d" % 
len(dims))
+            return CompatibleDimensionsResponse(
+                compatible_dimensions=dims,
+                source="builtin",
+            )
+
+        # ------------------------------------------------------------------
+        # External semantic view path
+        # ------------------------------------------------------------------
+        from superset.daos.semantic_layer import SemanticViewDAO
+        from superset.exceptions import SupersetSecurityException
+
+        view_id: int = request.view_id  # type: ignore[assignment]
+        with 
event_logger.log_context(action="mcp.get_compatible_dimensions.external"):
+            view = SemanticViewDAO.find_by_id(view_id)
+
+        if view is None:
+            return SemanticLayerError.create(
+                error=f"No semantic view found with id: {view_id}.",
+                error_type="NotFound",
+            )
+
+        try:
+            view.raise_for_access()
+        except SupersetSecurityException as ex:
+            return SemanticLayerError.create(
+                error=str(ex.error.message),
+                error_type="AccessDenied",
+            )
+
+        compatible_names = view.get_compatible_dimensions(
+            request.selected_metrics,
+            request.selected_dimensions,
+        )
+
+        # Enrich with full column metadata
+        all_cols = {col.column_name: col for col in view.columns}

Review Comment:
   Fixed — annotated as `all_cols: dict[str, ColumnMetadata]`, importing 
`ColumnMetadata` from `superset.semantic_layers.models`.



##########
superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py:
##########
@@ -0,0 +1,227 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""MCP tool: get_compatible_dimensions
+
+Returns dimensions compatible with the current metric/dimension selection.
+"""
+
+import logging
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.privacy import (
+    DATA_MODEL_METADATA_ERROR_TYPE,
+    requires_data_model_metadata_access,
+    user_can_view_data_model_metadata,
+)
+from superset.mcp_service.semantic_layer.schemas import (
+    CompatibleDimensionsResponse,
+    DimensionInfo,
+    GetCompatibleDimensionsRequest,
+    SemanticLayerError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["data", "semantic"],
+    class_permission_name="Dataset",
+    annotations=ToolAnnotations(
+        title="Get compatible dimensions",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+@requires_data_model_metadata_access
+async def get_compatible_dimensions(
+    request: GetCompatibleDimensionsRequest,
+    ctx: Context,
+) -> CompatibleDimensionsResponse | SemanticLayerError:
+    """Return dimensions compatible with the current metric/dimension 
selection.
+
+    Used to drive progressive disclosure in query builders: after the user
+    selects one or more metrics (and optionally some dimensions), this tool
+    returns the dimensions that can validly be added without breaking the
+    underlying query.
+
+    Provide exactly one of ``dataset_id`` (built-in) or ``view_id`` (external).
+
+    For built-in datasets, returns all groupby-enabled columns from the 
dataset.
+    SQL datasets have no semantic compatibility constraints between metrics and
+    dimensions, so all groupby columns are always returned regardless of the
+    selected metrics.
+
+    For external semantic views, delegates to the view's
+    ``get_compatible_dimensions`` implementation.
+
+    Example:
+    ```json
+    {
+        "selected_metrics": ["revenue"],
+        "selected_dimensions": [],
+        "view_id": 5
+    }
+    ```
+    """
+    await ctx.info(
+        "Getting compatible dimensions: dataset_id=%s, view_id=%s, "
+        "metrics=%s, dims=%s"
+        % (
+            request.dataset_id,
+            request.view_id,
+            request.selected_metrics,
+            request.selected_dimensions,
+        )
+    )
+
+    if not user_can_view_data_model_metadata():
+        return SemanticLayerError.create(
+            error="You don't have permission to access dataset details for 
your role.",
+            error_type=DATA_MODEL_METADATA_ERROR_TYPE,
+        )
+
+    if request.dataset_id is None and request.view_id is None:
+        return SemanticLayerError.create(
+            error="Provide either dataset_id (built-in) or view_id 
(external).",
+            error_type="ValidationError",
+        )
+    if request.dataset_id is not None and request.view_id is not None:
+        return SemanticLayerError.create(
+            error="Provide only one of dataset_id or view_id, not both.",
+            error_type="ValidationError",
+        )
+
+    try:
+        # ------------------------------------------------------------------
+        # Built-in dataset path
+        # ------------------------------------------------------------------
+        if request.dataset_id is not None:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+            from superset.daos.dataset import DatasetDAO
+
+            dataset_id: int = request.dataset_id
+            with event_logger.log_context(
+                action="mcp.get_compatible_dimensions.builtin"
+            ):
+                dataset = DatasetDAO.find_by_id(
+                    dataset_id,
+                    query_options=[
+                        subqueryload(SqlaTable.columns),
+                        subqueryload(SqlaTable.metrics),
+                    ],
+                )
+
+            if dataset is None:
+                return SemanticLayerError.create(
+                    error=f"No dataset found with id: {request.dataset_id}.",
+                    error_type="NotFound",
+                )
+
+            # For built-in datasets all groupby columns are always compatible;
+            # there's no per-metric compatibility constraint at the SQL level.
+            dims = [
+                DimensionInfo(
+                    name=col.column_name,
+                    verbose_name=col.verbose_name or None,
+                    description=col.description or None,
+                    type=col.type or None,
+                    is_dttm=bool(col.is_dttm),
+                    groupby=bool(col.groupby),
+                    filterable=bool(col.filterable),
+                    source="builtin",
+                )
+                for col in dataset.columns
+                if col.groupby
+            ]
+
+            await ctx.info("Compatible dimensions (builtin): count=%d" % 
len(dims))
+            return CompatibleDimensionsResponse(
+                compatible_dimensions=dims,
+                source="builtin",
+            )
+
+        # ------------------------------------------------------------------
+        # External semantic view path
+        # ------------------------------------------------------------------
+        from superset.daos.semantic_layer import SemanticViewDAO
+        from superset.exceptions import SupersetSecurityException
+
+        view_id: int = request.view_id  # type: ignore[assignment]
+        with 
event_logger.log_context(action="mcp.get_compatible_dimensions.external"):
+            view = SemanticViewDAO.find_by_id(view_id)
+
+        if view is None:
+            return SemanticLayerError.create(
+                error=f"No semantic view found with id: {view_id}.",
+                error_type="NotFound",
+            )
+
+        try:
+            view.raise_for_access()
+        except SupersetSecurityException as ex:
+            return SemanticLayerError.create(
+                error=str(ex.error.message),
+                error_type="AccessDenied",
+            )
+
+        compatible_names = view.get_compatible_dimensions(
+            request.selected_metrics,
+            request.selected_dimensions,
+        )

Review Comment:
   Fixed — annotated as `compatible_names: list[str]`, matching 
`SemanticView.get_compatible_dimensions`'s return type.



##########
tests/unit_tests/mcp_service/semantic_layer/tool/test_list_metrics.py:
##########
@@ -0,0 +1,183 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the list_metrics MCP tool."""
+
+from __future__ import annotations
+
+import importlib
+from collections.abc import Generator
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client, FastMCP
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+list_metrics_module = importlib.import_module(
+    "superset.mcp_service.semantic_layer.tool.list_metrics"
+)

Review Comment:
   Fixed — annotated as `ModuleType` (from `types`).



##########
superset/mcp_service/semantic_layer/tool/list_metrics.py:
##########
@@ -0,0 +1,294 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""MCP tool: list_metrics
+
+Unified metric discovery across built-in datasets and external semantic views.
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.daos.dataset import DatasetDAO
+from superset.daos.semantic_layer import SemanticViewDAO
+from superset.extensions import db, event_logger
+from superset.mcp_service.privacy import (
+    DATA_MODEL_METADATA_ERROR_TYPE,
+    requires_data_model_metadata_access,
+    user_can_view_data_model_metadata,
+)
+from superset.mcp_service.semantic_layer.schemas import (
+    DimensionInfo,
+    ListMetricsRequest,
+    MetricInfo,
+    MetricList,
+    SemanticLayerError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _matches_search(text: str | None, search: str) -> bool:
+    if not text:
+        return False
+    return search.lower() in text.lower()
+
+
+def _builtin_compatible_dims(dataset: Any) -> list[DimensionInfo]:
+    """Return groupby-enabled columns as compatible dimensions for a built-in 
metric."""
+    return [
+        DimensionInfo(
+            name=col.column_name,
+            verbose_name=col.verbose_name or None,
+            description=col.description or None,
+            type=col.type or None,
+            is_dttm=bool(col.is_dttm),
+            groupby=bool(col.groupby),
+            filterable=bool(col.filterable),
+            source="builtin",
+        )
+        for col in dataset.columns
+        if col.groupby
+    ]
+
+
+def _collect_builtin_metrics(request: ListMetricsRequest) -> list[MetricInfo]:
+    """Collect metrics from built-in SqlaTable datasets."""
+    from sqlalchemy.orm import subqueryload
+
+    from superset.connectors.sqla.models import SqlaTable
+
+    with event_logger.log_context(action="mcp.list_metrics.builtin_query"):
+        if request.dataset_id is not None:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+
+            dataset = DatasetDAO.find_by_id(
+                request.dataset_id,
+                query_options=[
+                    subqueryload(SqlaTable.columns),
+                    subqueryload(SqlaTable.metrics),
+                ],
+            )

Review Comment:
   Fixed — annotated as `dataset: SqlaTable | None`.



##########
superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py:
##########
@@ -0,0 +1,227 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""MCP tool: get_compatible_dimensions
+
+Returns dimensions compatible with the current metric/dimension selection.
+"""
+
+import logging
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.privacy import (
+    DATA_MODEL_METADATA_ERROR_TYPE,
+    requires_data_model_metadata_access,
+    user_can_view_data_model_metadata,
+)
+from superset.mcp_service.semantic_layer.schemas import (
+    CompatibleDimensionsResponse,
+    DimensionInfo,
+    GetCompatibleDimensionsRequest,
+    SemanticLayerError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["data", "semantic"],
+    class_permission_name="Dataset",
+    annotations=ToolAnnotations(
+        title="Get compatible dimensions",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+@requires_data_model_metadata_access
+async def get_compatible_dimensions(
+    request: GetCompatibleDimensionsRequest,
+    ctx: Context,
+) -> CompatibleDimensionsResponse | SemanticLayerError:
+    """Return dimensions compatible with the current metric/dimension 
selection.
+
+    Used to drive progressive disclosure in query builders: after the user
+    selects one or more metrics (and optionally some dimensions), this tool
+    returns the dimensions that can validly be added without breaking the
+    underlying query.
+
+    Provide exactly one of ``dataset_id`` (built-in) or ``view_id`` (external).
+
+    For built-in datasets, returns all groupby-enabled columns from the 
dataset.
+    SQL datasets have no semantic compatibility constraints between metrics and
+    dimensions, so all groupby columns are always returned regardless of the
+    selected metrics.
+
+    For external semantic views, delegates to the view's
+    ``get_compatible_dimensions`` implementation.
+
+    Example:
+    ```json
+    {
+        "selected_metrics": ["revenue"],
+        "selected_dimensions": [],
+        "view_id": 5
+    }
+    ```
+    """
+    await ctx.info(
+        "Getting compatible dimensions: dataset_id=%s, view_id=%s, "
+        "metrics=%s, dims=%s"
+        % (
+            request.dataset_id,
+            request.view_id,
+            request.selected_metrics,
+            request.selected_dimensions,
+        )
+    )
+
+    if not user_can_view_data_model_metadata():
+        return SemanticLayerError.create(
+            error="You don't have permission to access dataset details for 
your role.",
+            error_type=DATA_MODEL_METADATA_ERROR_TYPE,
+        )
+
+    if request.dataset_id is None and request.view_id is None:
+        return SemanticLayerError.create(
+            error="Provide either dataset_id (built-in) or view_id 
(external).",
+            error_type="ValidationError",
+        )
+    if request.dataset_id is not None and request.view_id is not None:
+        return SemanticLayerError.create(
+            error="Provide only one of dataset_id or view_id, not both.",
+            error_type="ValidationError",
+        )
+
+    try:
+        # ------------------------------------------------------------------
+        # Built-in dataset path
+        # ------------------------------------------------------------------
+        if request.dataset_id is not None:
+            from sqlalchemy.orm import subqueryload
+
+            from superset.connectors.sqla.models import SqlaTable
+            from superset.daos.dataset import DatasetDAO
+
+            dataset_id: int = request.dataset_id
+            with event_logger.log_context(
+                action="mcp.get_compatible_dimensions.builtin"
+            ):
+                dataset = DatasetDAO.find_by_id(
+                    dataset_id,
+                    query_options=[
+                        subqueryload(SqlaTable.columns),
+                        subqueryload(SqlaTable.metrics),
+                    ],
+                )
+
+            if dataset is None:
+                return SemanticLayerError.create(
+                    error=f"No dataset found with id: {request.dataset_id}.",
+                    error_type="NotFound",
+                )
+
+            # For built-in datasets all groupby columns are always compatible;
+            # there's no per-metric compatibility constraint at the SQL level.
+            dims = [
+                DimensionInfo(
+                    name=col.column_name,
+                    verbose_name=col.verbose_name or None,
+                    description=col.description or None,
+                    type=col.type or None,
+                    is_dttm=bool(col.is_dttm),
+                    groupby=bool(col.groupby),
+                    filterable=bool(col.filterable),
+                    source="builtin",
+                )
+                for col in dataset.columns
+                if col.groupby
+            ]

Review Comment:
   Fixed — annotated as `dims: list[DimensionInfo]`.



##########
superset/mcp_service/dataset/tool/query_dataset.py:
##########
@@ -379,44 +360,7 @@ async def query_dataset(  # noqa: C901
                 warnings=warnings,
             )
 
-        # Build column metadata in a single pass per column.
-        # Cap stats computation at STATS_SAMPLE rows to avoid O(rows*cols)
-        # overhead on large result sets (row_limit allows up to 50k).
-        stats_sample_size = 5000
-        stats_rows = data[:stats_sample_size]
-
-        columns_meta: list[DataColumn] = []
-        for col_name in raw_columns:
-            sample_values = [
-                row.get(col_name) for row in data[:3] if row.get(col_name) is 
not None
-            ]
-            data_type = "string"
-            if sample_values:
-                if all(isinstance(v, bool) for v in sample_values):
-                    data_type = "boolean"
-                elif all(isinstance(v, (int, float)) for v in sample_values):
-                    data_type = "numeric"
-
-            # Compute null_count and unique non-null values in one pass
-            null_count = 0
-            unique_vals: set[str] = set()
-            for row in stats_rows:
-                val = row.get(col_name)
-                if val is None:
-                    null_count += 1
-                else:
-                    unique_vals.add(str(val))
-
-            columns_meta.append(
-                DataColumn(
-                    name=col_name,
-                    display_name=col_name.replace("_", " ").title(),
-                    data_type=data_type,
-                    sample_values=sample_values[:3],
-                    null_count=null_count,
-                    unique_count=len(unique_vals),
-                )
-            )
+        columns_meta = format_data_columns(data, raw_columns)

Review Comment:
   Fixed — added `columns_meta: list[DataColumn]` (importing `DataColumn` from 
`superset.mcp_service.chart.schemas`, already imported alongside 
`PerformanceMetadata`).



##########
tests/unit_tests/mcp_service/semantic_layer/tool/test_get_compatible_dimensions.py:
##########
@@ -0,0 +1,231 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the get_compatible_dimensions MCP tool."""
+
+from __future__ import annotations
+
+import importlib
+from collections.abc import Generator
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client, FastMCP
+
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+get_compatible_dimensions_module = importlib.import_module(
+    "superset.mcp_service.semantic_layer.tool.get_compatible_dimensions"
+)

Review Comment:
   Fixed — annotated as `ModuleType` (from `types`).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to