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 7a9fe06762b fix(mcp): stop data tool responses crashing on binary and
NaT values (#44495)
7a9fe06762b is described below
commit 7a9fe06762b45d104ad098779116afdfee50bf30
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed Sep 23 10:39:01 2026 +1000
fix(mcp): stop data tool responses crashing on binary and NaT values
(#44495)
---
docs/admin_docs/configuration/mcp-server.mdx | 15 +
superset/mcp_service/chart/schemas.py | 33 +--
superset/mcp_service/chart/tool/get_chart_data.py | 31 +-
superset/mcp_service/dashboard/schemas.py | 13 +-
superset/mcp_service/dataset/schemas.py | 13 +-
superset/mcp_service/semantic_layer/schemas.py | 11 +-
superset/mcp_service/sql_lab/schemas.py | 18 +-
superset/mcp_service/sql_lab/tool/execute_sql.py | 19 --
superset/mcp_service/utils/response_utils.py | 8 +-
superset/mcp_service/utils/serialization.py | 225 +++++++++++++++
.../mcp_service/chart/tool/test_get_chart_data.py | 25 +-
.../mcp_service/sql_lab/tool/test_execute_sql.py | 104 +++----
.../mcp_service/test_response_serialization.py | 321 +++++++++++++++++++++
.../mcp_service/utils/test_serialization.py | 294 +++++++++++++++++++
14 files changed, 1002 insertions(+), 128 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index d07c06e54c2..319db21ccc4 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1175,6 +1175,21 @@ 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.
+## Result serialization
+
+Data-bearing tool responses sanitize row values, column samples, and statistics
+even when Python producers supply tuples, sets, generators, or read-only
mappings.
+Missing and non-finite values become JSON `null`; non-UTF-8 binary values
become
+base64 strings prefixed with `base64:`. Boolean row counts are treated as
+unavailable, producing `null` for nullable counts and `0` for non-nullable
counts,
+rather than being interpreted as one or zero rows.
+
+## Chart data column counts
+
+For saved and unsaved charts, `get_chart_data` column metadata excludes missing
+values from `unique_count`. Values serialized as JSON `null`, including NaN,
+NaT, and non-finite numbers, contribute to `null_count` instead.
+
## Dataset query dimensions and nested fields
The `query_dataset` tool accepts exact column names registered on the selected
diff --git a/superset/mcp_service/chart/schemas.py
b/superset/mcp_service/chart/schemas.py
index 4113cf02408..077bb756c9d 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -75,6 +75,13 @@ from superset.mcp_service.utils.sanitization import (
sanitize_user_input,
sanitize_user_input_with_changes,
)
+from superset.mcp_service.utils.serialization import (
+ JsonSafeMapping,
+ JsonSafeRows,
+ JsonSafeValues,
+ OptionalRowCount,
+ RowCount,
+)
logger = logging.getLogger(__name__)
@@ -4026,7 +4033,7 @@ class DataColumn(BaseModel):
name: str = Field(..., description="Column name")
display_name: str = Field(..., description="Human-readable column name")
data_type: str = Field(..., description="Inferred data type")
- sample_values: List[Any] = Field(description="Representative sample
values")
+ sample_values: JsonSafeValues = Field(description="Representative sample
values")
null_count: int = Field(
description="Number of null values. Approximate — see
'statistics.sampled_rows'"
" if the source result set was larger than the row cap used to compute
it."
@@ -4036,7 +4043,7 @@ class DataColumn(BaseModel):
"'statistics.sampled_rows' if the source result set was larger than
the "
"row cap used to compute it."
)
- statistics: Dict[str, Any] | None = Field(
+ statistics: JsonSafeMapping | None = Field(
None,
description="Additional column statistics, when available. May include
"
"'sampled_rows' (any column type) when null_count/unique_count were "
@@ -4052,9 +4059,9 @@ class ChartQueryResult(BaseModel):
query_index: int = Field(description="Zero-based query position")
columns: list[str] = Field(description="Column names returned by the
query")
- data: list[dict[str, Any]] = Field(description="Actual data rows")
- row_count: int = Field(description="Rows returned")
- total_rows: int | None = Field(None, description="Total available rows")
+ data: JsonSafeRows = Field(description="Actual data rows")
+ row_count: RowCount = Field(description="Rows returned")
+ total_rows: OptionalRowCount = Field(None, description="Total available
rows")
class ChartData(BaseModel):
@@ -4067,7 +4074,7 @@ class ChartData(BaseModel):
# Enhanced data description
columns: List[DataColumn] = Field(description="Rich column metadata")
- data: List[Dict[str, Any]] = Field(description="Actual data rows")
+ data: JsonSafeRows = Field(description="Actual data rows")
query_results: list[ChartQueryResult] | None = Field(
None,
description=(
@@ -4077,18 +4084,8 @@ class ChartData(BaseModel):
)
# Data insights
- row_count: int = Field(description="Rows returned")
- total_rows: int | None = Field(description="Total available rows")
-
- @field_validator("total_rows", mode="before")
- @classmethod
- def _coerce_total_rows(cls, v: Any) -> int | None:
- if v is None:
- return None
- try:
- return int(v)
- except (TypeError, ValueError):
- return None
+ row_count: RowCount = Field(description="Rows returned")
+ total_rows: OptionalRowCount = Field(description="Total available rows")
data_freshness: datetime | None = Field(description="When data was last
updated")
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index dff7cf87b41..5369a436c3c 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -60,6 +60,7 @@ from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
)
+from superset.mcp_service.utils.serialization import is_missing_value
from superset.utils.core import GenericDataType
logger = logging.getLogger(__name__)
@@ -858,7 +859,7 @@ async def execute_chart_data( # noqa: C901
sample_values = [
row.get(col_name)
for row in data[:3]
- if row.get(col_name) is not None
+ if not is_missing_value(row.get(col_name))
]
# Use SQL-derived GenericDataType when available,
@@ -878,8 +879,16 @@ async def execute_chart_data( # noqa: C901
display_name=col_name.replace("_", " ").title(),
data_type=data_type,
sample_values=sample_values[:3],
- null_count=sum(1 for row in data if row.get(col_name)
is None),
- unique_count=len({str(row.get(col_name)) for row in
data}),
+ null_count=sum(
+ 1 for row in data if
is_missing_value(row.get(col_name))
+ ),
+ unique_count=len(
+ {
+ str(row.get(col_name))
+ for row in data
+ if not is_missing_value(row.get(col_name))
+ }
+ ),
)
)
@@ -1199,7 +1208,9 @@ async def _query_from_form_data( # noqa: C901
columns = []
for col_name in raw_columns:
sample_values = [
- row.get(col_name) for row in data[:3] if row.get(col_name) is
not None
+ row.get(col_name)
+ for row in data[:3]
+ if not is_missing_value(row.get(col_name))
]
data_type = "string"
if sample_values and all(
@@ -1212,8 +1223,16 @@ async def _query_from_form_data( # noqa: C901
display_name=col_name.replace("_", " ").title(),
data_type=data_type,
sample_values=sample_values[:3],
- null_count=sum(1 for row in data if row.get(col_name) is
None),
- unique_count=len({str(row.get(col_name)) for row in data}),
+ null_count=sum(
+ 1 for row in data if
is_missing_value(row.get(col_name))
+ ),
+ unique_count=len(
+ {
+ str(row.get(col_name))
+ for row in data
+ if not is_missing_value(row.get(col_name))
+ }
+ ),
)
)
diff --git a/superset/mcp_service/dashboard/schemas.py
b/superset/mcp_service/dashboard/schemas.py
index 498f3c9a215..935b268ea80 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -117,6 +117,7 @@ from superset.mcp_service.utils.sanitization import (
sanitize_user_input,
sanitize_user_input_with_changes,
)
+from superset.mcp_service.utils.serialization import JsonSafeRows,
OptionalRowCount
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils.core import DatasourceType
from superset.utils.json import loads as json_loads
@@ -2907,11 +2908,11 @@ class DashboardChartQueryData(BaseModel):
query_index: int = Field(..., description="Zero-based query position")
columns: list[str] = Field(default_factory=list, description="Result
column names")
- sample_data: list[dict[str, Any]] = Field(
+ sample_data: JsonSafeRows = Field(
default_factory=list, description="A few example data rows"
)
- row_count: int | None = Field(None, description="Rows returned by this
query")
- total_rows: int | None = Field(
+ row_count: OptionalRowCount = Field(None, description="Rows returned by
this query")
+ total_rows: OptionalRowCount = Field(
None, description="Total rows available for this query when known"
)
truncated: bool = Field(
@@ -2926,11 +2927,11 @@ class DashboardChartData(BaseModel):
chart_name: str = Field(..., description="Chart name")
chart_type: str = Field(..., description="Chart viz type")
columns: list[str] = Field(default_factory=list, description="Result
column names")
- sample_data: list[dict[str, Any]] = Field(
+ sample_data: JsonSafeRows = Field(
default_factory=list, description="A few example data rows"
)
- row_count: int | None = Field(None, description="Rows returned by the
query")
- total_rows: int | None = Field(
+ row_count: OptionalRowCount = Field(None, description="Rows returned by
the query")
+ total_rows: OptionalRowCount = Field(
None,
description=(
"Total rows available when known; null when the fetch was capped
with "
diff --git a/superset/mcp_service/dataset/schemas.py
b/superset/mcp_service/dataset/schemas.py
index 4e4de483a8d..3cc6b594dc4 100644
--- a/superset/mcp_service/dataset/schemas.py
+++ b/superset/mcp_service/dataset/schemas.py
@@ -60,6 +60,11 @@ from superset.mcp_service.system.schemas import (
TagInfo,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
+from superset.mcp_service.utils.serialization import (
+ JsonSafeRows,
+ OptionalRowCount,
+ RowCount,
+)
from superset.sql.parse import has_aggregate
from superset.utils import json
@@ -886,11 +891,9 @@ class QueryDatasetResponse(BaseModel):
columns: List[DataColumn] = Field(
default_factory=list, description="Column metadata for returned data"
)
- data: List[Dict[str, Any]] = Field(
- default_factory=list, description="Query result rows"
- )
- row_count: int = Field(0, description="Number of rows returned")
- total_rows: int | None = Field(
+ data: JsonSafeRows = Field(default_factory=list, description="Query result
rows")
+ row_count: RowCount = Field(0, description="Number of rows returned")
+ total_rows: OptionalRowCount = Field(
None, description="Total row count from the query engine"
)
from_dttm: datetime | None = Field(
diff --git a/superset/mcp_service/semantic_layer/schemas.py
b/superset/mcp_service/semantic_layer/schemas.py
index 7339afbe5fd..11ea6f7b743 100644
--- a/superset/mcp_service/semantic_layer/schemas.py
+++ b/superset/mcp_service/semantic_layer/schemas.py
@@ -28,6 +28,11 @@ from superset.mcp_service.chart.schemas import DataColumn,
PerformanceMetadata
from superset.mcp_service.common.cache_schemas import CacheStatus
from superset.mcp_service.common.error_schemas import MCPBaseError
from superset.mcp_service.common.time_range_validation import
validate_time_range
+from superset.mcp_service.utils.serialization import (
+ JsonSafeRows,
+ OptionalRowCount,
+ RowCount,
+)
# ---------------------------------------------------------------------------
# Shared error schema
@@ -302,9 +307,9 @@ class GetTableResponse(BaseModel):
"""Response schema for get_table."""
columns: list[DataColumn]
- data: list[dict[str, Any]]
- row_count: int
- total_rows: int | None = None
+ data: JsonSafeRows
+ row_count: RowCount
+ total_rows: OptionalRowCount = None
from_dttm: datetime | None = Field(
None,
description=(
diff --git a/superset/mcp_service/sql_lab/schemas.py
b/superset/mcp_service/sql_lab/schemas.py
index 7fc03bb708f..11cc8942aa0 100644
--- a/superset/mcp_service/sql_lab/schemas.py
+++ b/superset/mcp_service/sql_lab/schemas.py
@@ -28,6 +28,12 @@ from pydantic import (
model_serializer,
)
+from superset.mcp_service.utils.serialization import (
+ JsonSafeRows,
+ OptionalRowCount,
+ RowCount,
+)
+
class _SchemaFieldNormalizer(BaseModel):
"""Mixin that renames schema_name → schema in JSON output.
@@ -133,9 +139,7 @@ class ColumnInfo(BaseModel):
class StatementData(BaseModel):
"""Row data and column metadata for a single SQL statement."""
- rows: list[dict[str, Any]] = Field(
- ..., description="Result rows as list of dictionaries"
- )
+ rows: JsonSafeRows = Field(..., description="Result rows as list of
dictionaries")
columns: list[ColumnInfo] = Field(..., description="Column metadata
information")
@@ -146,7 +150,7 @@ class StatementInfo(BaseModel):
executed_sql: str = Field(
..., description="SQL after transformations (RLS, mutations, limits)"
)
- row_count: int = Field(..., description="Number of rows returned/affected")
+ row_count: RowCount = Field(..., description="Number of rows
returned/affected")
execution_time_ms: float | None = Field(
None, description="Statement execution time in milliseconds"
)
@@ -164,14 +168,14 @@ class ExecuteSqlResponse(BaseModel):
"""Response schema for SQL execution results."""
success: bool = Field(..., description="Whether query executed
successfully")
- rows: list[dict[str, Any]] | None = Field(
+ rows: JsonSafeRows | None = Field(
None, description="Query result rows as list of dictionaries"
)
columns: list[ColumnInfo] | None = Field(
None, description="Column metadata information"
)
- row_count: int | None = Field(None, description="Number of rows returned")
- affected_rows: int | None = Field(
+ row_count: OptionalRowCount = Field(None, description="Number of rows
returned")
+ affected_rows: OptionalRowCount = Field(
None, description="Number of rows affected (for DML queries)"
)
execution_time: float | None = Field(
diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py
b/superset/mcp_service/sql_lab/tool/execute_sql.py
index 4e5d980c765..fa4d13d9e6a 100644
--- a/superset/mcp_service/sql_lab/tool/execute_sql.py
+++ b/superset/mcp_service/sql_lab/tool/execute_sql.py
@@ -23,7 +23,6 @@ Database.execute() API with RLS, template rendering, and
security validation.
"""
import logging
-from decimal import Decimal
from typing import Any
import pandas as pd
@@ -311,22 +310,6 @@ async def _log_execution_result(
)
-def _sanitize_row_values(rows: list[dict[str, Any]]) -> None:
- """Sanitize non-serializable values in rows for JSON serialization."""
- for row in rows:
- for key, value in row.items():
- if isinstance(value, (bytes, memoryview)):
- raw = bytes(value) if isinstance(value, memoryview) else value
- try:
- row[key] = raw.decode("utf-8")
- except (UnicodeDecodeError, AttributeError):
- row[key] = raw.hex()
- elif isinstance(value, Decimal):
- row[key] = float(value)
- elif not isinstance(value, (str, int, float, bool, type(None),
list, dict)):
- row[key] = str(value)
-
-
def _data_to_statement_data(data: Any) -> StatementData:
"""Convert statement data (DataFrame, list, dict, bytes) to StatementData.
@@ -343,7 +326,6 @@ def _data_to_statement_data(data: Any) -> StatementData:
rows_data = [rows_data]
elif isinstance(data, pd.DataFrame):
rows_data = data.to_dict(orient="records")
- _sanitize_row_values(rows_data)
return StatementData(
rows=rows_data,
columns=[
@@ -359,7 +341,6 @@ def _data_to_statement_data(data: Any) -> StatementData:
else:
rows_data = [{"value": str(data)}]
- _sanitize_row_values(rows_data)
col_names = list(rows_data[0].keys()) if rows_data else []
return StatementData(
rows=rows_data,
diff --git a/superset/mcp_service/utils/response_utils.py
b/superset/mcp_service/utils/response_utils.py
index b50c4b89223..a3ba54f33b8 100644
--- a/superset/mcp_service/utils/response_utils.py
+++ b/superset/mcp_service/utils/response_utils.py
@@ -64,6 +64,8 @@ if TYPE_CHECKING:
import humanize
+from superset.mcp_service.utils.serialization import is_missing_value
+
def humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
@@ -192,7 +194,9 @@ def format_data_columns(
columns_meta: list[DataColumn] = []
for col_name in raw_columns:
sample_values = [
- row.get(col_name) for row in data[:3] if row.get(col_name) is not
None
+ row.get(col_name)
+ for row in data[:3]
+ if not is_missing_value(row.get(col_name))
]
data_type: str = "string"
if sample_values:
@@ -207,7 +211,7 @@ def format_data_columns(
unique_vals: set[str] = set()
for row in stats_rows:
val = row.get(col_name)
- if val is None:
+ if is_missing_value(val):
null_count += 1
else:
unique_vals.add(str(val))
diff --git a/superset/mcp_service/utils/serialization.py
b/superset/mcp_service/utils/serialization.py
new file mode 100644
index 00000000000..e39893bd93b
--- /dev/null
+++ b/superset/mcp_service/utils/serialization.py
@@ -0,0 +1,225 @@
+# 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.
+
+"""JSON-safety helpers for MCP response schemas.
+
+FastMCP serialises every tool return value with
+``pydantic_core.to_json(value, fallback=str)``. The ``fallback`` only fires for
+types pydantic does not recognise, so values that *are* recognised but cannot
+be rendered as JSON raise ``PydanticSerializationError`` and abort the whole
+tool call — after the warehouse query already succeeded. Warehouse result sets
+routinely carry such values:
+
+* ``bytes`` from ``BLOB``/``BYTEA``/``VARBINARY`` columns that are not valid
+ UTF-8 (``invalid utf-8 sequence of 1 bytes from index N``);
+* ``pandas.NaT`` for a null timestamp — a ``datetime`` subclass backed by a
+ float, which pydantic tries to render as a datetime
+ (``TypeError: 'float' object cannot be interpreted as an integer``);
+* ``float`` NaN/Infinity, which serialise to the non-standard JSON literals
+ ``NaN``/``Infinity`` that strict JSON clients reject;
+* ``str`` holding lone surrogates, produced by drivers decoding with
+ ``surrogatepass`` (``surrogates not allowed``).
+
+Row counts are a second failure source: a DBAPI cursor may report ``rowcount``
+as a ``float``, which fails validation of an ``int`` field before serialisation
+is even reached.
+
+The annotated types exported here (:data:`JsonSafeRows`,
+:data:`JsonSafeValues`, :data:`JsonSafeMapping`, :data:`RowCount`,
+:data:`OptionalRowCount`) attach the coercion as a ``BeforeValidator``, so any
+response schema that uses them is protected at construction time. Prefer them
+over per-field validators so new data-bearing tools inherit the protection.
+"""
+
+from __future__ import annotations
+
+import base64
+import math
+from collections.abc import Mapping
+from datetime import date, datetime, time, timedelta
+from typing import Annotated, Any
+from uuid import UUID
+
+import numpy as np
+import pandas as pd
+from pydantic import BeforeValidator
+
+from superset.utils.json import base_json_conv
+
+# Undecodable binary is returned base64-encoded behind this marker. Base64 is
+# lossless and always valid UTF-8; decoding with a lenient codec instead (e.g.
+# UTF-16) can emit lone surrogates, which is a second way to break the
+# serializer.
+BINARY_PREFIX = "base64:"
+
+# Values nested deeper than this (a cyclic or pathologically nested JSON
+# column) are rendered as text rather than walked.
+MAX_DEPTH = 20
+
+# Types pydantic renders natively and that never need rewriting.
+_PASSTHROUGH_TYPES = (datetime, date, time, timedelta, UUID)
+
+# Marks a value the scalar pass did not handle; ``None`` is a real result.
+_UNHANDLED = object()
+
+
+def _decode_binary(value: bytes | bytearray | memoryview) -> str:
+ """Render binary column data as a JSON-safe string."""
+ raw = bytes(value)
+ try:
+ return _sanitize_str(raw.decode("utf-8"))
+ except UnicodeDecodeError:
+ return BINARY_PREFIX + base64.b64encode(raw).decode("ascii")
+
+
+def _sanitize_str(value: str) -> str:
+ """Replace lone surrogates, which cannot be encoded as UTF-8."""
+ if value.isascii():
+ return value
+ try:
+ value.encode("utf-8")
+ except UnicodeEncodeError:
+ return value.encode("utf-8", "replace").decode("utf-8")
+ return value
+
+
+def is_missing_value(value: Any) -> bool:
+ """True when a result value is null-like: ``None``, NaN, ``pandas.NaT``,
+ ``pandas.NA`` or ``numpy`` NaT.
+
+ Use this instead of ``value is None`` when summarising result data, so a
+ column full of NaN is not reported as having no nulls. This is exactly the
+ set of values :func:`sanitize_json_value` renders as JSON ``null``, so a
+ null count derived from it agrees with the payload.
+
+ ``pandas.isna`` returns an array for array-likes, so only a scalar boolean
+ result counts as missing.
+ """
+ if value is None:
+ return True
+ if isinstance(value, (float, np.floating)):
+ # NaN and the infinities, at every numpy width.
+ return not math.isfinite(value)
+ if isinstance(value, str):
+ return False
+ try:
+ missing = pd.isna(value)
+ except (TypeError, ValueError):
+ return False
+ return missing is True or (isinstance(missing, np.bool_) and bool(missing))
+
+
+def _sanitize_scalar(value: Any) -> Any:
+ """Sanitize a leaf value, or return :data:`_UNHANDLED` when ``value`` needs
+ the slower container/conversion path."""
+ if value is None or value is True or value is False:
+ return value
+ if isinstance(value, str):
+ return _sanitize_str(value)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ # Covers numpy.float64 (a float subclass) and NaN/Infinity.
+ return float(value) if math.isfinite(value) else None
+ if isinstance(value, (bytes, bytearray, memoryview)):
+ return _decode_binary(value)
+ return _UNHANDLED
+
+
+def _sanitize_other(value: Any, depth: int) -> Any:
+ """Sanitize containers and the long tail of warehouse value types."""
+ if isinstance(value, Mapping):
+ return {
+ _sanitize_str(key if isinstance(key, str) else str(key)): (
+ sanitize_json_value(item, depth + 1)
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, (list, tuple, set, frozenset)):
+ return [sanitize_json_value(item, depth + 1) for item in value]
+ # ``pandas.NaT`` is a ``datetime`` subclass, so the missing-value check has
+ # to run before the passthrough types.
+ if is_missing_value(value):
+ return None
+ if isinstance(value, np.generic):
+ # Every numpy scalar width, not just the few base_json_conv knows.
+ return sanitize_json_value(value.item(), depth + 1)
+ if isinstance(value, _PASSTHROUGH_TYPES):
+ return value
+ try:
+ # Normalises ndarrays, sets, Decimal and similar; the result may itself
+ # be a container or string that needs another pass. Recursion is
bounded
+ # by MAX_DEPTH.
+ converted = base_json_conv(value)
+ except TypeError:
+ # Unknown to Superset too. Render it as text rather than leaving it for
+ # FastMCP's ``fallback=str``, which the structured-content path
+ # (``to_jsonable_python``) does not apply.
+ return _sanitize_str(str(value))
+ return sanitize_json_value(converted, depth + 1)
+
+
+def sanitize_json_value(value: Any, depth: int = 0) -> Any:
+ """Return ``value`` with anything ``pydantic_core.to_json`` cannot encode
+ replaced by a JSON-safe equivalent."""
+ if (sanitized := _sanitize_scalar(value)) is not _UNHANDLED:
+ return sanitized
+ if depth >= MAX_DEPTH:
+ return _sanitize_str(str(value))
+ return _sanitize_other(value, depth)
+
+
+def sanitize_mapping(value: Any) -> Any:
+ """Sanitize a mapping, leaving non-mapping input to pydantic."""
+ if not isinstance(value, Mapping):
+ return value
+ return sanitize_json_value(value)
+
+
+def coerce_optional_int(value: Any) -> Any:
+ """Coerce an engine-reported count to ``int``, or ``None`` when it is not
+ a usable count (e.g. a boolean or ``NaN``)."""
+ if isinstance(value, (bool, np.bool_)):
+ return None
+ if value is None or isinstance(value, int):
+ return value
+ try:
+ coerced = int(value)
+ except (TypeError, ValueError, OverflowError):
+ return None
+ return coerced
+
+
+def coerce_int(value: Any) -> Any:
+ """Like :func:`coerce_optional_int` but yields ``0`` instead of ``None``,
+ for count fields that are not nullable. A count the engine did not report
+ therefore reads as ``0``; callers needing the true size of a result should
+ measure the rows themselves."""
+ coerced = coerce_optional_int(value)
+ return 0 if coerced is None else coerced
+
+
+#: A mapping of arbitrary values drawn from result data (e.g. column stats).
+JsonSafeMapping = Annotated[dict[str, Any], BeforeValidator(sanitize_mapping)]
+#: Result rows, sanitized element by element after pydantic coerces the
iterable.
+JsonSafeRows = list[JsonSafeMapping]
+#: Column samples, sanitized after pydantic coerces the iterable to a list.
+JsonSafeValues = list[Annotated[Any, BeforeValidator(sanitize_json_value)]]
+#: A non-nullable row count reported by the query engine.
+RowCount = Annotated[int, BeforeValidator(coerce_int)]
+#: A nullable row count reported by the query engine.
+OptionalRowCount = Annotated[int | None, BeforeValidator(coerce_optional_int)]
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
index 3436551a332..792e7a3fdac 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
@@ -25,6 +25,7 @@ from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
+import pandas as pd
import pytest
from fastmcp import Client
@@ -588,7 +589,10 @@ class TestUnsavedChartDataQueryConstruction:
assert not isinstance(result, ChartError)
assert [row["team"] for row in result.data] == ["Empty", "Blue", "NaN"]
assert result.row_count == result.total_rows == 3
- assert result.data_quality["completeness"] == pytest.approx(5 / 6)
+ # Both the None and the NaN metric serialize as null, so both count
+ # toward incompleteness: 2 missing cells out of 3 rows x 2 columns.
+ assert result.data_quality["completeness"] == pytest.approx(4 / 6)
+ assert [row["saved_sla"] for row in result.data] == [None, 98.5, None]
query = captured[0]["queries"][0]
assert query["metrics"] == ["saved_sla"]
assert query["orderby"] == [("saved_sla", False)]
@@ -1815,6 +1819,14 @@ class TestSavedChartExtraFormDataFilters:
{"team": "Infinity", "saved_sla": float("inf")},
{"team": "Negative infinity", "saved_sla": -float("inf")},
]
+ rows.extend(
+ [
+ {"team": "NaT", "saved_sla": pd.NaT},
+ {"team": "NA", "saved_sla": pd.NA},
+ ]
+ if export_format == "json"
+ else []
+ )
if has_finite:
rows.append({"team": "Blue", "saved_sla": 42})
source_rowcount = len(rows) + 7
@@ -1887,9 +1899,18 @@ class TestSavedChartExtraFormDataFilters:
expected_groups = [row["team"] for row in rows]
if export_format == "json":
assert [row["team"] for row in data["data"]] == expected_groups
+ # NaN and the infinities are not valid JSON, so they serialize as
+ # null and count as missing alongside None, NaT and NA: six
+ # missing cells out of len(rows) x 2 columns.
assert data["data_quality"]["completeness"] == pytest.approx(
- 1 - 1 / (len(rows) * 2)
+ 1 - 6 / (len(rows) * 2)
+ )
+ assert [row["saved_sla"] for row in data["data"][:6]] == [None] * 6
+ metric_column = next(
+ column for column in data["columns"] if column["name"] ==
"saved_sla"
)
+ assert metric_column["null_count"] == 6
+ assert metric_column["unique_count"] == int(has_finite)
assert data.get("query_results") is None
elif export_format == "csv":
import csv
diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
index b88ff947139..909bf61486e 100644
--- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
+++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py
@@ -22,6 +22,7 @@ These tests mock Database.execute() to test the MCP tool's
parameter mapping
and response conversion logic.
"""
+import base64
import logging
from decimal import Decimal
from typing import Any
@@ -42,6 +43,7 @@ from superset.exceptions import (
)
from superset.mcp_service.app import mcp
from superset.mcp_service.sql_lab.schemas import ColumnInfo
+from superset.mcp_service.utils.serialization import BINARY_PREFIX
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
@@ -1154,8 +1156,11 @@ class TestExecuteSql:
row = data["rows"][0]
# UTF-8 decodable bytes should become string
assert row["utf8_data"] == "hello world"
- # Non-UTF-8 bytes should become hex
- assert row["binary_data"] == "000102ff"
+ # Non-UTF-8 bytes are base64-encoded rather than failing the call
+ assert (
+ row["binary_data"]
+ == BINARY_PREFIX +
base64.b64encode(b"\x00\x01\x02\xff").decode()
+ )
@patch("superset.security_manager")
@patch("superset.db")
@@ -1215,53 +1220,55 @@ class TestExecuteSql:
assert isinstance(row["price"], float)
-class TestSanitizeRowValues:
- """Unit tests for _sanitize_row_values helper function."""
-
- def test_sanitize_utf8_bytes(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
+class TestStatementRowSanitization:
+ """Row values are made JSON-safe by the response schema itself, so every
+ tool that returns rows gets the same treatment."""
- rows = [{"data": b"hello"}]
- _sanitize_row_values(rows)
- assert rows[0]["data"] == "hello"
+ @staticmethod
+ def _rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ from superset.mcp_service.sql_lab.tool.execute_sql import (
+ _data_to_statement_data,
+ )
- def test_sanitize_non_utf8_bytes(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
+ return _data_to_statement_data(rows).rows
- rows = [{"data": b"\x00\xff"}]
- _sanitize_row_values(rows)
- assert rows[0]["data"] == "00ff"
+ def test_utf8_bytes_decode_to_text(self):
+ assert self._rows([{"data": b"hello"}])[0]["data"] == "hello"
- def test_sanitize_memoryview(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
+ def test_non_utf8_bytes_are_base64_encoded(self):
+ value = self._rows([{"data": b"\x00\xff"}])[0]["data"]
+ assert value == BINARY_PREFIX + base64.b64encode(b"\x00\xff").decode()
- rows = [{"data": memoryview(b"test")}]
- _sanitize_row_values(rows)
- assert rows[0]["data"] == "test"
+ def test_memoryview_is_handled(self):
+ assert self._rows([{"data": memoryview(b"test")}])[0]["data"] == "test"
- def test_sanitize_decimal(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
+ def test_decimal_becomes_float(self):
+ row = self._rows([{"price": Decimal("19.99"), "count":
Decimal("42")}])[0]
+ assert row["price"] == 19.99
+ assert isinstance(row["price"], float)
+ assert row["count"] == 42.0
- rows = [{"price": Decimal("19.99"), "count": Decimal("42")}]
- _sanitize_row_values(rows)
- assert rows[0]["price"] == 19.99
- assert isinstance(rows[0]["price"], float)
- assert rows[0]["count"] == 42.0
+ def test_missing_values_become_none(self):
+ """The old tool-level helper stringified NaT to "NaT"."""
+ row = self._rows([{"ts": pd.NaT, "ratio": float("nan")}])[0]
+ assert row["ts"] is None
+ assert row["ratio"] is None
- def test_sanitize_custom_type_uses_str(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
+ def test_nested_containers_are_sanitized(self):
+ """The old tool-level helper skipped list and dict values, which is how
+ binary data still reached the serializer."""
+ row = self._rows([{"payload": {"blob": b"\xff"}, "items":
[b"\xff"]}])[0]
+ assert row["payload"]["blob"].startswith(BINARY_PREFIX)
+ assert row["items"][0].startswith(BINARY_PREFIX)
+ def test_custom_type_uses_str(self):
class CustomType:
def __str__(self):
return "custom_value"
- rows = [{"data": CustomType()}]
- _sanitize_row_values(rows)
- assert rows[0]["data"] == "custom_value"
+ assert self._rows([{"data": CustomType()}])[0]["data"] ==
"custom_value"
def test_preserves_json_serializable_types(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
-
rows = [
{
"str_val": "hello",
@@ -1273,33 +1280,10 @@ class TestSanitizeRowValues:
"dict_val": {"a": 1},
}
]
- original = [dict(row) for row in rows]
- _sanitize_row_values(rows)
- assert rows == original
-
- def test_sanitize_empty_rows(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
-
- rows: list[dict[str, Any]] = []
- _sanitize_row_values(rows)
- assert rows == []
+ assert self._rows(rows) == rows
- def test_sanitize_mixed_types_in_single_row(self):
- from superset.mcp_service.sql_lab.tool.execute_sql import
_sanitize_row_values
-
- rows = [
- {
- "id": 1,
- "name": "test",
- "price": Decimal("9.99"),
- "blob": b"\x00\x01\x02\xff",
- }
- ]
- _sanitize_row_values(rows)
- assert rows[0]["id"] == 1
- assert rows[0]["name"] == "test"
- assert rows[0]["price"] == 9.99
- assert rows[0]["blob"] == "000102ff"
+ def test_empty_rows(self):
+ assert self._rows([]) == []
class TestExecuteSqlOAuth2:
diff --git a/tests/unit_tests/mcp_service/test_response_serialization.py
b/tests/unit_tests/mcp_service/test_response_serialization.py
new file mode 100644
index 00000000000..300b5d0ef9d
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_response_serialization.py
@@ -0,0 +1,321 @@
+# 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.
+
+"""Serialization contract for the data-bearing MCP tool responses.
+
+FastMCP renders every tool return value through
+``pydantic_core.to_json(value, fallback=str)``. ``fallback`` only fires for
+types pydantic does not recognise, so a recognised-but-unrenderable value
+(non-UTF-8 ``bytes``, ``pandas.NaT``) raises ``PydanticSerializationError`` and
+destroys the whole tool call after the warehouse query already succeeded.
+These tests drive that real serializer — asserting on field values alone is
+what let the failure recur after it was first patched.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+import pandas as pd
+import pytest
+from fastmcp.tools.base import default_serializer
+from pydantic import BaseModel
+from pydantic_core import to_jsonable_python
+
+from superset.mcp_service.chart.schemas import (
+ ChartData,
+ ChartQueryResult,
+ DataColumn,
+ PerformanceMetadata,
+)
+from superset.mcp_service.common.cache_schemas import CacheStatus
+from superset.mcp_service.dashboard.schemas import (
+ DashboardChartData,
+ DashboardChartQueryData,
+)
+from superset.mcp_service.dataset.schemas import QueryDatasetResponse
+from superset.mcp_service.semantic_layer.schemas import GetTableResponse
+from superset.mcp_service.sql_lab.schemas import (
+ ExecuteSqlResponse,
+ StatementData,
+ StatementInfo,
+)
+from superset.mcp_service.utils.serialization import BINARY_PREFIX
+from superset.utils.json import loads as json_loads
+
+#: A row as a driver can really hand it back: an undecodable BLOB and a null
+#: timestamp materialised as ``pandas.NaT``.
+HOSTILE_ROW: dict[str, Any] = {
+ "blob": b"\xff\xfe\x00\x01",
+ "ts": pd.NaT,
+ "ratio": float("nan"),
+ "nested": {"inner": b"\x80"},
+}
+
+#: Some engines report ``rowcount`` as a float. A fractional value is used
+#: deliberately: pydantic accepts an integral float for an ``int`` field on its
+#: own, so only a fractional one proves the coercion is doing the work.
+FLOAT_COUNT = 5.7
+
+
+def _serialize(response: BaseModel) -> dict[str, Any]:
+ """Serialize exactly as FastMCP does and parse back as strict JSON.
+
+ ``loads`` defaults to ``allow_nan=False``, so a payload carrying the
+ non-standard ``NaN``/``Infinity`` literals fails here too."""
+ payload = json_loads(default_serializer(response))
+ # Structured content takes the other pydantic_core entry point.
+ to_jsonable_python(value=response)
+ return payload
+
+
+def _assert_row_is_safe(row: dict[str, Any]) -> None:
+ """Assert that binary, missing, and nested result values are JSON-safe."""
+ assert row["blob"].startswith(BINARY_PREFIX)
+ assert row["ts"] is None
+ assert row["ratio"] is None
+ assert row["nested"]["inner"].startswith(BINARY_PREFIX)
+
+
+def _execute_sql_response() -> ExecuteSqlResponse:
+ """Build a SQL Lab response with hostile rows and statement data."""
+ return ExecuteSqlResponse(
+ success=True,
+ rows=[HOSTILE_ROW],
+ columns=[],
+ row_count=FLOAT_COUNT,
+ affected_rows=FLOAT_COUNT,
+ statements=[
+ StatementInfo(
+ original_sql="SELECT 1",
+ executed_sql="SELECT 1",
+ row_count=FLOAT_COUNT,
+ data=StatementData(rows=[HOSTILE_ROW], columns=[]),
+ )
+ ],
+ )
+
+
+def _data_column() -> DataColumn:
+ """Column metadata derives its samples and stats from the rows, so it
+ carries the same hostile values."""
+ return DataColumn(
+ name="blob",
+ display_name="blob",
+ data_type="bytes",
+ sample_values=[b"\xff\xfe", pd.NaT],
+ null_count=1,
+ unique_count=2,
+ statistics={"min": b"\xff\xfe"},
+ )
+
+
+def _chart_data() -> ChartData:
+ """Build a chart response with hostile rows, metadata, and query
results."""
+ return ChartData(
+ chart_id=1,
+ chart_name="chart",
+ chart_type="table",
+ columns=[_data_column()],
+ data=[HOSTILE_ROW],
+ query_results=[
+ ChartQueryResult(
+ query_index=0,
+ columns=["blob"],
+ data=[HOSTILE_ROW],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ )
+ ],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ data_freshness=None,
+ summary="summary",
+ insights=[],
+ data_quality={},
+ recommended_visualizations=[],
+ performance=PerformanceMetadata(
+ query_duration_ms=42, cache_status="fresh_query"
+ ),
+ cache_status=CacheStatus(cache_hit=False),
+ )
+
+
+def _query_dataset_response() -> QueryDatasetResponse:
+ """Build a dataset response with hostile rows and column metadata."""
+ return QueryDatasetResponse(
+ dataset_id=1,
+ dataset_name="dataset",
+ columns=[_data_column()],
+ data=[HOSTILE_ROW],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ )
+
+
+def _get_table_response() -> GetTableResponse:
+ """Build a semantic-layer table response with hostile warehouse values."""
+ return GetTableResponse(
+ columns=[_data_column()],
+ data=[HOSTILE_ROW],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ summary="summary",
+ source="builtin",
+ )
+
+
+def _dashboard_chart_data() -> DashboardChartData:
+ """Build a dashboard chart response with hostile samples and query data."""
+ return DashboardChartData(
+ chart_id=1,
+ chart_name="chart",
+ chart_type="table",
+ columns=["blob"],
+ sample_data=[HOSTILE_ROW],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ queries=[
+ DashboardChartQueryData(
+ query_index=0,
+ columns=["blob"],
+ sample_data=[HOSTILE_ROW],
+ row_count=FLOAT_COUNT,
+ total_rows=FLOAT_COUNT,
+ )
+ ],
+ )
+
+
[email protected](
+ "build,rows_field,count_fields",
+ [
+ pytest.param(
+ _execute_sql_response,
+ "rows",
+ ("row_count", "affected_rows"),
+ id="execute_sql",
+ ),
+ pytest.param(
+ _chart_data, "data", ("row_count", "total_rows"),
id="get_chart_data"
+ ),
+ pytest.param(
+ _query_dataset_response,
+ "data",
+ ("row_count", "total_rows"),
+ id="query_dataset",
+ ),
+ pytest.param(
+ _get_table_response, "data", ("row_count", "total_rows"),
id="get_table"
+ ),
+ pytest.param(
+ _dashboard_chart_data,
+ "sample_data",
+ ("row_count", "total_rows"),
+ id="get_dashboard_data",
+ ),
+ ],
+)
+def test_hostile_result_set_serializes(
+ build: Callable[[], BaseModel],
+ rows_field: str,
+ count_fields: tuple[str, ...],
+) -> None:
+ """Float row counts and a non-UTF-8 binary column must not abort the
call."""
+ payload = _serialize(build())
+
+ for field in count_fields:
+ assert payload[field] == 5, field
+ _assert_row_is_safe(payload[rows_field][0])
+
+
+def test_execute_sql_statements_are_sanitized() -> None:
+ payload = _serialize(_execute_sql_response())
+ statement = payload["statements"][0]
+
+ assert statement["row_count"] == 5
+ _assert_row_is_safe(statement["data"]["rows"][0])
+
+
+def test_chart_query_results_are_sanitized() -> None:
+ payload = _serialize(_chart_data())
+ result = payload["query_results"][0]
+
+ assert result["row_count"] == result["total_rows"] == 5
+ _assert_row_is_safe(result["data"][0])
+
+
[email protected](
+ "build",
+ [_chart_data, _query_dataset_response, _get_table_response],
+ ids=["get_chart_data", "query_dataset", "get_table"],
+)
+def test_column_metadata_is_sanitized(build: Callable[[], BaseModel]) -> None:
+ """Column samples and statistics are lifted straight out of the rows."""
+ column = _serialize(build())["columns"][0]
+
+ assert column["sample_values"][0].startswith(BINARY_PREFIX)
+ assert column["sample_values"][1] is None
+ assert column["statistics"]["min"].startswith(BINARY_PREFIX)
+ assert column["null_count"] == 1
+ assert column["unique_count"] == 2
+
+
[email protected](
+ "count,expected",
+ [
+ (5.7, 5),
+ (float("nan"), None),
+ ("unknown", None),
+ (None, None),
+ (True, None),
+ (False, None),
+ ],
+ ids=["fractional-float", "nan", "non-numeric", "missing", "true", "false"],
+)
+def test_unusable_nullable_count_becomes_null(count: Any, expected: int |
None) -> None:
+ """A count the engine reports in an unusable form must not fail the
call."""
+ response = QueryDatasetResponse(
+ dataset_id=1, dataset_name="dataset", data=[], row_count=0,
total_rows=count
+ )
+
+ assert _serialize(response)["total_rows"] == expected
+
+
+def test_unusable_required_count_becomes_zero() -> None:
+ """A non-nullable count falls back to 0 rather than failing validation."""
+ response = StatementInfo(
+ original_sql="SET x", executed_sql="SET x", row_count="unknown"
+ )
+
+ assert _serialize(response)["row_count"] == 0
+
+
+def test_clean_results_are_untouched() -> None:
+ """The coercion must not disturb ordinary payloads."""
+ response = QueryDatasetResponse(
+ dataset_id=1,
+ dataset_name="dataset",
+ data=[{"count": 3, "label": "a", "ratio": 1.5}],
+ row_count=1,
+ total_rows=None,
+ )
+ payload = _serialize(response)
+
+ assert payload["data"] == [{"count": 3, "label": "a", "ratio": 1.5}]
+ assert payload["row_count"] == 1
+ assert payload["total_rows"] is None
diff --git a/tests/unit_tests/mcp_service/utils/test_serialization.py
b/tests/unit_tests/mcp_service/utils/test_serialization.py
new file mode 100644
index 00000000000..ac1df122d71
--- /dev/null
+++ b/tests/unit_tests/mcp_service/utils/test_serialization.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.
+
+"""Unit tests for the MCP JSON-safety helpers."""
+
+from __future__ import annotations
+
+import base64
+from collections import deque, UserDict
+from collections.abc import Callable
+from decimal import Decimal
+from types import MappingProxyType
+from typing import Any
+from unittest.mock import patch
+
+import numpy as np
+import pandas as pd
+import pytest
+from fastmcp.tools.base import default_serializer
+from pydantic import TypeAdapter, ValidationError
+from pydantic_core import to_jsonable_python
+
+from superset.mcp_service.utils.serialization import (
+ BINARY_PREFIX,
+ coerce_int,
+ coerce_optional_int,
+ is_missing_value,
+ JsonSafeMapping,
+ JsonSafeRows,
+ JsonSafeValues,
+ MAX_DEPTH,
+ OptionalRowCount,
+ RowCount,
+ sanitize_json_value,
+ sanitize_mapping,
+)
+from superset.utils.json import loads as json_loads
+
+
+def test_utf8_bytes_decode_to_text() -> None:
+ assert sanitize_json_value(b"hello") == "hello"
+
+
+def test_non_utf8_bytes_become_base64() -> None:
+ raw = b"\xff\xfe\x00\x01"
+ assert sanitize_json_value(raw) == BINARY_PREFIX +
base64.b64encode(raw).decode()
+
+
[email protected]("value", [bytearray(b"\x80\x81"),
memoryview(b"\x80\x81")])
+def test_bytearray_and_memoryview_are_handled(value: Any) -> None:
+ """``bytes`` is not the only binary type a DBAPI driver returns."""
+ assert (
+ sanitize_json_value(value)
+ == BINARY_PREFIX + base64.b64encode(b"\x80\x81").decode()
+ )
+
+
[email protected](
+ "value", [pd.NaT, np.datetime64("NaT"), float("nan"), np.nan, pd.NA]
+)
+def test_missing_values_become_null(value: Any) -> None:
+ """``pandas.NaT`` is a ``datetime`` subclass backed by a float; pydantic
+ tries to render it as a datetime and raises ``TypeError: 'float' object
+ cannot be interpreted as an integer``."""
+ assert sanitize_json_value(value) is None
+
+
[email protected]("value", [float("inf"), float("-inf")])
+def test_infinities_become_null(value: float) -> None:
+ """``Infinity`` is not valid JSON, so strict clients reject it."""
+ assert sanitize_json_value(value) is None
+
+
+def test_lone_surrogates_are_replaced() -> None:
+ """Drivers decoding with ``surrogatepass`` produce strings that cannot be
+ encoded back to UTF-8."""
+ value = b"\xed\xa0\xbd".decode("utf-8", "surrogatepass")
+ sanitized = sanitize_json_value(value)
+ assert sanitized.encode("utf-8") # no UnicodeEncodeError
+ assert "\ud83d" not in sanitized
+
+
+def test_ordinary_scalars_pass_through() -> None:
+ assert sanitize_json_value("abc") == "abc"
+ assert sanitize_json_value(7) == 7
+ assert sanitize_json_value(True) is True
+ assert sanitize_json_value(1.5) == 1.5
+ assert sanitize_json_value(None) is None
+
+
[email protected](
+ "value,expected",
+ [
+ (np.int64(3), 3),
+ (np.int32(3), 3),
+ (np.int8(3), 3),
+ (np.uint16(3), 3),
+ (np.float64(1.5), 1.5),
+ (np.float32(1.5), 1.5),
+ (np.bool_(True), True),
+ ],
+)
+def test_numpy_scalars_of_every_width_are_normalised(value: Any, expected:
Any) -> None:
+ """Rendering a numpy scalar as text would silently turn numbers into
+ strings for the caller."""
+ sanitized = sanitize_json_value(value)
+ assert sanitized == expected
+ assert isinstance(sanitized, type(expected))
+
+
[email protected]("value", [np.float32("inf"), np.float64("-inf")])
+def test_non_finite_numpy_floats_become_null(value: Any) -> None:
+ assert sanitize_json_value(value) is None
+
+
+def test_numpy_arrays_become_lists() -> None:
+ assert sanitize_json_value(np.array([1, 2])) == [1, 2]
+
+
+def test_decimal_becomes_float() -> None:
+ """A JSON number is more useful to the caller than a quoted string."""
+ sanitized = sanitize_json_value(Decimal("19.99"))
+ assert sanitized == 19.99
+ assert isinstance(sanitized, float)
+
+
+def test_unknown_types_are_stringified() -> None:
+ """The structured-content path (``to_jsonable_python``) has no
+ ``fallback=str``, so unknown values cannot be left as-is."""
+
+ class Custom:
+ def __str__(self) -> str:
+ return "custom_value"
+
+ assert sanitize_json_value(Custom()) == "custom_value"
+
+
[email protected](
+ "value,expected",
+ [
+ (None, True),
+ (pd.NaT, True),
+ (pd.NA, True),
+ (float("nan"), True),
+ (np.float32("nan"), True),
+ (0, False),
+ ("", False),
+ ("NaN", False),
+ ("NaT", False),
+ ("None", False),
+ (b"\xff", False),
+ ([], False),
+ ([1, 2], False),
+ ],
+)
+def test_is_missing_value(value: Any, expected: bool) -> None:
+ assert is_missing_value(value) is expected
+
+
+def test_nested_containers_are_walked() -> None:
+ value = {"a": [{"b": b"\xff"}], "c": (pd.NaT, float("nan"))}
+ assert sanitize_json_value(value) == {
+ "a": [{"b": BINARY_PREFIX + base64.b64encode(b"\xff").decode()}],
+ "c": [None, None],
+ }
+
+
+def test_non_string_mapping_keys_are_stringified() -> None:
+ assert sanitize_json_value({1: "a"}) == {"1": "a"}
+
+
+def test_deep_nesting_is_capped_instead_of_recursing() -> None:
+ value: Any = b"\xff"
+ for _ in range(MAX_DEPTH + 5):
+ value = [value]
+ # The cap renders the tail as text rather than recursing forever; the
+ # point is that it returns something JSON-encodable at all.
+ assert json_loads(default_serializer(sanitize_json_value(value)))
+
+
+def test_sanitize_mapping_leaves_non_mappings_to_pydantic() -> None:
+ assert sanitize_mapping(5) == 5
+ assert sanitize_mapping({"a": b"\xff"}) == {
+ "a": BINARY_PREFIX + base64.b64encode(b"\xff").decode()
+ }
+
+
[email protected](
+ "value,expected",
+ [
+ (None, None),
+ (5, 5),
+ (5.0, 5),
+ (5.9, 5),
+ (Decimal("5.9"), 5),
+ ("nonsense", None),
+ (float("nan"), None),
+ (object(), None),
+ ],
+)
+def test_coerce_optional_int(value: Any, expected: int | None) -> None:
+ assert coerce_optional_int(value) == expected
+
+
+def test_coerce_int_falls_back_to_zero() -> None:
+ assert coerce_int(None) == 0
+ assert coerce_int("nonsense") == 0
+ assert coerce_int(5.9) == 5
+
+
[email protected]("container", [list, tuple, set, frozenset, iter,
deque])
[email protected]("value", [b"\xff", pd.NaT, float("inf"), "\ud800"])
+def test_json_safe_values_sanitize_coerced_iterables(
+ container: Callable[[list[Any]], Any], value: Any
+) -> None:
+ """Every accepted iterable must sanitize its elements before
serialization."""
+ values = TypeAdapter(JsonSafeValues).validate_python(container([value]))
+ expected = [sanitize_json_value(value)]
+ assert json_loads(default_serializer(values)) == expected
+ assert to_jsonable_python(values) == expected
+
+
+def test_json_safe_values_sanitize_generator() -> None:
+ """One-shot generators must be consumed once and sanitized element by
element."""
+ values = TypeAdapter(JsonSafeValues).validate_python(
+ value for value in [b"\xff", pd.NaT]
+ )
+ assert json_loads(default_serializer(values)) == ["base64:/w==", None]
+ assert to_jsonable_python(values) == ["base64:/w==", None]
+
+
[email protected]("container", [dict, MappingProxyType, UserDict])
+def test_json_safe_mapping_sanitizes_general_mappings(
+ container: Callable[[dict[str, Any]], Any],
+) -> None:
+ """Mapping implementations and nested mappings retain their JSON object
shape."""
+ mapping = TypeAdapter(JsonSafeMapping).validate_python(
+ container({"blob": b"\xff", "nested": container({"ts": pd.NaT})})
+ )
+ expected = {"blob": "base64:/w==", "nested": {"ts": None}}
+ assert json_loads(default_serializer(mapping)) == expected
+ assert to_jsonable_python(mapping) == expected
+
+
[email protected]("container", [list, tuple, iter, deque])
+def test_json_safe_rows_sanitize_coerced_iterables(
+ container: Callable[[list[Any]], Any],
+) -> None:
+ """Row mappings must be sanitized after the outer iterable is coerced."""
+ rows = TypeAdapter(JsonSafeRows).validate_python(
+ container([MappingProxyType({"blob": b"\xff", "ts": pd.NaT})])
+ )
+ expected = [{"blob": "base64:/w==", "ts": None}]
+ assert json_loads(default_serializer(rows)) == expected
+ assert to_jsonable_python(rows) == expected
+
+
[email protected]("value", ["text", b"bytes", bytearray(b"bytes"), {},
5, None])
[email protected]("alias", [JsonSafeRows, JsonSafeValues])
+def test_json_safe_sequences_reject_invalid_containers(value: Any, alias: Any)
-> None:
+ """Sanitization must not broaden the list schema's accepted input types."""
+ with pytest.raises(ValidationError):
+ TypeAdapter(alias).validate_python(value)
+
+
[email protected]("value", [True, False, np.bool_(True),
np.bool_(False)])
+def test_boolean_counts_are_unavailable(value: Any) -> None:
+ """Booleans are not warehouse row counts, including numpy booleans."""
+ assert coerce_optional_int(value) is None
+ assert coerce_int(value) == 0
+ assert TypeAdapter(OptionalRowCount).validate_python(value) is None
+ assert TypeAdapter(RowCount).validate_python(value) == 0
+
+
+def test_string_missing_check_does_not_call_pandas() -> None:
+ """Literal strings cannot represent missing scalars and need no pandas
call."""
+ with patch("superset.mcp_service.utils.serialization.pd.isna") as isna:
+ for value in ("", "NaN", "NaT", "None", "ordinary"):
+ assert is_missing_value(value) is False
+ isna.assert_not_called()