This is an automated email from the ASF dual-hosted git repository.
msyavuz 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 2fac66d1a30 feat(mcp): add update_dataset_metric tool for editing
saved dataset metrics (#40975)
2fac66d1a30 is described below
commit 2fac66d1a308036c60203593afb913d5a0593420
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Mon Jul 13 15:44:57 2026 +0300
feat(mcp): add update_dataset_metric tool for editing saved dataset metrics
(#40975)
---
superset/mcp_service/app.py | 6 +-
superset/mcp_service/dataset/dataset_utils.py | 50 ++
superset/mcp_service/dataset/schemas.py | 151 ++++++
superset/mcp_service/dataset/tool/__init__.py | 2 +
superset/mcp_service/dataset/tool/query_dataset.py | 30 +-
.../dataset/tool/update_dataset_metric.py | 294 +++++++++++
.../mcp_service/dataset/tool/test_query_dataset.py | 32 +-
.../dataset/tool/test_update_dataset_metric.py | 541 +++++++++++++++++++++
8 files changed, 1060 insertions(+), 46 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 9fb597f66a5..017909d3d12 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -175,6 +175,7 @@ Dataset Management:
- get_dataset_info: Get detailed dataset information by ID (includes
columns/metrics)
- create_dataset: Register a physical table as a dataset against an existing
DB connection (requires write access)
- create_virtual_dataset: Save a SQL query as a virtual dataset for charting
(requires write access)
+- update_dataset_metric: Update a saved metric on a dataset — expression,
name, verbose_name, format (requires dataset ownership)
- query_dataset: Query a dataset using its semantic layer (saved metrics,
dimensions, filters) without needing a saved chart
Chart Management:
@@ -463,8 +464,8 @@ Input format:
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting
write operations (creating datasets,
charts, or dashboards). SQL execution is a separate permission — see
execute_sql below.
- Write tools (generate_chart, generate_dashboard, update_chart,
duplicate_dashboard,
- create_dataset, create_virtual_dataset, save_sql_query,
add_chart_to_existing_dashboard,
- manage_native_filters, remove_chart_from_dashboard,
+ create_dataset, create_virtual_dataset, update_dataset_metric,
save_sql_query,
+ add_chart_to_existing_dashboard, manage_native_filters,
remove_chart_from_dashboard,
update_chart_preview) require write
permissions. These tools are only listed for users who have the necessary
access.
If a write tool does not appear in the tool list, the current user lacks
write access.
@@ -754,6 +755,7 @@ from superset.mcp_service.dataset.tool import ( # noqa:
F401, E402
get_dataset_info,
list_datasets,
query_dataset,
+ update_dataset_metric,
)
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
generate_explore_link,
diff --git a/superset/mcp_service/dataset/dataset_utils.py
b/superset/mcp_service/dataset/dataset_utils.py
new file mode 100644
index 00000000000..9a1a14fbb1b
--- /dev/null
+++ b/superset/mcp_service/dataset/dataset_utils.py
@@ -0,0 +1,50 @@
+# 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.
+
+"""Shared helpers for dataset MCP tools."""
+
+from typing import Any
+
+from superset.mcp_service.utils import _is_uuid
+
+
+def resolve_dataset(
+ identifier: int | str, eager_options: list[Any] | None = None
+) -> Any | None:
+ """Resolve a dataset by int ID or UUID string.
+
+ Replicates the identifier resolution logic from
ModelGetInfoCore._find_object().
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ opts = eager_options or None
+
+ if isinstance(identifier, int):
+ return DatasetDAO.find_by_id(identifier, query_options=opts)
+
+ # Try parsing as int
+ try:
+ id_val = int(identifier)
+ return DatasetDAO.find_by_id(id_val, query_options=opts)
+ except (ValueError, TypeError):
+ pass
+
+ # Try UUID
+ if _is_uuid(str(identifier)):
+ return DatasetDAO.find_by_id(identifier, id_column="uuid",
query_options=opts)
+
+ return None
diff --git a/superset/mcp_service/dataset/schemas.py
b/superset/mcp_service/dataset/schemas.py
index d469a753a72..adbf10cd210 100644
--- a/superset/mcp_service/dataset/schemas.py
+++ b/superset/mcp_service/dataset/schemas.py
@@ -607,6 +607,157 @@ class CreateVirtualDatasetResponse(BaseModel):
)
+UPDATABLE_METRIC_FIELDS: frozenset[str] = frozenset(
+ {
+ "metric_name",
+ "expression",
+ "verbose_name",
+ "description",
+ "d3format",
+ "metric_type",
+ "currency",
+ "warning_text",
+ "extra",
+ }
+)
+
+
+class MetricCurrency(BaseModel):
+ """Currency formatting configuration for a metric."""
+
+ symbol: str | None = Field(
+ None, description="ISO 4217 currency code (e.g. 'USD', 'EUR')."
+ )
+ symbolPosition: str | None = Field( # noqa: N815
+ None,
+ description="Where to render the symbol relative to the value "
+ "(typically 'prefix' or 'suffix').",
+ )
+
+
+class UpdateDatasetMetricRequest(BaseModel):
+ """Request schema for update_dataset_metric."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ metric: int | str = Field(
+ ...,
+ description="Metric to update — numeric metric ID, metric UUID, or "
+ "metric_name (e.g. 'sum_revenue'). Numeric strings are treated as IDs.
"
+ "Use get_dataset_info to discover a dataset's saved metrics.",
+ )
+ metric_name: str | None = Field(
+ None,
+ max_length=255,
+ description="New metric name, unique within the dataset. "
+ "Only pass this to rename the metric.",
+ )
+ expression: str | None = Field(
+ None,
+ description="New SQL aggregation expression (e.g. 'SUM(revenue)').",
+ )
+ verbose_name: str | None = Field(
+ None, max_length=1024, description="Human-friendly display label."
+ )
+ description: str | None = Field(None, description="Metric description.")
+ d3format: str | None = Field(
+ None,
+ min_length=1,
+ max_length=128,
+ description="D3 number format string (e.g. ',.2f', '.1%').",
+ )
+ metric_type: str | None = Field(
+ None,
+ min_length=1,
+ max_length=32,
+ description="Metric type (e.g. 'count', 'sum').",
+ )
+ currency: MetricCurrency | None = Field(
+ None, description="Currency formatting configuration."
+ )
+ warning_text: str | None = Field(
+ None, description="Warning shown to users of this metric."
+ )
+ extra: str | None = Field(
+ None, description="JSON-encoded string with extra metric metadata."
+ )
+
+ def updates(self) -> Dict[str, Any]:
+ """Return only the metric properties explicitly provided by the caller.
+
+ ``exclude_unset`` distinguishes "not provided" (leave the stored value
+ alone) from an explicit ``null`` (clear the stored value).
+ """
+ return self.model_dump(
+ exclude_unset=True,
+ include=set(UPDATABLE_METRIC_FIELDS),
+ )
+
+ @model_validator(mode="after")
+ def validate_updates(self) -> "UpdateDatasetMetricRequest":
+ """Require at least one updatable property and reject empty/invalid
values.
+
+ Guards against no-op requests, empty ``metric_name``/``expression``,
and
+ malformed ``extra`` JSON before the update reaches the command layer.
+ """
+ provided = self.model_fields_set & UPDATABLE_METRIC_FIELDS
+ if not provided:
+ raise ValueError(
+ "At least one metric property must be provided to update. "
+ f"Updatable properties: {sorted(UPDATABLE_METRIC_FIELDS)}."
+ )
+ if "metric_name" in provided and not (self.metric_name or "").strip():
+ raise ValueError("metric_name cannot be empty or null")
+ if "expression" in provided and not (self.expression or "").strip():
+ raise ValueError("expression cannot be empty or null")
+ if "extra" in provided and self.extra is not None:
+ try:
+ json.loads(self.extra)
+ except (ValueError, TypeError) as ex:
+ raise ValueError("extra must be a valid JSON-encoded string")
from ex
+ return self
+
+
+class DatasetMetricDetail(SqlMetricInfo):
+ """Full saved-metric details, including identifiers."""
+
+ id: int | None = Field(None, description="Metric ID")
+ uuid: str | None = Field(None, description="Metric UUID")
+ metric_type: str | None = Field(None, description="Metric type")
+ currency: MetricCurrency | None = Field(
+ None, description="Currency formatting configuration"
+ )
+ warning_text: str | None = Field(None, description="Warning text")
+ extra: str | None = Field(
+ None, description="JSON-encoded string with extra metric metadata"
+ )
+
+
+class UpdateDatasetMetricResponse(BaseModel):
+ """Response schema for update_dataset_metric."""
+
+ dataset_id: int | None = Field(None, description="Dataset ID")
+ dataset_name: str | None = Field(None, description="Dataset name")
+ metric: DatasetMetricDetail | None = Field(
+ None, description="The metric after the update. None if the update
failed."
+ )
+ updated_properties: List[str] = Field(
+ default_factory=list,
+ description="Names of the metric properties that were updated.",
+ )
+ url: str | None = Field(
+ None, description="Explore URL for the dataset. None if the update
failed."
+ )
+ error: str | None = Field(
+ None, description="Error message if the update failed, otherwise null."
+ )
+
+
VALID_FILTER_OPS = Literal[
"==",
"!=",
diff --git a/superset/mcp_service/dataset/tool/__init__.py
b/superset/mcp_service/dataset/tool/__init__.py
index 6fd3c12133c..191ffd4c4d8 100644
--- a/superset/mcp_service/dataset/tool/__init__.py
+++ b/superset/mcp_service/dataset/tool/__init__.py
@@ -20,6 +20,7 @@ from .create_virtual_dataset import create_virtual_dataset
from .get_dataset_info import get_dataset_info
from .list_datasets import list_datasets
from .query_dataset import query_dataset
+from .update_dataset_metric import update_dataset_metric
__all__ = [
"create_dataset",
@@ -27,4 +28,5 @@ __all__ = [
"get_dataset_info",
"list_datasets",
"query_dataset",
+ "update_dataset_metric",
]
diff --git a/superset/mcp_service/dataset/tool/query_dataset.py
b/superset/mcp_service/dataset/tool/query_dataset.py
index 6850d930873..8b92966e161 100644
--- a/superset/mcp_service/dataset/tool/query_dataset.py
+++ b/superset/mcp_service/dataset/tool/query_dataset.py
@@ -35,6 +35,7 @@ from superset.commands.exceptions import CommandException
from superset.exceptions import OAuth2Error, OAuth2RedirectError,
SupersetException
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import DataColumn, PerformanceMetadata
+from superset.mcp_service.dataset.dataset_utils import resolve_dataset
from superset.mcp_service.dataset.schemas import (
DatasetError,
QueryDatasetFilter,
@@ -46,7 +47,6 @@ from superset.mcp_service.privacy import (
requires_data_model_metadata_access,
user_can_view_data_model_metadata,
)
-from superset.mcp_service.utils import _is_uuid
from superset.mcp_service.utils.cache_utils import get_cache_status_from_result
from superset.mcp_service.utils.oauth2_utils import
build_oauth2_redirect_message
from superset.mcp_service.utils.query_utils import validate_names
@@ -55,32 +55,6 @@ from superset.mcp_service.utils.response_utils import
format_data_columns
logger = logging.getLogger(__name__)
-def _resolve_dataset(identifier: int | str, eager_options: list[Any]) -> Any |
None:
- """Resolve a dataset by int ID or UUID string.
-
- Replicates the identifier resolution logic from
ModelGetInfoCore._find_object().
- """
- from superset.daos.dataset import DatasetDAO
-
- opts = eager_options or None
-
- if isinstance(identifier, int):
- return DatasetDAO.find_by_id(identifier, query_options=opts)
-
- # Try parsing as int
- try:
- id_val = int(identifier)
- return DatasetDAO.find_by_id(id_val, query_options=opts)
- except (ValueError, TypeError):
- pass
-
- # Try UUID
- if _is_uuid(str(identifier)):
- return DatasetDAO.find_by_id(identifier, id_column="uuid",
query_options=opts)
-
- return None
-
-
_NO_SAVED_METRICS_HINT = (
"This dataset has no saved metrics, and query_dataset accepts saved "
"metric names only (not ad-hoc expressions). Use execute_sql for "
@@ -170,7 +144,7 @@ async def query_dataset( # noqa: C901
]
with event_logger.log_context(action="mcp.query_dataset.lookup"):
- dataset = _resolve_dataset(request.dataset_id, eager_options)
+ dataset = resolve_dataset(request.dataset_id, eager_options)
if dataset is None:
await ctx.error("Dataset not found: identifier=%s" %
(request.dataset_id,))
diff --git a/superset/mcp_service/dataset/tool/update_dataset_metric.py
b/superset/mcp_service/dataset/tool/update_dataset_metric.py
new file mode 100644
index 00000000000..f7f29b0a44e
--- /dev/null
+++ b/superset/mcp_service/dataset/tool/update_dataset_metric.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.
+
+"""Update a saved metric on a dataset (FastMCP tool)."""
+
+import difflib
+import logging
+from typing import Any
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ DatasetMetricDetail,
+ MetricCurrency,
+ UpdateDatasetMetricRequest,
+ UpdateDatasetMetricResponse,
+)
+from superset.mcp_service.utils import (
+ escape_llm_context_delimiters,
+ sanitize_for_llm_context,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_metric(metrics: list[Any], identifier: int | str) -> Any | None:
+ """Find a saved metric by ID, UUID, or metric_name."""
+ if isinstance(identifier, str):
+ try:
+ identifier = int(identifier)
+ except ValueError:
+ pass
+
+ if isinstance(identifier, int):
+ return next((m for m in metrics if m.id == identifier), None)
+
+ lowered = identifier.lower()
+ by_uuid = next(
+ (m for m in metrics if m.uuid and str(m.uuid).lower() == lowered), None
+ )
+ if by_uuid is not None:
+ return by_uuid
+ return next((m for m in metrics if m.metric_name == identifier), None)
+
+
+def _metric_not_found_message(metrics: list[Any], identifier: int | str) ->
str:
+ """Build a "metric not found" error, escaping caller- and stored-supplied
+ text so it can't break out of the LLM context delimiters (same treatment as
+ the success path in ``_serialize_metric``)."""
+ names = [m.metric_name for m in metrics]
+ safe_identifier = escape_llm_context_delimiters(str(identifier))
+ msg = f"Metric '{safe_identifier}' not found on this dataset."
+ if not names:
+ return f"{msg} This dataset has no saved metrics."
+ suggestions = difflib.get_close_matches(str(identifier), names, n=3,
cutoff=0.6)
+ if suggestions:
+ safe_suggestions = [escape_llm_context_delimiters(n) for n in
suggestions]
+ return f"{msg} Did you mean: {', '.join(safe_suggestions)}?"
+ safe_names = [escape_llm_context_delimiters(n) for n in sorted(names)]
+ return f"{msg} Available metrics: {', '.join(safe_names)}."
+
+
+def _serialize_metric(metric: Any) -> DatasetMetricDetail:
+ """Build a ``DatasetMetricDetail`` from a ``SqlMetric`` model.
+
+ Returns the metric's identifiers (id, uuid) and all updatable properties,
+ wrapping free-text fields in LLM-context sanitization the same way the
+ dataset read path does.
+ """
+ currency = getattr(metric, "currency", None)
+ return DatasetMetricDetail(
+ id=getattr(metric, "id", None),
+ uuid=str(metric.uuid) if getattr(metric, "uuid", None) else None,
+ metric_name=escape_llm_context_delimiters(metric.metric_name) or "",
+ verbose_name=sanitize_for_llm_context(
+ getattr(metric, "verbose_name", None),
+ field_path=("metric", "verbose_name"),
+ ),
+ expression=sanitize_for_llm_context(
+ getattr(metric, "expression", None),
+ field_path=("metric", "expression"),
+ ),
+ description=sanitize_for_llm_context(
+ getattr(metric, "description", None),
+ field_path=("metric", "description"),
+ ),
+ d3format=getattr(metric, "d3format", None),
+ metric_type=getattr(metric, "metric_type", None),
+ currency=MetricCurrency.model_validate(currency)
+ if isinstance(currency, dict)
+ else None,
+ warning_text=sanitize_for_llm_context(
+ getattr(metric, "warning_text", None),
+ field_path=("metric", "warning_text"),
+ ),
+ extra=sanitize_for_llm_context(
+ getattr(metric, "extra", None),
+ field_path=("metric", "extra"),
+ ),
+ )
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset metric",
+ readOnlyHint=False,
+ # Overwrites an existing metric's definition and affects every chart
+ # that uses it — non-additive, like update_chart.
+ destructiveHint=True,
+ ),
+)
+async def update_dataset_metric( # noqa: C901
+ request: UpdateDatasetMetricRequest, ctx: Context
+) -> UpdateDatasetMetricResponse:
+ """Update a saved metric on a dataset.
+
+ Modifies properties of an existing saved metric (the named, reusable
+ aggregations stored on a dataset, e.g. SUM(revenue)) — such as its SQL
+ expression, name, display label, or number format. Changes apply to
+ every chart that uses the metric.
+
+ Only the properties you pass are changed; everything else on the metric,
+ and all other metrics on the dataset, is left untouched. This tool cannot
+ create or delete metrics. Requires ownership of the dataset (or Admin).
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset's saved metrics
+ 2. Call this tool with the dataset ID, the metric identifier, and only
+ the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "metric": "sum_revenue",
+ "expression": "SUM(net_revenue)",
+ "d3format": "$,.2f"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset metric: dataset_id=%s, metric=%r, properties=%s"
+ % (request.dataset_id, request.metric, sorted(updates))
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.metrics),
+ joinedload(SqlaTable.database),
+ ]
+
+ with
event_logger.log_context(action="mcp.update_dataset_metric.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ await ctx.warning("Dataset not found: %s" % (request.dataset_id,))
+ return UpdateDatasetMetricResponse(
+ error=(
+ f"No dataset found with identifier: {request.dataset_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ # Enforce editorship before touching the metric list. Otherwise a
caller
+ # with read access but not edit rights could distinguish "metric
exists"
+ # from "metric not found" and enumerate metric names via the not-found
+ # suggestions. UpdateDatasetCommand re-checks this, but only after the
+ # lookup below would have already leaked that information.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning(
+ "Dataset metric update forbidden: dataset_id=%s" %
(dataset.id,)
+ )
+ return UpdateDatasetMetricResponse(
+ error="You must be an owner of this dataset (or an Admin) "
+ "to update its metrics.",
+ )
+
+ metrics = list(dataset.metrics)
+ target = _find_metric(metrics, request.metric)
+ if target is None:
+ message = _metric_not_found_message(metrics, request.metric)
+ await ctx.warning("Metric not found: %s" % (request.metric,))
+ return UpdateDatasetMetricResponse(
+ dataset_id=dataset.id,
+ dataset_name=dataset.table_name,
+ error=message,
+ )
+
+ # DatasetDAO.update_metrics deletes any metric missing from the list,
+ # so every metric is sent back: untouched ones as bare
+ # {id, metric_name} stubs (the bulk update merges them over the stored
+ # row), and the target with the requested changes applied on top.
+ metrics_payload: list[dict[str, Any]] = []
+ for metric in metrics:
+ entry: dict[str, Any] = {
+ "id": metric.id,
+ "metric_name": metric.metric_name,
+ }
+ if metric.id == target.id:
+ entry.update(updates)
+ metrics_payload.append(entry)
+
+ # UpdateDatasetCommand enforces dataset ownership and validates
+ # metric name uniqueness and the SQL expression
+ with
event_logger.log_context(action="mcp.update_dataset_metric.update"):
+ updated_dataset = UpdateDatasetCommand(
+ dataset.id, {"metrics": metrics_payload}
+ ).run()
+
+ updated_metric = next(
+ (m for m in updated_dataset.metrics if m.id == target.id), None
+ )
+
+ await ctx.info(
+ "Dataset metric updated: dataset_id=%s, metric_id=%s,
properties=%s"
+ % (updated_dataset.id, target.id, sorted(updates))
+ )
+
+ return UpdateDatasetMetricResponse(
+ dataset_id=updated_dataset.id,
+ dataset_name=updated_dataset.table_name,
+ metric=_serialize_metric(updated_metric) if updated_metric else
None,
+ updated_properties=sorted(updates),
+ url=(
+ f"{get_superset_base_url()}/explore/"
+ f"?datasource_type=table&datasource_id={updated_dataset.id}"
+ ),
+ )
+
+ except DatasetNotFoundError:
+ await ctx.warning("Dataset not found: %s" % (request.dataset_id,))
+ return UpdateDatasetMetricResponse(
+ error=f"No dataset found with identifier: {request.dataset_id}.",
+ )
+ except DatasetForbiddenError:
+ await ctx.warning(
+ "Dataset metric update forbidden: dataset_id=%s" %
(request.dataset_id,)
+ )
+ return UpdateDatasetMetricResponse(
+ error="You must be an owner of this dataset (or an Admin) "
+ "to update its metrics.",
+ )
+ except DatasetInvalidError as exc:
+ messages = exc.normalized_messages()
+ await ctx.warning("Dataset metric validation failed: %s" % (messages,))
+ return UpdateDatasetMetricResponse(error=str(messages))
+ except DatasetUpdateFailedError as exc:
+ await ctx.error("Dataset metric update failed: %s" % (str(exc),))
+ return UpdateDatasetMetricResponse(
+ error=f"Failed to update metric: {exc}",
+ )
+ except Exception as exc:
+ await ctx.error(
+ "Unexpected error updating dataset metric: %s: %s"
+ % (type(exc).__name__, str(exc))
+ )
+ raise
diff --git a/tests/unit_tests/mcp_service/dataset/tool/test_query_dataset.py
b/tests/unit_tests/mcp_service/dataset/tool/test_query_dataset.py
index eb9e241d283..08e17b5fbe0 100644
--- a/tests/unit_tests/mcp_service/dataset/tool/test_query_dataset.py
+++ b/tests/unit_tests/mcp_service/dataset/tool/test_query_dataset.py
@@ -143,7 +143,7 @@ async def test_query_dataset_success(mcp_server: FastMCP)
-> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -183,7 +183,7 @@ async def test_query_dataset_not_found(mcp_server: FastMCP)
-> None:
"""Dataset ID that doesn't exist returns error."""
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=None,
):
async with Client(mcp_server) as client:
@@ -209,7 +209,7 @@ async def test_query_dataset_invalid_metric(mcp_server:
FastMCP) -> None:
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
):
async with Client(mcp_server) as client:
@@ -237,7 +237,7 @@ async def test_query_dataset_invalid_column(mcp_server:
FastMCP) -> None:
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
):
async with Client(mcp_server) as client:
@@ -290,7 +290,7 @@ async def test_query_dataset_with_time_range(mcp_server:
FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -342,7 +342,7 @@ async def
test_query_dataset_time_range_no_temporal_column(mcp_server: FastMCP)
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
):
async with Client(mcp_server) as client:
@@ -376,7 +376,7 @@ async def test_query_dataset_with_filters(mcp_server:
FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -433,7 +433,7 @@ async def test_query_dataset_empty_results(mcp_server:
FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -474,7 +474,7 @@ async def test_query_dataset_by_uuid(mcp_server: FastMCP)
-> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
) as mock_resolve,
patch(
@@ -520,7 +520,7 @@ async def test_query_dataset_permission_denied(mcp_server:
FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -567,7 +567,7 @@ async def test_query_dataset_order_by_valid(mcp_server:
FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -611,7 +611,7 @@ async def test_query_dataset_order_by_invalid(mcp_server:
FastMCP) -> None:
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
):
async with Client(mcp_server) as client:
@@ -645,7 +645,7 @@ async def
test_query_dataset_time_column_override(mcp_server: FastMCP) -> None:
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -689,7 +689,7 @@ async def
test_query_dataset_non_dttm_time_column_warns(mcp_server: FastMCP) ->
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch(
@@ -729,7 +729,7 @@ async def
test_query_dataset_invalid_filter_column(mcp_server: FastMCP) -> None:
with patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
):
async with Client(mcp_server) as client:
@@ -769,7 +769,7 @@ async def
test_query_dataset_metadata_access_denied_no_suggestions(
with (
patch.object(
query_dataset_module,
- "_resolve_dataset",
+ "resolve_dataset",
return_value=dataset,
),
patch.object(
diff --git
a/tests/unit_tests/mcp_service/dataset/tool/test_update_dataset_metric.py
b/tests/unit_tests/mcp_service/dataset/tool/test_update_dataset_metric.py
new file mode 100644
index 00000000000..d533498b26f
--- /dev/null
+++ b/tests/unit_tests/mcp_service/dataset/tool/test_update_dataset_metric.py
@@ -0,0 +1,541 @@
+# 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.
+
+from collections.abc import Iterator
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastmcp import Client, FastMCP
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.dataset.schemas import UpdateDatasetMetricRequest
+from superset.mcp_service.utils.sanitization import (
+ LLM_CONTEXT_CLOSE_DELIMITER,
+ LLM_CONTEXT_OPEN_DELIMITER,
+)
+from superset.utils import json
+
+
+def _wrapped(value: str) -> str:
+ return
f"{LLM_CONTEXT_OPEN_DELIMITER}\n{value}\n{LLM_CONTEXT_CLOSE_DELIMITER}"
+
+
[email protected]
+def mcp_server() -> FastMCP:
+ """Provide the shared FastMCP app instance for the in-process test
client."""
+ return mcp
+
+
[email protected](autouse=True)
+def mock_auth() -> Iterator[MagicMock]:
+ """Mock authentication for all tests."""
+ from unittest.mock import Mock
+
+ with patch("superset.mcp_service.auth.get_user_from_request") as
mock_get_user:
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
[email protected](autouse=True)
+def allow_ownership() -> Iterator[MagicMock]:
+ """Let ownership checks pass by default; override to simulate a non-owner.
+
+ Patches the security manager class method (rather than the module-level
+ proxy) so it resolves without an app context, as in CI.
+ """
+ with patch(
+ "superset.security.SupersetSecurityManager.raise_for_editorship",
+ return_value=None,
+ ) as mock_raise:
+ yield mock_raise
+
+
+def make_metric(
+ metric_id: int = 10,
+ metric_name: str = "count",
+ uuid: str = "a1b2c3d4-5678-90ab-cdef-1234567890ab",
+ **overrides: Any,
+) -> SimpleNamespace:
+ """Build a stand-in SqlMetric with all serialized attributes populated."""
+ metric = SimpleNamespace(
+ id=metric_id,
+ uuid=uuid,
+ metric_name=metric_name,
+ verbose_name=None,
+ expression="COUNT(*)",
+ description=None,
+ d3format=None,
+ metric_type=None,
+ currency=None,
+ warning_text=None,
+ )
+ for key, value in overrides.items():
+ setattr(metric, key, value)
+ return metric
+
+
+def make_dataset(
+ dataset_id: int = 1, metrics: list[SimpleNamespace] | None = None
+) -> MagicMock:
+ """Build a stand-in SqlaTable exposing an id, table_name, and metrics."""
+ dataset = MagicMock()
+ dataset.id = dataset_id
+ dataset.table_name = "my_table"
+ dataset.metrics = metrics if metrics is not None else [make_metric()]
+ return dataset
+
+
+# ---------------------------------------------------------------------------
+# Request schema validation
+# ---------------------------------------------------------------------------
+
+
+def test_request_requires_at_least_one_property() -> None:
+ with pytest.raises(ValidationError, match="At least one metric property"):
+ UpdateDatasetMetricRequest(dataset_id=1, metric="count")
+
+
+def test_request_rejects_empty_expression() -> None:
+ with pytest.raises(ValidationError, match="expression cannot be empty"):
+ UpdateDatasetMetricRequest(dataset_id=1, metric="count", expression="
")
+
+
+def test_request_rejects_null_metric_name() -> None:
+ with pytest.raises(ValidationError, match="metric_name cannot be empty"):
+ UpdateDatasetMetricRequest(dataset_id=1, metric="count",
metric_name=None)
+
+
+def test_request_updates_only_includes_provided_properties() -> None:
+ request = UpdateDatasetMetricRequest.model_validate(
+ {"dataset_id": 1, "metric": "count", "expression": "COUNT(1)"}
+ )
+ assert request.updates() == {"expression": "COUNT(1)"}
+
+
+def test_request_updates_keeps_explicit_nulls() -> None:
+ request = UpdateDatasetMetricRequest.model_validate(
+ {"dataset_id": 1, "metric": "count", "description": None, "d3format":
",.2f"}
+ )
+ assert request.updates() == {"description": None, "d3format": ",.2f"}
+
+
+def test_request_rejects_invalid_extra_json() -> None:
+ with pytest.raises(ValidationError, match="extra must be a valid JSON"):
+ UpdateDatasetMetricRequest(dataset_id=1, metric="count", extra="{not
json")
+
+
+def test_request_accepts_valid_extra_json() -> None:
+ request = UpdateDatasetMetricRequest.model_validate(
+ {"dataset_id": 1, "metric": "count", "extra": '{"warning_markdown":
"hi"}'}
+ )
+ assert request.updates() == {"extra": '{"warning_markdown": "hi"}'}
+
+
+def test_request_updates_dumps_nested_currency() -> None:
+ request = UpdateDatasetMetricRequest.model_validate(
+ {
+ "dataset_id": 1,
+ "metric": 10,
+ "currency": {"symbol": "USD", "symbolPosition": "prefix"},
+ }
+ )
+ assert request.updates() == {
+ "currency": {"symbol": "USD", "symbolPosition": "prefix"}
+ }
+
+
+# ---------------------------------------------------------------------------
+# Tool behavior
+# ---------------------------------------------------------------------------
+
+
[email protected]
+async def test_update_dataset_metric_success(mcp_server: FastMCP) -> None:
+ """Happy path: only the target metric is changed, others sent as stubs."""
+ target = make_metric(metric_id=10, metric_name="count")
+ other = make_metric(
+ metric_id=11,
+ metric_name="sum_revenue",
+ uuid="b2c3d4e5-6789-01bc-def0-234567890abc",
+ expression="SUM(revenue)",
+ )
+ dataset = make_dataset(dataset_id=1, metrics=[target, other])
+
+ updated_target = make_metric(
+ metric_id=10, metric_name="count", expression="COUNT(1)", d3format=",d"
+ )
+ updated_dataset = make_dataset(dataset_id=1, metrics=[updated_target,
other])
+
+ mock_command = MagicMock()
+ mock_command.run.return_value = updated_dataset
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ) as command_cls,
+ patch(
+ "superset.mcp_service.utils.url_utils.get_superset_base_url",
+ return_value="http://localhost:8088",
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "count",
+ "expression": "COUNT(1)",
+ "d3format": ",d",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ command_cls.assert_called_once_with(
+ 1,
+ {
+ "metrics": [
+ {
+ "id": 10,
+ "metric_name": "count",
+ "expression": "COUNT(1)",
+ "d3format": ",d",
+ },
+ {"id": 11, "metric_name": "sum_revenue"},
+ ]
+ },
+ )
+ assert data["error"] is None
+ assert data["dataset_id"] == 1
+ assert data["dataset_name"] == "my_table"
+ assert data["updated_properties"] == ["d3format", "expression"]
+ assert data["metric"]["id"] == 10
+ assert data["metric"]["expression"] == _wrapped("COUNT(1)")
+ assert data["metric"]["d3format"] == ",d"
+ assert "datasource_id=1" in data["url"]
+
+
[email protected]
+async def test_update_dataset_metric_returns_extra(mcp_server: FastMCP) ->
None:
+ """`extra` is an updatable property, so it is echoed back in the
response."""
+ dataset = make_dataset(dataset_id=1, metrics=[make_metric(metric_id=10)])
+ updated = make_dataset(
+ dataset_id=1,
+ metrics=[make_metric(metric_id=10, extra='{"warning_markdown":
"note"}')],
+ )
+ mock_command = MagicMock()
+ mock_command.run.return_value = updated
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ),
+ patch(
+ "superset.mcp_service.utils.url_utils.get_superset_base_url",
+ return_value="http://localhost:8088",
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": 10,
+ "extra": '{"warning_markdown": "note"}',
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["error"] is None
+ assert data["updated_properties"] == ["extra"]
+ assert data["metric"]["extra"] == _wrapped('{"warning_markdown": "note"}')
+
+
[email protected]
+async def test_update_dataset_metric_by_id_and_uuid(mcp_server: FastMCP) ->
None:
+ """The metric can be addressed by numeric ID (also as string) or UUID."""
+ target = make_metric(metric_id=10, metric_name="count")
+ dataset = make_dataset(dataset_id=1, metrics=[target])
+
+ mock_command = MagicMock()
+ mock_command.run.return_value = dataset
+
+ for identifier in (10, "10", "A1B2C3D4-5678-90ab-cdef-1234567890ab"):
+ mock_command.reset_mock()
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ),
+ patch(
+ "superset.mcp_service.utils.url_utils.get_superset_base_url",
+ return_value="http://localhost:8088",
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": identifier,
+ "verbose_name": "Count",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["error"] is None, identifier
+ mock_command.run.assert_called_once()
+
+
[email protected]
+async def test_update_dataset_metric_not_found_suggests_names(
+ mcp_server: FastMCP,
+) -> None:
+ dataset = make_dataset(
+ dataset_id=1, metrics=[make_metric(metric_id=10,
metric_name="sum_revenue")]
+ )
+
+ with patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "sum_revenu",
+ "expression": "SUM(net_revenue)",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert "not found" in data["error"]
+ assert "sum_revenue" in data["error"]
+
+
[email protected]
+async def test_update_dataset_metric_not_found_escapes_names(
+ mcp_server: FastMCP,
+) -> None:
+ """Metric names in the not-found error are escaped like the success
path."""
+ from superset.mcp_service.utils.sanitization import (
+ LLM_CONTEXT_ESCAPED_OPEN_DELIMITER,
+ )
+
+ hostile_name = f"{LLM_CONTEXT_OPEN_DELIMITER}evil"
+ dataset = make_dataset(
+ dataset_id=1, metrics=[make_metric(metric_id=10,
metric_name=hostile_name)]
+ )
+
+ with patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "missing",
+ "expression": "COUNT(1)",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert LLM_CONTEXT_OPEN_DELIMITER not in data["error"]
+ assert LLM_CONTEXT_ESCAPED_OPEN_DELIMITER in data["error"]
+
+
[email protected]
+async def test_update_dataset_metric_dataset_not_found(mcp_server: FastMCP) ->
None:
+ with patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=None,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 999,
+ "metric": "count",
+ "description": "desc",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert "No dataset found" in data["error"]
+
+
[email protected]
+async def test_update_dataset_metric_forbidden_non_owner(
+ mcp_server: FastMCP, allow_ownership: MagicMock
+) -> None:
+ """A non-owner is rejected before the metric list is inspected, so the
+ error cannot be used to enumerate metric names."""
+ from superset.exceptions import SupersetSecurityException
+
+ allow_ownership.side_effect = SupersetSecurityException(
+ MagicMock(message="not an owner")
+ )
+
+ dataset = make_dataset(
+ dataset_id=1,
+ metrics=[make_metric(metric_id=10, metric_name="secret_metric")],
+ )
+ mock_command = MagicMock()
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ) as command_cls,
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "does_not_exist",
+ "expression": "COUNT(1)",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert "owner" in data["error"]
+ # No metric names leak, and the command is never constructed/run.
+ assert "secret_metric" not in data["error"]
+ command_cls.assert_not_called()
+
+
[email protected]
+async def test_update_dataset_metric_invalid_error(mcp_server: FastMCP) ->
None:
+ """Validation failures (e.g. duplicate name) surface as error responses."""
+ from superset.commands.dataset.exceptions import (
+ DatasetInvalidError,
+ DatasetMetricsDuplicateValidationError,
+ )
+
+ invalid_exc = DatasetInvalidError()
+ invalid_exc.append(DatasetMetricsDuplicateValidationError())
+
+ dataset = make_dataset()
+ mock_command = MagicMock()
+ mock_command.run.side_effect = invalid_exc
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "count",
+ "metric_name": "sum_revenue",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert data["error"] is not None
+
+
[email protected]
+async def test_update_dataset_metric_update_failed(mcp_server: FastMCP) ->
None:
+ """A persistence failure surfaces as a "Failed to update" error
response."""
+ from superset.commands.dataset.exceptions import DatasetUpdateFailedError
+
+ dataset = make_dataset()
+ mock_command = MagicMock()
+ mock_command.run.side_effect = DatasetUpdateFailedError()
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.commands.dataset.update.UpdateDatasetCommand",
+ return_value=mock_command,
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "update_dataset_metric",
+ {
+ "request": {
+ "dataset_id": 1,
+ "metric": "count",
+ "expression": "COUNT(1)",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert data["metric"] is None
+ assert "Failed to update" in data["error"]