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()