You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/09/18 18:12:33 UTC

(superset) branch master updated: fix(mcp): keep the default list_metrics page under the response-token cap (#44266)

This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new b7f1bdd571c fix(mcp): keep the default list_metrics page under the response-token cap (#44266)
b7f1bdd571c is described below

commit b7f1bdd571cd37040dd0367619f1a8ce4d1d2637
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Fri Sep 18 12:12:26 2026 -0600

    fix(mcp): keep the default list_metrics page under the response-token cap (#44266)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    Co-authored-by: Claude Fable 5 <no...@anthropic.com>
---
 UPDATING.md                                        |  11 +
 superset/mcp_service/app.py                        |   5 +-
 superset/mcp_service/semantic_layer/schemas.py     |  34 ++-
 .../mcp_service/semantic_layer/tool/get_table.py   |   5 +-
 .../semantic_layer/tool/list_metrics.py            |   9 +-
 superset/semantic_layers/models.py                 |   4 +-
 .../semantic_layer/tool/test_list_metrics.py       | 235 ++++++++++++++++++++-
 tests/unit_tests/semantic_layers/models_test.py    |  32 +++
 8 files changed, 320 insertions(+), 15 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index ca62d1dec13..920ff55248e 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -52,6 +52,17 @@ behavior explicitly:
 SQLALCHEMY_ENGINE_OPTIONS = {"isolation_level": "REPEATABLE READ"}
 ```
 
+### MCP metric discovery defaults and embedded dimensions
+
+`list_metrics` defaults to 25 metrics per page and does not embed compatible
+dimensions. Clients needing those dimensions should call `get_compatible_dimensions`
+for the chosen metrics, or explicitly request `include_compatible_dimensions=true`
+with `page_size` at most 8 for every scope, including built-in `dataset_id`
+requests. Non-embedded requests retain the 500-metric ceiling.
+This fixed embedding cap is independent of the operator's
+`MCP_RESPONSE_SIZE_CONFIG['token_limit']` (25,000 by default); it does not guarantee
+that every payload fits a configured response limit.
+
 ### Default Docker image is now batteries-included; the minimal image moves to `-lean`
 
 The default `apache/superset` Docker image (the plain tags: `latest`, `master`,
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index e1b51ececc5..81bc1a66c44 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -319,8 +319,9 @@ with 'search'.
 
 To explore metrics across all data sources (built-in datasets + external semantic views):
 1. list_metrics(request={{"search": "<keyword>"}})
-   -> returns metrics with dataset_id/view_id and compatible_dimensions inline
-2. get_table(request={{
+   -> returns metrics with dataset_id/view_id; dimensions are not embedded by default
+2. get_compatible_dimensions -> discover dimensions for the chosen metrics
+3. get_table(request={{
      "dataset_id": <id>,          # OR "view_id": <id> for external semantic views
      "metrics": ["revenue"],
      "dimensions": ["region"],
diff --git a/superset/mcp_service/semantic_layer/schemas.py b/superset/mcp_service/semantic_layer/schemas.py
index 50d113154c0..bc54f652f72 100644
--- a/superset/mcp_service/semantic_layer/schemas.py
+++ b/superset/mcp_service/semantic_layer/schemas.py
@@ -89,6 +89,12 @@ class MetricInfo(BaseModel):
 # ---------------------------------------------------------------------------
 
 
+# Measured with 40 dimensions per metric: 20x40 = 42,336 tokens;
+# 10x40 = 21,186; 8x40 = 16,956 (18,284 fallback), against the default 25,000 limit.
+# The fixed page cap is not a guarantee for every payload or operator token limit.
+EMBEDDED_DIMENSIONS_MAX_PAGE_SIZE: int = 8
+
+
 class ListMetricsRequest(BaseModel):
     """Request schema for list_metrics."""
 
@@ -105,17 +111,37 @@ class ListMetricsRequest(BaseModel):
         description="Filter to metrics from a specific semantic view.",
     )
     include_compatible_dimensions: bool = Field(
-        default=True,
+        default=False,
         description=(
-            "When True, each metric includes its list of compatible dimensions. "
-            "Set to False to reduce response size when dimensions aren't needed."
+            "Embed compatible dimensions only when explicitly requested. "
+            "Use get_compatible_dimensions for the full per-metric list. "
+            "When True, set page_size to at most 8, including built-in datasets."
         ),
     )
     page: int = Field(default=1, ge=1, description="1-based page number.")
     page_size: int = Field(
-        default=50, ge=1, le=500, description="Number of metrics per page."
+        default=25, ge=1, le=500, description="Number of metrics per page."
     )
 
+    @model_validator(mode="after")
+    def validate_embedded_dimensions_page_size(self) -> "ListMetricsRequest":
+        """Reject embedded pages that risk exceeding the MCP response guard."""
+        if (
+            self.include_compatible_dimensions
+            and self.page_size > EMBEDDED_DIMENSIONS_MAX_PAGE_SIZE
+        ):
+            raise ValueError(
+                "Embedded compatible dimensions require "
+                f"page_size <= {EMBEDDED_DIMENSIONS_MAX_PAGE_SIZE}: each "
+                "metric's dimension list can consume roughly 1–2k tokens or more, "
+                "and the MCP response guard uses "
+                "MCP_RESPONSE_SIZE_CONFIG['token_limit'] (~25k by default). "
+                "This fixed page cap does not guarantee that every response fits. "
+                "Reduce page_size or use include_compatible_dimensions=false "
+                "and get_compatible_dimensions for the chosen metric."
+            )
+        return self
+
 
 class MetricList(BaseModel):
     """Response schema for list_metrics."""
diff --git a/superset/mcp_service/semantic_layer/tool/get_table.py b/superset/mcp_service/semantic_layer/tool/get_table.py
index 0c70cc832c6..550bc7bcc14 100644
--- a/superset/mcp_service/semantic_layer/tool/get_table.py
+++ b/superset/mcp_service/semantic_layer/tool/get_table.py
@@ -536,8 +536,9 @@ async def get_table(
     relative expression. Additional filters can further constrain the range.
 
     Workflow:
-    1. list_metrics -> discover metrics and their compatible_dimensions
-    2. get_table -> query with chosen metrics and dimensions
+    1. list_metrics -> discover metrics
+    2. get_compatible_dimensions -> discover dimensions for the chosen metrics
+    3. get_table -> query with chosen metrics and dimensions
 
     Example (built-in):
     ```json
diff --git a/superset/mcp_service/semantic_layer/tool/list_metrics.py b/superset/mcp_service/semantic_layer/tool/list_metrics.py
index 1b060c27deb..eac1710f060 100644
--- a/superset/mcp_service/semantic_layer/tool/list_metrics.py
+++ b/superset/mcp_service/semantic_layer/tool/list_metrics.py
@@ -248,18 +248,19 @@ async def list_metrics(
 
     This is the primary entry point for semantic layer exploration. Returns a
     unified list of metrics from all data sources the current user can access,
-    with compatible dimensions included inline.
+    with compatible dimensions embedded only on request.
 
     Workflow:
-    1. list_metrics -> discover available metrics and their compatible dimensions
-    2. get_table -> query data using chosen metrics and dimensions
+    1. list_metrics -> discover available metrics
+    2. get_compatible_dimensions -> discover dimensions for the chosen metric
+    3. get_table -> query data using chosen metrics and dimensions
 
     Use ``search`` to filter by metric name or description. Use ``dataset_id``
     or ``view_id`` to scope to a specific data source.
 
     Example:
     ```json
-    {"search": "revenue", "include_compatible_dimensions": true, "page": 1}
+    {"search": "revenue", "page": 1}
     ```
     """
     if ctx is None:
diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py
index c75b1f77c9a..c0f81e34591 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -808,6 +808,8 @@ class SemanticView(AuditMixinNullable, Model):
 
         Translates string names to semantic-layer objects, delegates to the
         view implementation, and translates the result back to names.
+        Collapse grain variants into sorted unique names, also bounding
+        the shared list_metrics projection.
         """
         metric_map = {m.name: m for m in self.implementation.get_metrics()}
         dim_map = {d.name: d for d in self.implementation.get_dimensions()}
@@ -816,7 +818,7 @@ class SemanticView(AuditMixinNullable, Model):
         compatible = self.implementation.get_compatible_dimensions(
             sel_metrics, sel_dims
         )
-        return [d.name for d in compatible]
+        return sorted({d.name for d in compatible})
 
 
 sa.event.listen(SemanticLayer, "after_insert", SemanticLayer.after_insert)
diff --git a/tests/unit_tests/mcp_service/semantic_layer/tool/test_list_metrics.py b/tests/unit_tests/mcp_service/semantic_layer/tool/test_list_metrics.py
index 4777d203a0d..27589a2e061 100644
--- a/tests/unit_tests/mcp_service/semantic_layer/tool/test_list_metrics.py
+++ b/tests/unit_tests/mcp_service/semantic_layer/tool/test_list_metrics.py
@@ -22,17 +22,28 @@ from __future__ import annotations
 import contextlib
 import importlib
 from collections.abc import Generator
-from types import ModuleType
+from types import MethodType, ModuleType
 from typing import Any
 from unittest.mock import call, MagicMock, Mock, patch
 
+import pyarrow as pa
 import pytest
 from fastmcp import Client, FastMCP
 from fastmcp.exceptions import ToolError
+from pydantic import ValidationError
+from superset_core.semantic_layers.types import Dimension, Grains
 
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import SupersetSecurityException
 from superset.mcp_service.app import mcp
+from superset.mcp_service.constants import DEFAULT_TOKEN_LIMIT
+from superset.mcp_service.middleware import (
+    create_response_size_guard_middleware,
+    ResponseSizeGuardMiddleware,
+)
+from superset.mcp_service.semantic_layer.schemas import ListMetricsRequest
+from superset.mcp_service.utils.token_utils import estimate_response_tokens
+from superset.semantic_layers.models import SemanticView
 from superset.utils import json
 
 list_metrics_module: ModuleType = importlib.import_module(
@@ -105,6 +116,143 @@ def _make_view(view_id: int = 5) -> MagicMock:
     return view
 
 
+@pytest.fixture
+def large_metric_catalog() -> Generator[MagicMock, None, None]:
+    """Expose a large catalog through the real dimension-name projection."""
+    view: MagicMock = _make_view(5)
+    view.metrics = [_make_metric(f"metric_{i}") for i in range(60)]
+    view.columns = [_make_column(f"dimension_{i}") for i in range(40)]
+    dimensions: set[Dimension] = {
+        Dimension(
+            id=f"dimension_{i}",
+            name=f"dimension_{i}",
+            type=pa.timestamp("us"),
+            definition=f"dimension_{i}",
+            grain=grain,
+        )
+        for i in range(40)
+        for grain in (
+            Grains.HOUR,
+            Grains.DAY,
+            Grains.WEEK,
+            Grains.MONTH,
+            Grains.QUARTER,
+            Grains.YEAR,
+        )
+    }
+    view.implementation.get_dimensions.return_value = dimensions
+    view.implementation.get_metrics.return_value = []
+    view.implementation.get_compatible_dimensions.return_value = dimensions
+    view.get_compatible_dimensions.side_effect = MethodType(
+        SemanticView.get_compatible_dimensions, view
+    )
+    dataset: MagicMock = _make_dataset(1)
+    dataset.metrics = [_make_metric(f"builtin_{i}") for i in range(20)]
+    dataset.columns = [_make_column(f"column_{i}") for i in range(30)]
+    view_dao: MagicMock
+    with _patched_dataset_search([dataset]) as (_, view_dao, _):
+        view_dao.find_accessible.return_value = [view]
+        view_dao.find_by_id.return_value = view
+        yield view
+
+
+@pytest.mark.asyncio
+async def test_list_metrics_default_page_token_bound(
+    mcp_server: FastMCP,
+    large_metric_catalog: MagicMock,
+) -> None:
+    """Default discovery leaves token headroom without embedded dimensions."""
+    async with Client(mcp_server) as client:
+        data: dict[str, Any] = json.loads(
+            (await client.call_tool("list_metrics", {})).content[0].text
+        )
+    assert data["success"] is True
+    assert data["total_count"] == 80
+    assert estimate_response_tokens(data) <= DEFAULT_TOKEN_LIMIT // 2
+    assert data["page_size"] == 25
+    assert len(data["metrics"]) == 25
+    assert all(metric["compatible_dimensions"] == [] for metric in data["metrics"])
+    large_metric_catalog.get_compatible_dimensions.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_list_metrics_embedded_page_token_bound(
+    mcp_server: FastMCP,
+    large_metric_catalog: MagicMock,
+) -> None:
+    """The largest embedded page fits after grain variants are collapsed."""
+    async with Client(mcp_server) as client:
+        data: dict[str, Any] = json.loads(
+            (
+                await client.call_tool(
+                    "list_metrics",
+                    {
+                        "request": {
+                            "view_id": 5,
+                            "include_compatible_dimensions": True,
+                            "page_size": 8,
+                        }
+                    },
+                )
+            )
+            .content[0]
+            .text
+        )
+    assert data["success"] is True
+    assert len(data["metrics"]) == data["page_size"] == 8
+    assert data["total_count"] == 60
+    metric: dict[str, Any]
+    for metric in data["metrics"]:
+        assert len(metric["compatible_dimensions"]) == 40
+        assert len({dim["name"] for dim in metric["compatible_dimensions"]}) == 40
+    assert estimate_response_tokens(data) < DEFAULT_TOKEN_LIMIT
+
+
+@pytest.mark.asyncio
+async def test_list_metrics_embedded_page_over_cap_rejected(
+    mcp_server: FastMCP,
+) -> None:
+    """Oversized embedded pages fail validation with a recovery suggestion."""
+    async with Client(mcp_server) as client:
+        with pytest.raises(ToolError, match="get_compatible_dimensions"):
+            await client.call_tool(
+                "list_metrics",
+                {
+                    "request": {
+                        "include_compatible_dimensions": True,
+                        "page_size": 9,
+                    }
+                },
+            )
+
+
+@pytest.mark.parametrize("token_limit", [10000, 50000])
+def test_embedding_cap_is_independent_of_configured_token_limit(
+    token_limit: int,
+) -> None:
+    """The guard honors overrides without changing the fixed embedding cap."""
+    app: MagicMock = MagicMock()
+    app.config = {"MCP_RESPONSE_SIZE_CONFIG": {"token_limit": token_limit}}
+    with patch("superset.mcp_service.flask_singleton.get_flask_app", return_value=app):
+        guard: ResponseSizeGuardMiddleware | None = (
+            create_response_size_guard_middleware()
+        )
+    assert guard is not None
+    assert guard.token_limit == token_limit
+    assert (
+        ListMetricsRequest(include_compatible_dimensions=True, page_size=8).page_size
+        == 8
+    )
+    error: pytest.ExceptionInfo[ValidationError]
+    with pytest.raises(ValidationError) as error:
+        ListMetricsRequest(include_compatible_dimensions=True, page_size=9)
+    message: str = str(error.value)
+    assert "MCP_RESPONSE_SIZE_CONFIG['token_limit']" in message
+    assert "25k by default" in message
+    assert "page_size <= 8" in message
+    assert "get_compatible_dimensions" in message
+
+
 def _access_denied_exc(message: str = "Access denied") -> SupersetSecurityException:
     return SupersetSecurityException(
         SupersetError(
@@ -290,7 +438,13 @@ async def test_list_metrics_external_per_metric_compatible_dimensions(
         async with Client(mcp_server) as client:
             result = await client.call_tool(
                 "list_metrics",
-                {"request": {"view_id": 5, "include_compatible_dimensions": True}},
+                {
+                    "request": {
+                        "view_id": 5,
+                        "include_compatible_dimensions": True,
+                        "page_size": 8,
+                    }
+                },
             )
         data = json.loads(result.content[0].text)
 
@@ -476,6 +630,83 @@ async def test_list_metrics_page_size_over_max_rejected(mcp_server: FastMCP) ->
             await client.call_tool("list_metrics", {"request": {"page_size": 501}})
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize("page_size", [9, 25, 500])
+async def test_builtin_embedded_metrics_reject_oversized_pages(
+    mcp_server: FastMCP, page_size: int
+) -> None:
+    """Dataset scoping must not bypass the embedded page cap."""
+    mock_ds: MagicMock = _make_dataset(42)
+    with _patched_dataset_lookup(mock_ds):
+        async with Client(mcp_server) as client:
+            with pytest.raises(ToolError, match="page_size <= 8"):
+                await client.call_tool(
+                    "list_metrics",
+                    {
+                        "request": {
+                            "dataset_id": 42,
+                            "page_size": page_size,
+                            "include_compatible_dimensions": True,
+                        }
+                    },
+                )
+
+
+@pytest.mark.asyncio
+async def test_builtin_embedded_metrics_realistic_token_bound(
+    mcp_server: FastMCP,
+) -> None:
+    """Twenty metrics with thirty columns require bounded, lossless pages."""
+    dataset: MagicMock = _make_dataset(42)
+    dataset.metrics = [_make_metric(f"builtin_{i}") for i in range(20)]
+    dataset.columns = [_make_column(f"column_{i}") for i in range(30)]
+    names: list[str] = []
+    page: int
+    metric: dict[str, Any]
+    with _patched_dataset_lookup(dataset):
+        async with Client(mcp_server) as client:
+            with pytest.raises(ToolError, match="page_size <= 8"):
+                await client.call_tool(
+                    "list_metrics",
+                    {
+                        "request": {
+                            "dataset_id": 42,
+                            "include_compatible_dimensions": True,
+                        }
+                    },
+                )
+            for page in (1, 2, 3):
+                result: Any = await client.call_tool(
+                    "list_metrics",
+                    {
+                        "request": {
+                            "dataset_id": 42,
+                            "include_compatible_dimensions": True,
+                            "page_size": 8,
+                            "page": page,
+                        }
+                    },
+                )
+                data: dict[str, Any] = json.loads(result.content[0].text)
+                assert data["success"] is True
+                assert data["total_count"] == 20
+                assert data["total_pages"] == 3
+                assert data["page_size"] == 8
+                assert len(data["metrics"]) == (4 if page == 3 else 8)
+                assert estimate_response_tokens(data) < DEFAULT_TOKEN_LIMIT
+                for metric in data["metrics"]:
+                    assert len(metric["compatible_dimensions"]) == 30
+                    names.append(metric["name"])
+    assert len(names) == len(set(names)) == 20
+
+
+@pytest.mark.parametrize("scope", [{}, {"view_id": 42}])
+def test_external_embedded_metrics_retain_page_ceiling(scope: dict[str, int]) -> None:
+    """Unscoped and view-scoped requests can carry external dimensions."""
+    with pytest.raises(ValidationError, match="page_size <= 8"):
+        ListMetricsRequest(**scope, include_compatible_dimensions=True, page_size=9)
+
+
 @pytest.mark.asyncio
 async def test_list_metrics_page_size_at_max_accepted(mcp_server: FastMCP) -> None:
     """page_size == 500 (the max) is accepted and echoed back."""
diff --git a/tests/unit_tests/semantic_layers/models_test.py b/tests/unit_tests/semantic_layers/models_test.py
index 1d92e012888..ff201c469e9 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -1176,6 +1176,38 @@ def test_semantic_view_get_compatible_dimensions(
 # =============================================================================
 
 
+def test_semantic_view_compatible_dimensions_collapse_grains(
+    mock_implementation: MagicMock,
+) -> None:
+    """Return sorted unique names regardless of grain-variant encounter order."""
+    variants: list[Dimension] = [
+        Dimension(
+            id="orders.created_at",
+            name="created_at",
+            type=pa.timestamp("us"),
+            definition="orders.created_at",
+            grain=grain,
+        )
+        for grain in (Grains.DAY, Grains.MONTH, Grains.YEAR)
+    ]
+    category: Dimension = Dimension(
+        id="category", name="category", type=pa.utf8(), definition="category"
+    )
+    view: SemanticView = SemanticView()
+    mock_implementation.get_dimensions.return_value = set(variants + [category])
+    mock_implementation.get_compatible_dimensions.side_effect = [
+        set(variants + [category]),
+        set([category] + list(reversed(variants))),
+    ]
+    with patch.object(
+        SemanticView,
+        "implementation",
+        new_callable=lambda: property(lambda s: mock_implementation),
+    ):
+        assert view.get_compatible_dimensions([], []) == ["category", "created_at"]
+        assert view.get_compatible_dimensions([], []) == ["category", "created_at"]
+
+
 def test_semantic_layer_get_perm() -> None:
     """Test SemanticLayer.get_perm() format."""
     layer = SemanticLayer()