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 2317d9cf918 fix(mcp): truncate query-tool responses instead of 
hard-failing (#42244)
2317d9cf918 is described below

commit 2317d9cf918248e2b6b8d9887742ea9aacd59533
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Jul 23 14:38:49 2026 -0400

    fix(mcp): truncate query-tool responses instead of hard-failing (#42244)
---
 superset/mcp_service/middleware.py                 | 181 ++++++++++---
 superset/mcp_service/utils/token_utils.py          | 244 +++++++++++++++++
 tests/unit_tests/mcp_service/test_middleware.py    | 295 +++++++++++++++++++++
 .../mcp_service/utils/test_token_utils.py          | 103 +++++++
 4 files changed, 781 insertions(+), 42 deletions(-)

diff --git a/superset/mcp_service/middleware.py 
b/superset/mcp_service/middleware.py
index fdb5681eaa0..5faebbb19c7 100644
--- a/superset/mcp_service/middleware.py
+++ b/superset/mcp_service/middleware.py
@@ -51,10 +51,12 @@ from superset.mcp_service.constants import (
     DEFAULT_WARN_THRESHOLD_PCT,
 )
 from superset.mcp_service.utils.token_utils import (
+    DATA_QUERY_TOOLS,
     estimate_response_tokens,
     format_size_limit_error,
     INFO_TOOLS,
     truncate_oversized_response,
+    truncate_query_result,
 )
 from superset.utils.core import get_user_id
 
@@ -840,6 +842,141 @@ class ResponseSizeGuardMiddleware(Middleware):
 
         return truncated
 
+    def _try_truncate_data_query_response(
+        self,
+        tool_name: str,
+        response: Any,
+        estimated_tokens: int,
+    ) -> Any | None:
+        """Attempt to truncate a data-query tool response by dropping tail 
rows.
+
+        Returns the truncated response if successful, None otherwise.
+        """
+        extracted = self._extract_payload_from_tool_result(response)
+        truncation_target = extracted if extracted is not None else response
+
+        try:
+            truncated, was_truncated, notes = truncate_query_result(
+                truncation_target, self.token_limit, tool_name=tool_name
+            )
+        except Exception as trunc_error:  # noqa: BLE001
+            logger.warning(
+                "Query result truncation failed for %s due to %s: %s",
+                tool_name,
+                type(trunc_error).__name__,
+                trunc_error,
+            )
+            return None
+
+        if not was_truncated:
+            return None
+
+        # Mirror the info-tool path: if truncation couldn't bring the
+        # response back under the limit (e.g. a single row/scalar field
+        # alone exceeds it), fall back to the hard size-limit error instead
+        # of shipping an over-budget response.
+        truncated_tokens = estimate_response_tokens(truncated)
+        if truncated_tokens > self.token_limit:
+            return None
+
+        logger.warning(
+            "Query result for %s truncated from ~%d to ~%d tokens (limit: %d). 
%s",
+            tool_name,
+            estimated_tokens,
+            truncated_tokens,
+            self.token_limit,
+            "; ".join(notes),
+        )
+
+        try:
+            user_id = get_user_id()
+            event_logger.log(
+                user_id=user_id,
+                action="mcp_response_truncated",
+                curated_payload={
+                    "tool": tool_name,
+                    "original_tokens": estimated_tokens,
+                    "truncated_tokens": truncated_tokens,
+                    "token_limit": self.token_limit,
+                    "truncation_notes": notes,
+                },
+            )
+        except Exception as log_error:  # noqa: BLE001
+            logger.warning("Failed to log truncation event: %s", log_error)
+
+        if extracted is not None and isinstance(truncated, dict):
+            return self._rewrap_as_tool_result(truncated, response)
+
+        return truncated
+
+    def _handle_oversized_response(
+        self,
+        tool_name: str,
+        response: Any,
+        estimated_tokens: int,
+        params: dict[str, Any],
+    ) -> Any:
+        """Attempt truncation for known tool categories; block everything else.
+
+        For info tools (``INFO_TOOLS``) and data-query tools
+        (``DATA_QUERY_TOOLS``), tries dynamic truncation first and returns
+        the truncated result if successful.  Falls through to a hard
+        ``ToolError`` for all other tools, or when truncation cannot reduce
+        the response to fit the limit.
+
+        Raises:
+            ToolError: When the response exceeds the limit and cannot be
+                truncated.
+        """
+        # Info tools: field-level truncation (strings, lists, dicts).
+        if tool_name in INFO_TOOLS:
+            truncated = self._try_truncate_info_response(
+                tool_name, response, estimated_tokens
+            )
+            if truncated is not None:
+                return truncated
+
+        # Data-query tools: row-level truncation.
+        if tool_name in DATA_QUERY_TOOLS:
+            truncated = self._try_truncate_data_query_response(
+                tool_name, response, estimated_tokens
+            )
+            if truncated is not None:
+                return truncated
+
+        # Log the blocked response (user-caused: requested too much data)
+        logger.warning(
+            "Response blocked for %s: ~%d tokens exceeds limit of %d",
+            tool_name,
+            estimated_tokens,
+            self.token_limit,
+        )
+
+        try:
+            user_id = get_user_id()
+            event_logger.log(
+                user_id=user_id,
+                action="mcp_response_size_exceeded",
+                curated_payload={
+                    "tool": tool_name,
+                    "estimated_tokens": estimated_tokens,
+                    "token_limit": self.token_limit,
+                    "params": _sanitize_params(params),
+                },
+            )
+        except Exception as log_error:  # noqa: BLE001
+            logger.warning("Failed to log size exceeded event: %s", log_error)
+
+        raise ToolError(
+            format_size_limit_error(
+                tool_name=tool_name,
+                params=params,
+                estimated_tokens=estimated_tokens,
+                token_limit=self.token_limit,
+                response=None,
+            )
+        )
+
     async def on_call_tool(
         self,
         context: MiddlewareContext,
@@ -886,52 +1023,12 @@ class ResponseSizeGuardMiddleware(Middleware):
                 self.token_limit,
             )
 
-        # Block if over limit
         if estimated_tokens > self.token_limit:
             params = getattr(context.message, "params", {}) or {}
-
-            # For info tools, try dynamic truncation before blocking
-            if tool_name in INFO_TOOLS:
-                truncated = self._try_truncate_info_response(
-                    tool_name, response, estimated_tokens
-                )
-                if truncated is not None:
-                    return truncated
-
-            # Log the blocked response (user-caused: requested too much data)
-            logger.warning(
-                "Response blocked for %s: ~%d tokens exceeds limit of %d",
-                tool_name,
-                estimated_tokens,
-                self.token_limit,
-            )
-
-            # Log to event logger for monitoring
-            try:
-                user_id = get_user_id()
-                event_logger.log(
-                    user_id=user_id,
-                    action="mcp_response_size_exceeded",
-                    curated_payload={
-                        "tool": tool_name,
-                        "estimated_tokens": estimated_tokens,
-                        "token_limit": self.token_limit,
-                        "params": _sanitize_params(params),
-                    },
-                )
-            except Exception as log_error:  # noqa: BLE001
-                logger.warning("Failed to log size exceeded event: %s", 
log_error)
-
-            error_message = format_size_limit_error(
-                tool_name=tool_name,
-                params=params,
-                estimated_tokens=estimated_tokens,
-                token_limit=self.token_limit,
-                response=None,
+            return self._handle_oversized_response(
+                tool_name, response, estimated_tokens, params
             )
 
-            raise ToolError(error_message)
-
         return response
 
 
diff --git a/superset/mcp_service/utils/token_utils.py 
b/superset/mcp_service/utils/token_utils.py
index c8e482dffd7..5d546e2e635 100644
--- a/superset/mcp_service/utils/token_utils.py
+++ b/superset/mcp_service/utils/token_utils.py
@@ -469,6 +469,22 @@ INFO_TOOLS = frozenset(
     }
 )
 
+# Data-query tools that return tabular results.  When the response exceeds the
+# token limit, these tools are truncated by dropping tail rows rather than
+# raising a hard ToolError.  A truncation note is appended so the caller
+# knows the result is partial and how to narrow the query.
+DATA_QUERY_TOOLS = frozenset(
+    {
+        "execute_sql",
+        "query_dataset",
+        "get_chart_data",
+    }
+)
+
+# Data field names used by the three query tools (in priority order).
+# ``rows`` is used by execute_sql; ``data`` by query_dataset and 
get_chart_data.
+_DATA_ROW_FIELDS = ("rows", "data")
+
 # Maximum character length for string fields before truncation
 _MAX_STRING_CHARS = 500
 # Maximum keys to keep when summarizing large dict fields
@@ -663,6 +679,234 @@ def truncate_oversized_response(
     return data, was_truncated, notes
 
 
+def _bisect_row_limit(
+    data: Dict[str, Any],
+    row_field: str,
+    original_rows: List[Any],
+    token_limit: int,
+) -> int:
+    """Binary-search for the largest row prefix that keeps data under limit.
+
+    Mutates ``data[row_field]`` during the search and leaves it at the final
+    kept count on return.  Returns the number of rows kept (>= 1 if the
+    original list was non-empty).
+    """
+    from superset.utils import json as utils_json
+
+    lo, hi = 0, len(original_rows)
+    while lo < hi:
+        mid = (lo + hi + 1) // 2
+        data[row_field] = original_rows[:mid]
+        if estimate_token_count(utils_json.dumps(data)) <= token_limit:
+            lo = mid
+        else:
+            hi = mid - 1
+
+    kept = lo
+    if kept == 0 and original_rows:
+        # Even a single row is too large — keep it anyway so the caller gets
+        # at least some data rather than an empty list.
+        kept = 1
+
+    data[row_field] = original_rows[:kept]
+    return kept
+
+
+def _bisect_string_length(
+    data: Dict[str, Any],
+    field: str,
+    original_value: str,
+    token_limit: int,
+) -> int:
+    """Binary-search for the largest string prefix that keeps data under limit.
+
+    Mutates ``data[field]`` during the search and leaves it at the final
+    kept length on return.
+    """
+    from superset.utils import json as utils_json
+
+    lo, hi = 0, len(original_value)
+    while lo < hi:
+        mid = (lo + hi + 1) // 2
+        data[field] = original_value[:mid]
+        if estimate_token_count(utils_json.dumps(data)) <= token_limit:
+            lo = mid
+        else:
+            hi = mid - 1
+
+    kept = lo
+    if kept == 0 and original_value:
+        kept = 1
+
+    data[field] = original_value[:kept]
+    return kept
+
+
+# Row-truncation advice, keyed by tool name. ``get_chart_data`` and
+# ``query_dataset`` cap output via a row/page-size parameter, not a SQL
+# clause, so the generic "Add a LIMIT clause" wording is wrong for them.
+_ROW_LIMIT_ADVICE = {
+    "get_chart_data": "Use the 'limit' parameter to restrict rows returned.",
+    "query_dataset": "Use the 'row_limit' parameter to restrict rows 
returned.",
+}
+_DEFAULT_ROW_LIMIT_ADVICE = (
+    "Add a LIMIT clause or reduce selected columns to get all rows."
+)
+
+
+def _truncate_rows_field(
+    data: Dict[str, Any],
+    row_field: str,
+    token_limit: int,
+    advice: str,
+) -> list[str] | None:
+    """Try to bisect ``data[row_field]`` down to fit the limit.
+
+    Returns the truncation notes on success, or ``None`` if no rows were
+    (or could be) dropped, in which case ``data`` is left unmodified.
+    """
+    original_rows: list[Any] = data[row_field]
+    original_count = len(original_rows)
+    if not original_rows:
+        return None
+
+    # Reserve space for the truncation-note metadata *before* bisecting so
+    # the search already accounts for it, rather than measuring fit on bare
+    # row data and finding out only afterward that appending the note
+    # pushed the payload back over the limit. The placeholder uses
+    # original_count for both halves of "X of Y rows", which is the
+    # longest the real note can ever be (kept <= original_count).
+    placeholder_note = (
+        f"Result truncated: {original_count} of {original_count} rows returned 
"
+        f"(limit ~{token_limit:,} tokens). {advice}"
+    )
+    data["_response_truncated"] = True
+    data["_truncation_notes"] = [placeholder_note]
+
+    kept = _bisect_row_limit(data, row_field, original_rows, token_limit)
+
+    if kept < original_count:
+        notes = [
+            f"Result truncated: {kept} of {original_count} rows returned "
+            f"(limit ~{token_limit:,} tokens). {advice}"
+        ]
+        data["_truncation_notes"] = notes
+        if "row_count" in data:
+            data["row_count"] = kept
+        return notes
+
+    # Reserving headroom turned out not to be needed after all — no rows
+    # were actually dropped, so undo the placeholder metadata.
+    del data["_response_truncated"]
+    del data["_truncation_notes"]
+    return None
+
+
+def _truncate_csv_data_field(
+    data: Dict[str, Any],
+    token_limit: int,
+    advice: str,
+) -> list[str] | None:
+    """Try to bisect the scalar ``csv_data`` field down to fit the limit.
+
+    Used when there are no rows to trim — e.g. a CSV export where ``data``
+    is empty and the actual payload lives in ``csv_data``. ``excel_data``
+    is base64-encoded binary and is intentionally left alone: cutting it
+    would produce a corrupt file, so oversized Excel exports still fall
+    through to the hard size-limit error.
+
+    Returns the truncation notes on success, or ``None`` if nothing could
+    be trimmed, in which case ``data`` is left unmodified.
+    """
+    csv_data = data.get("csv_data")
+    if not isinstance(csv_data, str) or not csv_data:
+        return None
+
+    original_len = len(csv_data)
+    # Same reservation trick as ``_truncate_rows_field``, keyed on the
+    # character count rather than a row count.
+    placeholder_note = (
+        f"CSV content truncated: kept {original_len:,} of {original_len:,} "
+        f"characters (limit ~{token_limit:,} tokens). {advice}"
+    )
+    data["_response_truncated"] = True
+    data["_truncation_notes"] = [placeholder_note]
+
+    kept_len = _bisect_string_length(data, "csv_data", csv_data, token_limit)
+    if kept_len < original_len:
+        notes = [
+            f"CSV content truncated: kept {kept_len:,} of {original_len:,} "
+            f"characters (limit ~{token_limit:,} tokens). {advice}"
+        ]
+        data["_truncation_notes"] = notes
+        return notes
+
+    del data["_response_truncated"]
+    del data["_truncation_notes"]
+    return None
+
+
+def truncate_query_result(
+    response: ToolResponse,
+    token_limit: int,
+    tool_name: str | None = None,
+) -> tuple[ToolResponse, bool, list[str]]:
+    """Truncate a data-query tool response to fit within the token limit.
+
+    Unlike ``truncate_oversized_response`` (which targets info-tool dict
+    fields), this function targets the rows/data list directly: it performs
+    a binary search to find the largest prefix of rows that keeps the
+    serialised payload under the limit, then records a truncation note so
+    the caller knows the result is partial.
+
+    Handles both dict responses and Pydantic models (e.g. ExecuteSqlResponse,
+    QueryDatasetResponse, GetChartDataResponse) that contain a ``rows`` or
+    ``data`` field. Note that summary/column-level statistics (e.g.
+    ``summary``, per-column ``null_count``/``unique_count``, ``insights``)
+    describe the pre-truncation dataset and are not recomputed here; callers
+    rely on ``_truncation_notes`` and the true ``total_rows`` to catch this.
+
+    Args:
+        response: The tool response containing tabular row data.
+        token_limit: Maximum estimated tokens allowed.
+        tool_name: Name of the calling tool, used to tailor the truncation
+            advice (e.g. ``limit`` vs. ``row_limit`` vs. SQL ``LIMIT``).
+
+    Returns:
+        A tuple of (possibly-truncated response, was_truncated, list of notes).
+    """
+    from superset.utils import json as utils_json
+
+    advice = _ROW_LIMIT_ADVICE.get(tool_name or "", _DEFAULT_ROW_LIMIT_ADVICE)
+
+    if hasattr(response, "model_dump"):
+        data = response.model_dump()
+    elif isinstance(response, dict):
+        data = dict(response)
+    else:
+        return response, False, []
+
+    # Find which field holds the row list.
+    row_field: str | None = None
+    for field in _DATA_ROW_FIELDS:
+        if field in data and isinstance(data[field], list):
+            row_field = field
+            break
+
+    if row_field is None:
+        # No recognised row field — fall back to generic field truncation.
+        return truncate_oversized_response(response, token_limit)
+
+    if estimate_token_count(utils_json.dumps(data)) <= token_limit:
+        return data, False, []
+
+    notes = _truncate_rows_field(data, row_field, token_limit, advice)
+    if notes is None:
+        notes = _truncate_csv_data_field(data, token_limit, advice)
+
+    return data, notes is not None, notes or []
+
+
 def format_size_limit_error(
     tool_name: str,
     params: Dict[str, Any] | None,
diff --git a/tests/unit_tests/mcp_service/test_middleware.py 
b/tests/unit_tests/mcp_service/test_middleware.py
index 818450e049c..62248710911 100644
--- a/tests/unit_tests/mcp_service/test_middleware.py
+++ b/tests/unit_tests/mcp_service/test_middleware.py
@@ -380,6 +380,263 @@ class TestResponseSizeGuardMiddleware:
         # native_filters (48 items) fits under the custom cap, untouched
         assert len(result["native_filters"]) == 48
 
+    @pytest.mark.asyncio
+    async def test_truncates_execute_sql_rows_instead_of_blocking(self) -> 
None:
+        """execute_sql should truncate rows, not raise ToolError."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "status": "success",
+            "rows": [row] * 200,
+            "columns": [{"name": f"col_{i}", "type": "STRING"} for i in 
range(10)],
+            "row_count": 200,
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert isinstance(result, dict)
+        assert result["_response_truncated"] is True
+        assert len(result["rows"]) < 200
+        assert isinstance(result.get("_truncation_notes"), list)
+        assert result["_truncation_notes"]
+
+    @pytest.mark.asyncio
+    async def test_truncates_query_dataset_data_field(self) -> None:
+        """query_dataset should truncate the 'data' list, not raise 
ToolError."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "query_dataset"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "dataset_id": 1,
+            "dataset_name": "test",
+            "data": [row] * 200,
+            "row_count": 200,
+            "summary": "",
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert isinstance(result, dict)
+        assert result["_response_truncated"] is True
+        assert len(result["data"]) < 200
+
+    @pytest.mark.asyncio
+    async def test_truncates_get_chart_data_rows(self) -> None:
+        """get_chart_data should truncate rows, not raise ToolError."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "get_chart_data"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "chart_id": 1,
+            "chart_name": "test chart",
+            "chart_type": "table",
+            "data": [row] * 200,
+            "row_count": 200,
+            "summary": "",
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert isinstance(result, dict)
+        assert result["_response_truncated"] is True
+        assert len(result["data"]) < 200
+
+    @pytest.mark.asyncio
+    async def test_data_query_truncation_updates_row_count(self) -> None:
+        """row_count should reflect the truncated count, not the original."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "status": "success",
+            "rows": [row] * 200,
+            "row_count": 200,
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert result["_response_truncated"] is True
+        assert result["row_count"] == len(result["rows"])
+
+    @pytest.mark.asyncio
+    async def test_data_query_truncation_note_mentions_limit_clause(self) -> 
None:
+        """Truncation note must tell the caller to add a LIMIT clause."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "status": "success",
+            "rows": [row] * 200,
+            "row_count": 200,
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        notes = result.get("_truncation_notes", [])
+        assert notes, "Should have at least one truncation note"
+        assert any("LIMIT" in note for note in notes)
+
+    @pytest.mark.asyncio
+    async def test_data_query_truncation_logs_truncation_event(self) -> None:
+        """Should log mcp_response_truncated (not size_exceeded) for query 
tools."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_response = {
+            "status": "success",
+            "rows": [row] * 200,
+            "row_count": 200,
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger") as 
mock_event_logger,
+        ):
+            await middleware.on_call_tool(context, call_next)
+
+        mock_event_logger.log.assert_called()
+        assert (
+            mock_event_logger.log.call_args.kwargs["action"] == 
"mcp_response_truncated"
+        )
+
+    @pytest.mark.asyncio
+    async def test_truncates_get_chart_data_csv_export(self) -> None:
+        """CSV exports (data=[], payload in csv_data) should be truncated 
too."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+
+        context = MagicMock()
+        context.message.name = "get_chart_data"
+        context.message.params = {}
+
+        large_response: dict[str, Any] = {
+            "chart_id": 1,
+            "chart_name": "test chart",
+            "chart_type": "table",
+            "columns": [],
+            "data": [],
+            "row_count": 200,
+            "summary": "",
+            "csv_data": "col_0,col_1\n" + ("value,value\n" * 2000),
+            "format": "csv",
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert isinstance(result, dict)
+        assert result["_response_truncated"] is True
+        assert len(result["csv_data"]) < len(large_response["csv_data"])
+
+    @pytest.mark.asyncio
+    async def test_data_query_blocks_when_single_row_still_exceeds_limit(self) 
-> None:
+        """Should raise ToolError, not ship an over-budget response.
+
+        ``_bisect_row_limit`` always keeps at least one row when the
+        original list is non-empty, even if that one row alone exceeds the
+        token limit. The middleware must re-check the truncated size and
+        fall back to the hard error rather than treating this as success.
+        """
+        middleware = ResponseSizeGuardMiddleware(token_limit=50)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        huge_row = {"col": "x" * 5000}
+        large_response = {
+            "status": "success",
+            "rows": [huge_row] * 3,
+            "row_count": 3,
+        }
+        call_next = AsyncMock(return_value=large_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+            pytest.raises(ToolError),
+        ):
+            await middleware.on_call_tool(context, call_next)
+
+    @pytest.mark.asyncio
+    async def test_data_query_under_limit_passes_through(self) -> None:
+        """Small query results should pass through unchanged."""
+        middleware = ResponseSizeGuardMiddleware(token_limit=25000)
+
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        small_response = {
+            "status": "success",
+            "rows": [{"a": 1, "b": 2}] * 3,
+            "row_count": 3,
+        }
+        call_next = AsyncMock(return_value=small_response)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert result == small_response
+        assert "_response_truncated" not in result
+
 
 class TestCreateResponseSizeGuardMiddleware:
     """Test create_response_size_guard_middleware factory function."""
@@ -753,6 +1010,44 @@ class TestToolResultWrapping:
         ):
             await middleware.on_call_tool(context, call_next)
 
+    @pytest.mark.asyncio
+    async def test_data_query_tool_result_is_truncated_and_rewrapped(self) -> 
None:
+        """Truncate a ToolResult-wrapped execute_sql response and re-wrap it.
+
+        Regression test for the production path: FastMCP always wraps tool
+        return values in ToolResult before middleware sees them, so
+        data-query truncation must be exercised through that wrapper, not
+        just against a plain dict.
+        """
+        from fastmcp.tools.tool import ToolResult
+
+        from superset.utils import json
+
+        middleware = ResponseSizeGuardMiddleware(token_limit=500)
+        context = MagicMock()
+        context.message.name = "execute_sql"
+        context.message.params = {}
+
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        large_payload = {
+            "status": "success",
+            "rows": [row] * 200,
+            "row_count": 200,
+        }
+        tool_result = self._make_tool_result(large_payload)
+        call_next = AsyncMock(return_value=tool_result)
+
+        with (
+            patch("superset.mcp_service.middleware.get_user_id", 
return_value=1),
+            patch("superset.mcp_service.middleware.event_logger"),
+        ):
+            result = await middleware.on_call_tool(context, call_next)
+
+        assert isinstance(result, ToolResult)
+        reparsed = json.loads(result.content[0].text)
+        assert reparsed["_response_truncated"] is True
+        assert len(reparsed["rows"]) < 200
+
     @pytest.mark.asyncio
     async def test_meta_preserved_after_truncation(self) -> None:
         """Should preserve the original ToolResult meta through truncation."""
diff --git a/tests/unit_tests/mcp_service/utils/test_token_utils.py 
b/tests/unit_tests/mcp_service/utils/test_token_utils.py
index f42401385a6..d183bd770b7 100644
--- a/tests/unit_tests/mcp_service/utils/test_token_utils.py
+++ b/tests/unit_tests/mcp_service/utils/test_token_utils.py
@@ -40,6 +40,7 @@ from superset.mcp_service.utils.token_utils import (
     get_response_size_bytes,
     INFO_TOOLS,
     truncate_oversized_response,
+    truncate_query_result,
 )
 
 
@@ -749,3 +750,105 @@ class TestTruncateOversizedResponse:
         assert isinstance(result, dict)
         assert len(result["charts"]) == 5
         assert any("form_data" in n for n in notes)
+
+
+class TestTruncateQueryResult:
+    """Tests for ``truncate_query_result`` (data-query row/scalar 
truncation)."""
+
+    def _rows_response(self, row_field: str, count: int = 200) -> dict[str, 
Any]:
+        row = {f"col_{i}": f"value_{i}" for i in range(10)}
+        return {
+            "status": "success",
+            row_field: [row] * count,
+            "row_count": count,
+        }
+
+    def test_no_truncation_needed(self) -> None:
+        response = self._rows_response("rows", count=3)
+        result, was_truncated, notes = truncate_query_result(response, 25000)
+        assert was_truncated is False
+        assert notes == []
+        assert result == response
+
+    def test_truncated_result_fits_under_limit(self) -> None:
+        """The final payload (rows + note metadata) must itself fit.
+
+        Regression test: the note is built from the kept row count, but
+        that note text also consumes tokens. The bisection must reserve
+        room for it up front rather than measuring fit on bare rows and
+        appending the note afterward, which could push the final payload
+        back over the limit.
+        """
+        response = self._rows_response("rows")
+        result, was_truncated, notes = truncate_query_result(response, 500)
+        assert was_truncated is True
+        assert isinstance(result, dict)
+        assert estimate_response_tokens(result) <= 500
+        assert result["row_count"] == len(result["rows"])
+        assert result["row_count"] < 200
+
+    def test_single_oversized_row_is_kept_anyway(self) -> None:
+        """A single row that alone exceeds the limit is still returned.
+
+        ``truncate_query_result`` always keeps >=1 row when the original
+        list is non-empty; it is the caller's (middleware) job to reject
+        a still-oversized result rather than ship it silently.
+        """
+        response = {
+            "status": "success",
+            "rows": [{"col": "x" * 5000}] * 3,
+            "row_count": 3,
+        }
+        result, was_truncated, notes = truncate_query_result(response, 50)
+        assert was_truncated is True
+        assert isinstance(result, dict)
+        assert len(result["rows"]) == 1
+        assert notes
+
+    def test_truncates_csv_scalar_field_when_rows_empty(self) -> None:
+        """CSV exports carry their payload in ``csv_data`` with ``data=[]``."""
+        response: dict[str, Any] = {
+            "chart_id": 1,
+            "data": [],
+            "csv_data": "col_0,col_1\n" + ("value,value\n" * 2000),
+            "format": "csv",
+        }
+        result, was_truncated, notes = truncate_query_result(
+            response, 500, tool_name="get_chart_data"
+        )
+        assert was_truncated is True
+        assert isinstance(result, dict)
+        assert len(result["csv_data"]) < len(response["csv_data"])
+        assert estimate_response_tokens(result) <= 500
+        assert any("CSV" in n for n in notes)
+
+    def test_does_not_truncate_excel_binary_field(self) -> None:
+        """excel_data is base64 binary — truncating it would corrupt the 
file."""
+        response = {
+            "chart_id": 1,
+            "data": [],
+            "excel_data": "QUJDREVGRw==" * 5000,
+            "format": "excel",
+        }
+        result, was_truncated, notes = truncate_query_result(response, 500)
+        assert was_truncated is False
+        assert notes == []
+        assert isinstance(result, dict)
+        assert result["excel_data"] == response["excel_data"]
+
+    def test_get_chart_data_advice_mentions_limit_param(self) -> None:
+        response = self._rows_response("data")
+        _, _, notes = truncate_query_result(response, 500, 
tool_name="get_chart_data")
+        assert any("'limit' parameter" in n for n in notes)
+        assert not any("LIMIT clause" in n for n in notes)
+
+    def test_query_dataset_advice_mentions_row_limit_param(self) -> None:
+        response = self._rows_response("data")
+        _, _, notes = truncate_query_result(response, 500, 
tool_name="query_dataset")
+        assert any("'row_limit' parameter" in n for n in notes)
+        assert not any("LIMIT clause" in n for n in notes)
+
+    def test_execute_sql_advice_mentions_limit_clause(self) -> None:
+        response = self._rows_response("rows")
+        _, _, notes = truncate_query_result(response, 500, 
tool_name="execute_sql")
+        assert any("LIMIT clause" in n for n in notes)


Reply via email to