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 c0c688d3e4b fix(mcp): return a specific error per failure class on 
tool calls (#44387)
c0c688d3e4b is described below

commit c0c688d3e4b2696bf481757f5364a5dcc4184fbe
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Sep 24 03:24:45 2026 +1000

    fix(mcp): return a specific error per failure class on tool calls (#44387)
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 docs/admin_docs/configuration/mcp-server.mdx       |  16 +
 superset/mcp_service/chart/compile.py              |  29 +-
 superset/mcp_service/constants.py                  |  28 +
 superset/mcp_service/middleware.py                 | 287 ++++++++-
 .../mcp_service/test_error_classification.py       | 679 +++++++++++++++++++++
 5 files changed, 995 insertions(+), 44 deletions(-)

diff --git a/docs/admin_docs/configuration/mcp-server.mdx 
b/docs/admin_docs/configuration/mcp-server.mdx
index 086dce6b9a4..a8bb4d6a92c 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1175,6 +1175,22 @@ answer. Dashboard access checks and row-level security 
remain independent of thi
 display-context projection. Integrations must ensure the lookup actually 
happens
 and the assistant uses the result; tool guidance alone cannot guarantee this.
 
+## Tool-call errors
+
+For exceptions raised by tools, the shared error handler distinguishes 
permission
+denials, invalid arguments, datasource failures, invalid SQL, and internal 
errors.
+Datasource responses include an enumerated reason rather than raw driver 
output;
+internal errors return an error ID rather than implementation details. Tools 
such
+as `get_chart_data` that return structured error envelopes instead of raising 
do
+not pass through this handler.
+
+Missing tables, columns, and schemas log at `WARNING` without invoking
+`MCP_ERROR_HOOK`, even when the exception inherits status 500. Connection 
failures
+with status 500 or higher remain `ERROR` and invoke the hook. An explicitly
+sub-500 exception remains a warning even if it carries a connection error 
reason:
+the generic engine reason can also describe a caller's malformed column, and a
+connection access denial is still a permission failure.
+
 ## Result serialization
 
 Data-bearing tool responses sanitize row values, column samples, and statistics
diff --git a/superset/mcp_service/chart/compile.py 
b/superset/mcp_service/chart/compile.py
index 4befcb8f5be..b97ab8ec2ce 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -40,7 +40,6 @@ from typing import Any, Dict, List, Literal
 from sqlalchemy.exc import SQLAlchemyError
 
 from superset.commands.exceptions import CommandException
-from superset.errors import SupersetErrorType
 from superset.mcp_service.chart.query_result import (
     normalize_chart_query_result,
     query_result_failure,
@@ -57,34 +56,10 @@ from superset.mcp_service.common.error_schemas import (
     ColumnSuggestion,
     DatasetContext,
 )
+from superset.mcp_service.constants import CONNECTION_ERROR_TYPES
 
 logger = logging.getLogger(__name__)
 
-# Error types from db_engine_spec.extract_errors() that indicate a database
-# connectivity or authentication issue rather than a query/config problem.
-#
-# GENERIC_DB_ENGINE_ERROR is included because many engines (BigQuery,
-# Snowflake, Athena, Databricks, Trino) lack specific CONNECTION_* regex
-# patterns in their engine specs — all their connection failures fall back
-# to this generic type. This is safe here because _compile_chart only runs
-# after Tier 1 schema validation has already verified columns, metrics, and
-# filters against the dataset. At that point the SQL is auto-generated by
-# Superset's query builder, so genuine SQL/config errors are very unlikely.
-_CONNECTION_ERROR_TYPES = {
-    SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR,
-    SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR,
-    SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
-    SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
-    SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
-    SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
-    SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
-    SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
-    SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
-    SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
-    SupersetErrorType.CONNECTION_DATABASE_TIMEOUT,
-    SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
-}
-
 
 @dataclass
 class CompileResult:
@@ -354,7 +329,7 @@ def _classify_as_database_error(exc: BaseException, 
dataset_id: int) -> bool:
         dataset = DatasetDAO.find_by_id(dataset_id)
         if dataset and dataset.database and isinstance(exc, Exception):
             errors = dataset.database.db_engine_spec.extract_errors(exc)
-            return any(e.error_type in _CONNECTION_ERROR_TYPES for e in errors)
+            return any(e.error_type in CONNECTION_ERROR_TYPES for e in errors)
     except Exception:  # pylint: disable=broad-except
         logger.debug(
             "Failed to classify error via engine spec for dataset %s: %s",
diff --git a/superset/mcp_service/constants.py 
b/superset/mcp_service/constants.py
index 4e6b8a9c240..0b435dba815 100644
--- a/superset/mcp_service/constants.py
+++ b/superset/mcp_service/constants.py
@@ -18,6 +18,8 @@
 
 from typing import Literal
 
+from superset.errors import SupersetErrorType
+
 # Supported model types for schema discovery and MCP tools
 ModelType = Literal["chart", "dataset", "dashboard", "database", "report"]
 
@@ -30,3 +32,29 @@ DEFAULT_MAX_RESPONSE_BYTES = 50_000  # ~50KB preserves the 
former 25K-token guar
 DEFAULT_WARN_THRESHOLD_PCT = 80  # Log warnings above 80% of limit
 # Phase 2 list-field truncation cap; matches MAX_PAGE_SIZE
 DEFAULT_MAX_LIST_ITEMS = 100
+
+# Error types that mean the connection to the analytics database failed, as
+# opposed to the query being malformed. Shared by chart compilation (which
+# retries/reports connection trouble separately) and the MCP error handler
+# (which must not blame the caller for an unreachable datasource).
+#
+# GENERIC_DB_ENGINE_ERROR is included because many engines (BigQuery,
+# Snowflake, Athena, Databricks, Trino) lack specific CONNECTION_* regex
+# patterns in their engine specs — all their connection failures fall back
+# to this generic type.
+CONNECTION_ERROR_TYPES = frozenset(
+    {
+        SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+        SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
+        SupersetErrorType.CONNECTION_DATABASE_TIMEOUT,
+        SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+        SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+        SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR,
+        SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
+        SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR,
+        SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+        SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+        SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
+        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+    }
+)
diff --git a/superset/mcp_service/middleware.py 
b/superset/mcp_service/middleware.py
index 59893fd9fdf..35c4cce2535 100644
--- a/superset/mcp_service/middleware.py
+++ b/superset/mcp_service/middleware.py
@@ -38,7 +38,15 @@ from superset.commands.exceptions import (
     ForbiddenError,
     ObjectNotFoundError,
 )
-from superset.exceptions import SupersetException, SupersetSecurityException
+from superset.errors import SupersetErrorType
+from superset.exceptions import (
+    ColumnNotFoundException,
+    DatabaseNotFound,
+    SupersetErrorsException,
+    SupersetException,
+    SupersetGenericDBErrorException,
+    SupersetSecurityException,
+)
 from superset.extensions import event_logger, stats_logger_manager
 from superset.mcp_service.auth import (
     _get_app_context_manager,
@@ -49,6 +57,7 @@ from superset.mcp_service.auth import (
     MCPPermissionDeniedError,
 )
 from superset.mcp_service.constants import (
+    CONNECTION_ERROR_TYPES,
     DEFAULT_MAX_LIST_ITEMS,
     DEFAULT_MAX_RESPONSE_BYTES,
     DEFAULT_WARN_THRESHOLD_PCT,
@@ -209,6 +218,204 @@ def _invoke_error_hook(error: Exception, hook_context: 
dict[str, Any]) -> None:
         logger.warning("MCP_ERROR_HOOK raised an exception: %s", hook_error)
 
 
+# The prefix FastMCP puts on every ToolError it wraps a tool exception in.
+# Pinned by a guard test so an upstream change surfaces as a clear failure
+# rather than silently reinstating the undifferentiated-error bug.
+_FASTMCP_WRAPPED_ERROR_PREFIX = "Error calling tool "
+
+
+def _unwrap_tool_error(error: Exception) -> Exception:
+    """Return the exception a ``ToolError`` was raised from, if any.
+
+    A tool failure is wrapped twice on its way out: FastMCP wraps whatever the
+    tool body raised, and :class:`GlobalErrorHandlerMiddleware` re-raises its
+    classified message as a fresh ``ToolError`` chained off the same cause.
+    Either way the real failure is the ``__cause__``.
+
+    This is for *classification only* — log severity, metrics, and
+    error-tracker capture — where attributing a failure to the wrapper rather
+    than to ``OperationalError`` or ``MCPPermissionDeniedError`` loses the
+    distinction that matters. It does not decide client-facing text; use
+    :func:`_unwrap_fastmcp_wrapped_error` for that.
+    """
+    if isinstance(error, ToolError) and isinstance(error.__cause__, Exception):
+        return error.__cause__
+    return error
+
+
+def _unwrap_fastmcp_wrapped_error(error: Exception) -> Exception:
+    """Return the original exception behind *FastMCP's* ``ToolError`` wrapper.
+
+    FastMCP catches every non-``FastMCPError`` raised inside a tool body and
+    re-raises it as ``ToolError(f"Error calling tool {name!r}: {e}") from e``
+    *before* any middleware error hook runs (see ``FastMCP._call_tool``). By
+    the time :class:`GlobalErrorHandlerMiddleware` sees a tool failure, the
+    concrete type — ``MCPPermissionDeniedError``, ``SupersetException``,
+    ``OperationalError`` — is no longer the exception itself, only its
+    ``__cause__``. Classifying the wrapper instead of the cause collapses
+    every distinct failure into one undifferentiated message.
+
+    A ``ToolError`` raised deliberately by tool code is already formatted for
+    MCP and is returned as-is. Tool code may legitimately chain one off
+    another exception (``raise ToolError(...) from exc``), so ``__cause__``
+    alone does not identify FastMCP's wrapper — the message prefix is
+    required as well, otherwise a tool-authored message would be discarded
+    and replaced by cause-based handling.
+    """
+    if (
+        isinstance(error, ToolError)
+        and isinstance(error.__cause__, Exception)
+        and str(error).startswith(_FASTMCP_WRAPPED_ERROR_PREFIX)
+    ):
+        return error.__cause__
+    return error
+
+
+# Exception classes that mean "the query behind this tool failed", not "the
+# caller used the tool wrong". The tool name and arguments were valid; the
+# datasource, table, column, or connection it reads is broken or gone.
+#
+# Deliberately narrow: only classes that are datasource-scoped *by definition*
+# and that do not reliably carry a recognisable ``SupersetErrorType``. Broader
+# classes are matched by error type instead (see _DATASOURCE_ERROR_TYPES),
+# because the class alone does not establish that the datasource is at fault:
+#
+# - ``SQLAlchemyError`` also covers metadata-database IntegrityError /
+#   ProgrammingError raised while a tool reads Superset's own metastore.
+# - ``SupersetTimeoutException`` is the generic timeout class; SigalrmTimeout
+#   and TimerTimeout raise it with BACKEND_TIMEOUT_ERROR.
+# - ``QueryObjectValidationError`` is raised for missing/invalid query fields
+#   and invalid result types, which are caller or configuration problems.
+#
+# Misclassifying any of those would tell the caller their arguments were valid
+# and blame a datasource that is in fact healthy.
+_DATASOURCE_ERROR_EXCEPTIONS = (
+    ColumnNotFoundException,
+    DatabaseNotFound,
+    SupersetGenericDBErrorException,
+)
+
+# ``SupersetError.error_type`` values in the DB-engine, viz, and SQL Lab
+# families. Superset raises a bare ``SupersetErrorException`` for many of
+# these, so the exception class alone is not enough to classify them. The
+# connection half comes from the shared CONNECTION_ERROR_TYPES set so this
+# cannot drift out of sync with chart compilation's view of the same thing.
+_DATASOURCE_ERROR_TYPES = (
+    frozenset(
+        {
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.DATABASE_NOT_FOUND_ERROR,
+            SupersetErrorType.FAILED_FETCHING_DATASOURCE_INFO_ERROR,
+            SupersetErrorType.INVALID_SQL_ERROR,
+            SupersetErrorType.OBJECT_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.RESULTS_BACKEND_ERROR,
+            SupersetErrorType.SCHEMA_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.SQLLAB_TIMEOUT_ERROR,
+            SupersetErrorType.SYNTAX_ERROR,
+            SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.TABLE_NOT_FOUND_ERROR,
+            SupersetErrorType.UNKNOWN_DATASOURCE_TYPE_ERROR,
+            SupersetErrorType.VIZ_GET_DF_ERROR,
+        }
+    )
+    | CONNECTION_ERROR_TYPES
+)
+
+# Surfaced when a datasource failure carries no recognised SupersetErrorType.
+# A fixed sentinel, never a Python class name: the reason is part of the
+# client-facing message and must stay a closed, non-sensitive vocabulary.
+_GENERIC_DATASOURCE_REASON = "DATASOURCE_QUERY_FAILED"
+
+# Reasons where the *query* is at fault, not the datasource. For a
+# SQL-authoring tool the query text is itself an argument, so telling the
+# caller "your arguments were valid" would steer an agent away from fixing
+# its own malformed SQL.
+_QUERY_SYNTAX_REASONS = frozenset(
+    {
+        SupersetErrorType.INVALID_SQL_ERROR.value,
+        SupersetErrorType.SYNTAX_ERROR.value,
+    }
+)
+
+# Reasons that mean the connection to the analytics database is unhealthy —
+# an operational problem worth paging on, unlike a missing table.
+_CONNECTION_REASONS = frozenset(t.value for t in CONNECTION_ERROR_TYPES)
+
+
+def _datasource_error_reason(error: Exception) -> str | None:
+    """Return the enumerated reason for a datasource failure, if any.
+
+    Only ``SupersetErrorType`` members are returned — they are a closed,
+    non-sensitive vocabulary. Raw driver output (which can carry SQL, table
+    contents, or connection strings) is never surfaced from here.
+    """
+    errors = getattr(error, "errors", None)
+    single_error = getattr(error, "error", None)
+    if isinstance(error, SupersetErrorsException) and errors:
+        # SupersetErrorsException carries a list of SupersetError. Scan all of
+        # them, not just the first: a datasource failure reported alongside
+        # other errors would otherwise lose its specific reason.
+        candidates = [getattr(err, "error_type", None) for err in errors]
+    elif single_error is not None:
+        # SupersetErrorException carries a single SupersetError.
+        candidates = [getattr(single_error, "error_type", None)]
+    else:
+        # Plain SupersetException exposes error_type directly.
+        candidates = [getattr(error, "error_type", None)]
+    for error_type in candidates:
+        if error_type in _DATASOURCE_ERROR_TYPES:
+            return str(getattr(error_type, "value", error_type))
+    return None
+
+
+def _is_datasource_error(error: Exception) -> bool:
+    """Classify a failure as coming from the datasource behind the tool.
+
+    ``SupersetSecurityException`` subclasses ``SupersetErrorException``, so
+    callers must check for permission failures *before* calling this.
+    """
+    return (
+        isinstance(error, _DATASOURCE_ERROR_EXCEPTIONS)
+        or _datasource_error_reason(error) is not None
+    )
+
+
+def _datasource_error_is_user_error(error: Exception) -> bool | None:
+    """Severity for a datasource failure; ``None`` if it is not one.
+
+    :func:`_is_user_error` keys on ``SupersetException.status``, but Superset
+    raises a *bare* ``SupersetErrorException`` for most datasource failures,
+    and that does not override ``SupersetException.status = 500``. A dropped
+    table therefore logs at ERROR with a traceback and fires
+    ``MCP_ERROR_HOOK``, paging on what is routine MCP traffic — an agent
+    pointing at a chart whose table was renamed.
+
+    Classify by what actually failed instead of by an inherited default: an
+    unreachable or misconfigured *connection* is an operational problem worth
+    paging on; a missing table, column, or schema is not.
+
+    This only ever *de-escalates*. A sub-500 status is a deliberate judgement
+    by the exception class that the caller is at fault, and is never
+    overridden — otherwise the connection half of the allow-list would page
+    on exactly the errors this function exists to stop paging on. The
+    catch-all ``GENERIC_DB_ENGINE_ERROR`` makes that concrete: engines
+    without specific ``CONNECTION_*`` regexes (BigQuery, Snowflake, Athena,
+    Databricks, Trino) report a malformed adhoc column through it, carried by
+    a status-400 ``SupersetGenericDBErrorException``. Genuine connection
+    failures arrive as a bare status-500 ``SupersetErrorException`` and still
+    page.
+
+    Returns ``None`` when no recognised reason is available, leaving the
+    existing status-based judgement in place.
+    """
+    reason = _datasource_error_reason(error)
+    if reason is None:
+        return None
+    if getattr(error, "status", 500) < 500:
+        return True
+    return reason not in _CONNECTION_REASONS
+
+
 # Errors caused by the LLM/user — expected in normal MCP operation.
 # Agents send bad params, try tools they lack access to, request nonexistent
 # resources. These are 400-class errors and should be logged at WARNING.
@@ -669,15 +876,11 @@ class LoggingMiddleware(Middleware):
                 )
             return result
         except Exception as exc:
-            # GlobalErrorHandlerMiddleware (inner) wraps tool exceptions in
-            # ToolError with the original attached as __cause__; unwrap it so
-            # error_type and the user/system classification reflect the real
-            # failure rather than the ToolError wrapper.
-            original = (
-                exc.__cause__
-                if isinstance(exc, ToolError) and exc.__cause__ is not None
-                else exc
-            )
+            # Tool exceptions arrive wrapped in ToolError with the original
+            # attached as __cause__; unwrap it so error_type and the
+            # user/system classification reflect the real failure rather than
+            # the ToolError wrapper.
+            original = _unwrap_tool_error(exc)
             error_type = type(original).__name__
             raised_is_user_error = _is_user_error(original)
             success = False
@@ -998,12 +1201,24 @@ class GlobalErrorHandlerMiddleware(Middleware):
 
     async def _handle_error(  # noqa: C901
         self,
-        error: Exception,
+        wrapped_error: Exception,
         context: MiddlewareContext,
         tool_name: str,
         duration_ms: int,
     ) -> None:
-        """Handle different types of errors with appropriate responses"""
+        """Handle different types of errors with appropriate responses.
+
+        ``wrapped_error`` is what reached the middleware; ``error`` is the
+        real failure. FastMCP re-raises everything a tool body throws as
+        ``ToolError(...) from e`` before any middleware runs, so classifying
+        the exception as received would funnel an RBAC denial, a dead table,
+        and an internal bug into the same message. Every decision below —
+        log severity, error-tracker capture, and the client-facing text — is
+        therefore made on the unwrapped cause. See
+        :func:`_unwrap_fastmcp_wrapped_error`.
+        """
+        error = _unwrap_fastmcp_wrapped_error(wrapped_error)
+
         # Extract user context for logging
         user_id = None
         try:
@@ -1015,6 +1230,12 @@ class GlobalErrorHandlerMiddleware(Middleware):
         # system errors (unexpected) → ERROR
         sanitized_error = _sanitize_error_for_logging(error)
         is_user = _is_user_error(error)
+        # A datasource failure's severity follows what actually broke, not the
+        # 500 status a bare SupersetErrorException inherits. See
+        # _datasource_error_is_user_error.
+        datasource_is_user = _datasource_error_is_user_error(error)
+        if datasource_is_user is not None:
+            is_user = datasource_is_user
         log_fn = logger.warning if is_user else logger.error
         log_fn(
             "MCP tool call failed: tool=%s, user_id=%s, "
@@ -1071,7 +1292,9 @@ class GlobalErrorHandlerMiddleware(Middleware):
 
         # Handle specific error types with appropriate responses
         if isinstance(error, ToolError):
-            # Tool errors are already formatted for MCP
+            # A ToolError that survived _unwrap_fastmcp_wrapped_error was
+            # raised deliberately by tool code (it carries no cause, or no
+            # FastMCP wrapper prefix) and is already formatted for MCP.
             raise error
         elif isinstance(error, ValidationError):
             # Pydantic validation errors
@@ -1100,12 +1323,19 @@ class GlobalErrorHandlerMiddleware(Middleware):
                 f"Service error in {tool_name}: 
{_sanitize_error_for_logging(error)}"
             ) from error
         elif isinstance(error, MCPPermissionDeniedError):
-            # MCP RBAC permission denied — convert to structured ToolError.
-            # Must come before the generic PermissionError branch because
-            # MCPPermissionDeniedError inherits from PermissionError.
+            # MCP RBAC permission denied. Rendered from the exception's own
+            # structured fields ("Permission denied: <permission> on
+            # <resource>") rather than through _sanitize_error_for_logging,
+            # which flattens every PermissionError to "Access denied" and
+            # would throw away the two facts that make the denial
+            # actionable. Must come before the generic PermissionError
+            # branch because MCPPermissionDeniedError inherits from it.
             raise ToolError(str(error)) from error
         elif isinstance(error, PermissionError):
-            # Permission/authorization errors
+            # Authorization failures that are not raised by the MCP RBAC
+            # decorator still get the "Permission denied" shape, so callers
+            # can tell a denial from a malformed call no matter which layer
+            # refused them.
             raise ToolError(
                 f"Permission denied for {tool_name}: "
                 f"You don't have access to this resource."
@@ -1127,6 +1357,29 @@ class GlobalErrorHandlerMiddleware(Middleware):
                 f"Permission denied for {tool_name}: "
                 f"{_sanitize_error_for_logging(error)}"
             ) from error
+        elif _is_datasource_error(error):
+            # The query behind the tool failed. The caller must NOT be told to
+            # re-check the tool schema — that advice fits an argument error and
+            # sends them down the wrong path for a dropped table or a dead
+            # connection. Only the enumerated SupersetErrorType is echoed; raw
+            # driver output could carry SQL or connection details.
+            reason = _datasource_error_reason(error) or 
_GENERIC_DATASOURCE_REASON
+            if reason in _QUERY_SYNTAX_REASONS:
+                # The datasource is fine; the query is malformed. For a
+                # SQL-authoring tool that query is the caller's own argument,
+                # so blaming the datasource would steer an agent away from
+                # the one thing it can actually fix.
+                raise ToolError(
+                    f"Query error in {tool_name}: the datasource rejected the "
+                    f"query as invalid ({reason}). Fix the query itself — the "
+                    f"datasource is reachable."
+                ) from error
+            raise ToolError(
+                f"Datasource error in {tool_name}: the query against the "
+                f"underlying datasource failed ({reason}). The tool name and "
+                f"arguments were valid — the datasource, table, or column it "
+                f"reads may be missing, renamed, or unreachable."
+            ) from error
         elif isinstance(error, SupersetException):
             # Other Superset errors — .status determines severity (already
             # classified by _is_user_error above for log level)
diff --git a/tests/unit_tests/mcp_service/test_error_classification.py 
b/tests/unit_tests/mcp_service/test_error_classification.py
new file mode 100644
index 00000000000..bde63de30be
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_error_classification.py
@@ -0,0 +1,679 @@
+# 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.
+
+"""Per-failure-class error classification for MCP tool calls.
+
+Three unrelated failures used to reach MCP clients as one undifferentiated
+message, because FastMCP re-raises everything a tool body throws as
+``ToolError(f"Error calling tool {name!r}: {e}") from e`` *before* any
+middleware error hook runs. ``GlobalErrorHandlerMiddleware`` then matched its
+first branch, ``isinstance(error, ToolError)``, and re-raised — so its entire
+per-type branch chain was unreachable for tool failures, and the client got
+whatever ``str(exc)`` happened to say.
+
+The rest of the suite exercises that chain by injecting *raw* exceptions into
+``on_message``, which is why the gap went unnoticed: no test drove a tool
+exception through FastMCP's wrapping first. These tests do, end to end.
+"""
+
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.exceptions import ToolError
+from sqlalchemy.exc import IntegrityError, OperationalError
+
+from superset.commands.exceptions import ForbiddenError
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import (
+    DatabaseNotFound,
+    QueryObjectValidationError,
+    SupersetErrorException,
+    SupersetErrorsException,
+    SupersetException,
+    SupersetGenericDBErrorException,
+    SupersetSecurityException,
+    SupersetTimeoutException,
+)
+from superset.mcp_service.auth import MCPPermissionDeniedError
+from superset.mcp_service.constants import CONNECTION_ERROR_TYPES
+from superset.mcp_service.middleware import (
+    _datasource_error_is_user_error,
+    _datasource_error_reason,
+    _FASTMCP_WRAPPED_ERROR_PREFIX,
+    _GENERIC_DATASOURCE_REASON,
+    _is_datasource_error,
+    _is_user_error,
+    _unwrap_fastmcp_wrapped_error,
+    _unwrap_tool_error,
+    GlobalErrorHandlerMiddleware,
+    ToolResultCompatibilityMiddleware,
+)
+
+# A connection string and an internal path, planted in the exception messages
+# below to prove neither reaches the caller.
+LEAKED_DSN = "postgresql://admin:[email protected]/prod"
+LEAKED_PATH = "/srv/app/superset/mcp_service/chart/tool/get_chart_data.py"
+
+
+def _dead_table_error() -> SupersetErrorException:
+    """The upstream failure a chart over a dropped table produces."""
+    return SupersetErrorException(
+        SupersetError(
+            message=f'relation "orders" does not exist ({LEAKED_DSN})',
+            error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+            level=ErrorLevel.ERROR,
+        )
+    )
+
+
+def connection_denied(unused: int = 1) -> str:
+    """A connection access denial remains a permission failure."""
+    raise SupersetSecurityException(
+        SupersetError(
+            message="denied",
+            error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            level=ErrorLevel.ERROR,
+        )
+    )
+
+
+def _build_server() -> FastMCP:
+    """A FastMCP server with one tool per failure class.
+
+    ``LoggingMiddleware`` is deliberately omitted: it needs a Flask app
+    context, and it does not participate in classifying the response.
+    """
+    mcp: FastMCP = FastMCP("error-classification")
+
+    @mcp.tool
+    def rbac_denied(unused: int = 1) -> str:
+        """RBAC denial raised by the @tool permission decorator."""
+        raise MCPPermissionDeniedError(
+            permission_name="can_get",
+            view_name="User",
+            tool_name="rbac_denied",
+        )
+
+    @mcp.tool
+    def forbidden(unused: int = 1) -> str:
+        """Authorization refused below the MCP layer."""
+        raise ForbiddenError()
+
+    @mcp.tool
+    def dead_table(unused: int = 1) -> str:
+        """Well-formed call whose datasource is gone."""
+        raise _dead_table_error()
+
+    @mcp.tool
+    def untyped_datasource(unused: int = 1) -> str:
+        """Datasource failure carrying no recognised SupersetErrorType."""
+        raise DatabaseNotFound("Database backing this chart is gone")
+
+    @mcp.tool
+    def unreachable_datasource(unused: int = 1) -> str:
+        """The analytics database cannot be reached."""
+        raise SupersetErrorException(
+            SupersetError(
+                message="could not connect to host",
+                error_type=SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+                level=ErrorLevel.ERROR,
+            )
+        )
+
+    @mcp.tool
+    def malformed_column(unused: int = 1) -> str:
+        """Engine reports a bad adhoc column via the GENERIC_DB_ENGINE 
catch-all."""
+        raise SupersetGenericDBErrorException("bad adhoc column expression")
+
+    mcp.tool(connection_denied)
+
+    @mcp.tool
+    def bad_sql(unused: int = 1) -> str:
+        """The caller's own SQL is malformed."""
+        raise SupersetErrorException(
+            SupersetError(
+                message="syntax error at or near SELEC",
+                error_type=SupersetErrorType.SYNTAX_ERROR,
+                level=ErrorLevel.ERROR,
+            )
+        )
+
+    @mcp.tool
+    def needs_id(id: int) -> str:  # noqa: A002
+        """Schema mismatch: callers guess ``identifier`` for ``id``."""
+        return "ok"
+
+    @mcp.tool
+    def internal_bug(unused: int = 1) -> str:
+        """A genuine server-side defect."""
+        raise RuntimeError(f"unexpected failure in {LEAKED_PATH}")
+
+    mcp.add_middleware(
+        ToolResultCompatibilityMiddleware(structured_output_enabled=False)
+    )
+    mcp.add_middleware(GlobalErrorHandlerMiddleware())
+    return mcp
+
+
+async def _call(tool: str, arguments: dict[str, Any] | None = None) -> str:
+    """Return the client-facing text for a failing tool call."""
+    async with Client(_build_server()) as client:
+        result = await client.call_tool(tool, arguments or {}, 
raise_on_error=False)
+    assert result.is_error, f"{tool} was expected to fail"
+    return result.content[0].text
+
+
+class TestFastMCPWrappingPrecondition:
+    """Guard the assumption the classification fix is built on."""
+
+    @pytest.mark.asyncio
+    async def test_tool_exceptions_arrive_wrapped_in_tool_error(self) -> None:
+        """FastMCP wraps tool exceptions before middleware sees them.
+
+        If a FastMCP upgrade stops doing this, ``_unwrap_tool_error`` becomes
+        a no-op and the branch chain keeps working — but this test failing is
+        the signal that the comments explaining *why* it exists are stale.
+        """
+        seen: list[Exception] = []
+
+        mcp: FastMCP = FastMCP("precondition")
+
+        @mcp.tool
+        def boom(unused: int = 1) -> str:
+            raise PermissionError("denied")
+
+        class Capture(ToolResultCompatibilityMiddleware):
+            async def on_call_tool(self, context: Any, call_next: Any) -> Any:
+                try:
+                    return await call_next(context)
+                except Exception as exc:  # noqa: BLE001
+                    seen.append(exc)
+                    raise
+
+        mcp.add_middleware(Capture(structured_output_enabled=False))
+
+        async with Client(mcp) as client:
+            await client.call_tool("boom", {}, raise_on_error=False)
+
+        assert len(seen) == 1
+        assert isinstance(seen[0], ToolError)
+        assert isinstance(seen[0].__cause__, PermissionError)
+        # _unwrap_fastmcp_wrapped_error uses this prefix to identify the 
wrapper
+        # apart from a ToolError tool code chained off another exception.
+        assert str(seen[0]).startswith(_FASTMCP_WRAPPED_ERROR_PREFIX)
+
+
+class TestUnwrapToolError:
+    """``_unwrap_tool_error`` classifies by the underlying failure.
+
+    Used for log severity, metrics, and error-tracker capture only — never to
+    pick client-facing text — so it unwraps any chained ToolError, including
+    GlobalErrorHandlerMiddleware's own re-raise, which LoggingMiddleware (the
+    outer middleware) is what actually sees.
+    """
+
+    def test_returns_cause_of_fastmcp_wrapped_error(self) -> None:
+        cause = MCPPermissionDeniedError(permission_name="can_read", 
view_name="Chart")
+        wrapped = ToolError("Error calling tool 'list_charts': denied")
+        wrapped.__cause__ = cause
+
+        assert _unwrap_tool_error(wrapped) is cause
+
+    def test_returns_cause_of_our_own_handlers_reraise(self) -> None:
+        """GlobalErrorHandlerMiddleware re-raises a classified message chained
+        off the same cause, with no FastMCP prefix. LoggingMiddleware must
+        still attribute the failure to OperationalError, not to ToolError."""
+        cause = OperationalError("db error", {}, Exception())
+        reraised = ToolError("Database error in execute_sql")
+        reraised.__cause__ = cause
+
+        assert _unwrap_tool_error(reraised) is cause
+
+    def test_passes_through_unchained_tool_error(self) -> None:
+        deliberate = ToolError("'search_tools' cannot be called via the proxy")
+
+        assert _unwrap_tool_error(deliberate) is deliberate
+
+    def test_passes_through_unwrapped_exception(self) -> None:
+        error = ValueError("page must be positive")
+
+        assert _unwrap_tool_error(error) is error
+
+
+class TestUnwrapFastMCPWrappedError:
+    """``_unwrap_fastmcp_wrapped_error`` decides client-facing text, so it
+    must recover FastMCP's cause without eating a tool-authored message."""
+
+    def test_returns_cause_of_fastmcp_wrapped_error(self) -> None:
+        cause = MCPPermissionDeniedError(permission_name="can_read", 
view_name="Chart")
+        wrapped = ToolError("Error calling tool 'list_charts': denied")
+        wrapped.__cause__ = cause
+
+        assert _unwrap_fastmcp_wrapped_error(wrapped) is cause
+
+    def test_passes_through_deliberate_tool_error(self) -> None:
+        """A ToolError raised by tool code is already formatted for MCP, so
+        it must survive untouched."""
+        deliberate = ToolError("'search_tools' cannot be called via the proxy")
+
+        assert _unwrap_fastmcp_wrapped_error(deliberate) is deliberate
+
+    def test_passes_through_deliberate_tool_error_chained_off_a_cause(
+        self,
+    ) -> None:
+        """``raise ToolError(...) from exc`` in tool code is legitimate. Its
+        author-written message must not be discarded in favour of cause-based
+        handling just because a cause is attached."""
+        deliberate = ToolError("dataset is missing a time column")
+        deliberate.__cause__ = ValueError("no granularity")
+
+        assert _unwrap_fastmcp_wrapped_error(deliberate) is deliberate
+
+    def test_passes_through_unwrapped_exception(self) -> None:
+        error = ValueError("page must be positive")
+
+        assert _unwrap_fastmcp_wrapped_error(error) is error
+
+
+class TestDatasourceErrorClassification:
+    """Only genuine query/datasource failures take the datasource branch."""
+
+    def test_classifies_missing_table(self) -> None:
+        assert _is_datasource_error(_dead_table_error()) is True
+
+    def test_reason_is_the_enumerated_error_type(self) -> None:
+        assert (
+            _datasource_error_reason(_dead_table_error())
+            == SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR.value
+        )
+
+    def test_security_exception_is_not_a_datasource_error(self) -> None:
+        """SupersetSecurityException subclasses SupersetErrorException, so the
+        permission branch must win — a denied table is not a broken table."""
+        error = SupersetSecurityException(
+            SupersetError(
+                message="denied",
+                error_type=SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
+                level=ErrorLevel.ERROR,
+            )
+        )
+
+        assert _is_datasource_error(error) is False
+
+    def test_unrelated_superset_exception_is_not_a_datasource_error(self) -> 
None:
+        assert _is_datasource_error(SupersetException("something else")) is 
False
+
+    def test_connection_failures_are_datasource_errors(self) -> None:
+        """Every canonical connection error type classifies, so an
+        unreachable database is never reported as an internal error."""
+        for error_type in CONNECTION_ERROR_TYPES:
+            error = SupersetErrorException(
+                SupersetError(
+                    message="cannot connect",
+                    error_type=error_type,
+                    level=ErrorLevel.ERROR,
+                )
+            )
+
+            assert _is_datasource_error(error) is True, error_type
+            assert _datasource_error_reason(error) == error_type.value
+
+    def test_metastore_failure_is_not_a_datasource_error(self) -> None:
+        """SQLAlchemyError also covers Superset's own metadata database. A
+        metastore failure must not be reported as a failed datasource query
+        with the caller told their arguments were valid."""
+        error = IntegrityError("INSERT INTO logs", {}, Exception("duplicate 
key"))
+
+        assert _is_datasource_error(error) is False
+
+    def test_generic_backend_timeout_is_not_a_datasource_error(self) -> None:
+        """SigalrmTimeout/TimerTimeout raise SupersetTimeoutException with
+        BACKEND_TIMEOUT_ERROR; that is not a datasource outage."""
+        error = SupersetTimeoutException(
+            error_type=SupersetErrorType.BACKEND_TIMEOUT_ERROR,
+            message="Process timed out",
+            level=ErrorLevel.ERROR,
+        )
+
+        assert _is_datasource_error(error) is False
+
+    def test_query_object_validation_error_is_not_a_datasource_error(self) -> 
None:
+        """Raised for missing/invalid query fields and invalid result types —
+        a caller or configuration problem, not a broken datasource."""
+        error = QueryObjectValidationError("Invalid result type: bogus")
+
+        assert _is_datasource_error(error) is False
+
+    def test_reason_scans_every_error_in_a_multi_error_exception(self) -> None:
+        """A datasource error reported alongside others keeps its specific
+        reason instead of falling back to the generic sentinel."""
+        error = SupersetErrorsException(
+            [
+                SupersetError(
+                    message="first",
+                    error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
+                    level=ErrorLevel.ERROR,
+                ),
+                SupersetError(
+                    message="second",
+                    error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+                    level=ErrorLevel.ERROR,
+                ),
+            ]
+        )
+
+        assert (
+            _datasource_error_reason(error)
+            == SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR.value
+        )
+
+    def test_reason_is_none_for_unlisted_error_type(self) -> None:
+        error = SupersetErrorException(
+            SupersetError(
+                message="nope",
+                error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
+                level=ErrorLevel.ERROR,
+            )
+        )
+
+        assert _datasource_error_reason(error) is None
+
+
+class TestPerClassClientFacingErrors:
+    """Each failure class reaches the caller as its own honest message."""
+
+    @pytest.mark.asyncio
+    async def test_rbac_denial_uses_permission_denied_shape(self) -> None:
+        """An RBAC denial names the permission and the resource, and never
+        tells the caller to go re-read the tool schema."""
+        message = await _call("rbac_denied")
+
+        assert "Permission denied: can_get on User" in message
+        assert "Validation error" not in message
+        assert "inputSchema" not in message
+        assert "Internal error" not in message
+        # Anchor on the *fixed* shape. A bare `in` check also passes on the
+        # unfixed code, where FastMCP's wrapper message merely contains the
+        # denial text -- the assertion above would give false confidence.
+        assert _FASTMCP_WRAPPED_ERROR_PREFIX not in message
+
+    @pytest.mark.asyncio
+    async def test_non_rbac_authorization_failure_also_says_permission_denied(
+        self,
+    ) -> None:
+        """Denials raised below the MCP RBAC decorator get the same shape, so
+        a caller can recognise a denial regardless of which layer refused."""
+        message = await _call("forbidden")
+
+        assert "Permission denied" in message
+
+    @pytest.mark.asyncio
+    async def test_schema_mismatch_keeps_validation_guidance(self) -> None:
+        """Wrong argument names still get the advice that actually fits:
+        the offending fields, so the caller can match the inputSchema."""
+        message = await _call("needs_id", {"identifier": 5})
+
+        assert "Validation error in needs_id" in message
+        assert "id" in message
+        assert "identifier" in message
+        assert "Permission denied" not in message
+
+    @pytest.mark.asyncio
+    async def test_datasource_failure_blames_the_query_not_the_caller(self) -> 
None:
+        """A dropped table says the datasource failed and states the call was
+        valid — the previous message implied a bad tool name or arguments."""
+        message = await _call("dead_table")
+
+        assert "Datasource error in dead_table" in message
+        assert SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR.value in message
+        assert "arguments were valid" in message
+        assert "Permission denied" not in message
+        assert "Validation error" not in message
+
+    @pytest.mark.asyncio
+    async def test_untyped_datasource_failure_uses_the_generic_sentinel(self) 
-> None:
+        """The reason is part of the client-facing message, so it stays a
+        closed vocabulary — never the Python exception class name."""
+        message = await _call("untyped_datasource")
+
+        assert "Datasource error in untyped_datasource" in message
+        assert _GENERIC_DATASOURCE_REASON in message
+        assert "DatabaseNotFound" not in message
+        assert "Exception" not in message
+
+    @pytest.mark.asyncio
+    async def test_internal_error_is_opaque_and_carries_an_error_id(self) -> 
None:
+        message = await _call("internal_bug")
+
+        assert "Internal error in internal_bug" in message
+        assert "Error ID:" in message
+
+    @pytest.mark.asyncio
+    async def test_the_four_classes_do_not_share_a_message(self) -> None:
+        """The regression in one assertion: conflating these is the bug."""
+        messages = [
+            await _call("rbac_denied"),
+            await _call("needs_id", {"identifier": 5}),
+            await _call("dead_table"),
+            await _call("internal_bug"),
+        ]
+
+        assert len(set(messages)) == len(messages)
+        # Distinctness alone is not the property under test: the unfixed code
+        # also produced four distinct strings, just four *unclassified* ones.
+        # None may still carry FastMCP's wrapper.
+        for message in messages:
+            assert _FASTMCP_WRAPPED_ERROR_PREFIX not in message
+
+
+class TestNoNewDisclosure:
+    """Classification must not widen what a failure discloses."""
+
+    @pytest.mark.asyncio
+    async def test_datasource_error_does_not_echo_raw_driver_output(self) -> 
None:
+        """Only the enumerated SupersetErrorType is surfaced; the driver
+        message (which carried a DSN) is not."""
+        message = await _call("dead_table")
+
+        assert LEAKED_DSN not in message
+        assert "hunter2" not in message
+        assert "db.internal" not in message
+        assert "orders" not in message
+
+    @pytest.mark.asyncio
+    async def test_internal_error_does_not_echo_server_paths(self) -> None:
+        message = await _call("internal_bug")
+
+        assert LEAKED_PATH not in message
+        assert "mcp_service" not in message
+
+
+class TestDatasourceErrorSeverity:
+    """A datasource failure's severity must follow what actually broke.
+
+    ``_is_user_error`` keys on ``SupersetException.status``, and a *bare*
+    ``SupersetErrorException`` — the shape Superset raises for most datasource
+    failures — inherits ``status = 500``. Without an override, a dropped table
+    logs at ERROR with a traceback and pages through ``MCP_ERROR_HOOK``.
+    """
+
+    @pytest.mark.parametrize(
+        "error_type",
+        [
+            SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+            SupersetErrorType.SCHEMA_DOES_NOT_EXIST_ERROR,
+        ],
+    )
+    def test_missing_object_is_a_user_error_despite_status_500(
+        self, error_type: SupersetErrorType
+    ) -> None:
+        error = SupersetErrorException(
+            SupersetError(message="gone", error_type=error_type, 
level=ErrorLevel.ERROR)
+        )
+
+        # The status this would otherwise be judged by.
+        assert error.status == 500
+        assert _is_user_error(error) is False
+        # ...but a renamed table is routine MCP traffic, not a page.
+        assert _datasource_error_is_user_error(error) is True
+
+    def test_connection_failure_stays_a_system_error(self) -> None:
+        """An unreachable database is an operational problem worth paging on.
+
+        Genuine connection failures arrive as a *bare* 
``SupersetErrorException``,
+        which inherits ``status = 500`` — the shape the escalation applies to.
+        """
+        for error_type in CONNECTION_ERROR_TYPES:
+            error = SupersetErrorException(
+                SupersetError(
+                    message="unreachable",
+                    error_type=error_type,
+                    level=ErrorLevel.ERROR,
+                )
+            )
+
+            assert error.status == 500
+            assert _datasource_error_is_user_error(error) is False, error_type
+
+    def test_sub_500_exceptions_are_never_escalated(self) -> None:
+        """The override only de-escalates.
+
+        A sub-500 status is a deliberate judgement by the exception class that
+        the caller is at fault. Escalating it would re-create the very
+        false-paging this function exists to remove — just for a different
+        error type.
+        """
+        # GENERIC_DB_ENGINE_ERROR is in CONNECTION_ERROR_TYPES (the catch-all
+        # for engines without specific CONNECTION_* regexes), but engines also
+        # report a malformed adhoc column through it, carried by a status-400
+        # exception. Reachable via generate_chart -> _compile_chart.
+        malformed_column = SupersetGenericDBErrorException("bad adhoc column")
+
+        assert malformed_column.status == 400
+        assert _is_user_error(malformed_column) is True
+        assert _datasource_error_is_user_error(malformed_column) is True
+
+    def test_security_exception_with_connection_reason_is_not_escalated(
+        self,
+    ) -> None:
+        """A 403 denial must not page just because its reason happens to be a
+        connection error type; the client is correctly told access was 
denied."""
+        denial = SupersetSecurityException(
+            SupersetError(
+                message="denied",
+                error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+                level=ErrorLevel.ERROR,
+            )
+        )
+
+        assert denial.status == 403
+        assert _datasource_error_is_user_error(denial) is True
+
+    def test_non_datasource_error_keeps_status_based_judgement(self) -> None:
+        assert _datasource_error_is_user_error(ValueError("bad page")) is None
+        assert _datasource_error_is_user_error(SupersetException("other")) is 
None
+
+    @pytest.mark.asyncio
+    async def test_missing_table_logs_warning_and_does_not_fire_error_hook(
+        self,
+    ) -> None:
+        """End to end: the dropped-table shape must not page on-call."""
+        hook_calls: list[Any] = []
+
+        with (
+            patch(
+                "superset.mcp_service.middleware._invoke_error_hook",
+                side_effect=lambda *a, **k: hook_calls.append(a),
+            ),
+            patch("superset.mcp_service.middleware.logger") as mock_logger,
+        ):
+            await _call("dead_table")
+
+        assert hook_calls == []
+        mock_logger.warning.assert_called()
+        mock_logger.error.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_connection_failure_logs_error_and_fires_error_hook(self) -> 
None:
+        """End to end: an unreachable datasource still reaches the tracker."""
+        hook_calls: list[Any] = []
+
+        with (
+            patch(
+                "superset.mcp_service.middleware._invoke_error_hook",
+                side_effect=lambda *a, **k: hook_calls.append(a),
+            ),
+            patch("superset.mcp_service.middleware.logger") as mock_logger,
+        ):
+            await _call("unreachable_datasource")
+
+        assert len(hook_calls) == 1
+        mock_logger.error.assert_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("tool_name", "message_prefix"),
+        [
+            ("malformed_column", "Datasource error in malformed_column:"),
+            ("connection_denied", "Permission denied"),
+        ],
+    )
+    async def test_sub_500_connection_reason_logs_warning_without_error_hook(
+        self, tool_name: str, message_prefix: str
+    ) -> None:
+        """Keep 400 engine errors and 403 denials non-paging through 
FastMCP."""
+        hook_calls: list[Any] = []
+
+        with (
+            patch(
+                "superset.mcp_service.middleware._invoke_error_hook",
+                side_effect=lambda *a, **k: hook_calls.append(a),
+            ),
+            patch("superset.mcp_service.middleware.logger") as mock_logger,
+        ):
+            message = await _call(tool_name)
+
+        assert message.removeprefix("Error: ").startswith(message_prefix)
+        assert hook_calls == []
+        failure_logs = [
+            call
+            for call in mock_logger.warning.call_args_list
+            if call.args[0].startswith("MCP tool call failed:")
+        ]
+        assert len(failure_logs) == 1
+        assert failure_logs[0].kwargs["exc_info"] is False
+        mock_logger.error.assert_not_called()
+
+
+class TestQuerySyntaxErrors:
+    """A malformed query is the caller's problem, not the datasource's."""
+
+    @pytest.mark.asyncio
+    async def test_syntax_error_blames_the_query_not_the_datasource(self) -> 
None:
+        """For a SQL-authoring tool the query text *is* an argument, so
+        "arguments were valid" would steer an agent away from the one thing
+        it can fix."""
+        message = await _call("bad_sql")
+
+        assert "Query error in bad_sql" in message
+        assert SupersetErrorType.SYNTAX_ERROR.value in message
+        assert "Fix the query itself" in message
+        assert "arguments were valid" not in message

Reply via email to