Files
superset2/superset/mcp_service/utils/response_utils.py

220 lines
7.6 KiB
Python

# 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.
"""
Utilities for building MCP tool responses with explicit omission metadata.
When MCP tool responses strip large fields to reduce context window usage,
the LLM agent should be told *what* was omitted and *why* — otherwise it
cannot distinguish "field is empty" from "field was stripped for size".
This module provides a reusable builder for omission metadata that any
MCP tool serializer can use.
Industry context (as of 2026):
- The MCP spec has no standard for field omission signaling.
- Silent omission is considered an anti-pattern (Grafana MCP #557).
- Production servers (mcp-git-polite, Blockscout, Axiom) converge on
explicit omission indicators with size hints and retrieval guidance.
- Anthropic's "Writing Tools for Agents" blog recommends surfacing
what was stripped so agents can decide whether to fetch full data.
Usage example::
from superset.mcp_service.utils.response_utils import OmittedFieldsBuilder
omitted = (
OmittedFieldsBuilder()
.add_raw_field(
"position_json",
raw_value=dashboard.position_json,
reason="Internal layout tree — not useful for analysis.",
)
.add_extracted_field(
"json_metadata",
raw_value=dashboard.json_metadata,
reason="native_filters and cross_filters_enabled extracted above.",
)
.build()
)
# Returns: {"position_json": "Omitted (~42 KB) — ...", ...}
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from superset.mcp_service.chart.schemas import DataColumn
import humanize
def humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
return humanize.naturaltime(now - dt)
def _byte_size_label(value: str | None) -> str:
"""Return a human-readable size label for a string value."""
if not value or not isinstance(value, str):
return "empty"
size_bytes = len(value.encode("utf-8", errors="replace"))
if size_bytes < 1024:
return f"{size_bytes} B"
return f"{size_bytes / 1024:.0f} KB"
class OmittedFieldsBuilder:
"""Builder for constructing omission metadata dicts.
Produces a ``Dict[str, str]`` mapping field names to human-readable
descriptions of what was omitted, including approximate sizes.
Two field types are supported:
- **Raw fields** (``add_raw_field``): The field was stripped entirely
with no replacement. The agent has no way to access this data
unless a companion tool exists.
- **Extracted fields** (``add_extracted_field``): The raw blob was
stripped, but useful subsets were extracted into structured fields
on the same response object (e.g. ``native_filters`` extracted
from ``json_metadata``).
All methods return ``self`` for fluent chaining.
"""
def __init__(self) -> None:
self._fields: Dict[str, str] = {}
def add_raw_field(
self,
field_name: str,
raw_value: str | None,
reason: str,
) -> "OmittedFieldsBuilder":
"""Record a field that was stripped with no replacement.
Parameters
----------
field_name:
The original field name (e.g. ``"position_json"``).
raw_value:
The raw value that was omitted (used only to compute size).
Pass ``None`` if the field was empty/unset.
reason:
Why the field was omitted, written for an LLM audience.
"""
size = _byte_size_label(raw_value)
has_data = isinstance(raw_value, str) and len(raw_value) > 0
if has_data:
self._fields[field_name] = f"Omitted (~{size}) — {reason}"
else:
self._fields[field_name] = f"Omitted ({size}) — {reason}"
return self
def add_extracted_field(
self,
field_name: str,
raw_value: str | None,
reason: str,
) -> "OmittedFieldsBuilder":
"""Record a field whose useful parts were extracted into other fields.
Parameters
----------
field_name:
The original raw field name (e.g. ``"json_metadata"``).
raw_value:
The raw value that was omitted (used only to compute size).
reason:
Explanation of what was extracted and where, for LLM context.
"""
size = _byte_size_label(raw_value)
has_data = isinstance(raw_value, str) and len(raw_value) > 0
if has_data:
self._fields[field_name] = (
f"Omitted (~{size}), useful parts extracted — {reason}"
)
else:
self._fields[field_name] = (
f"Omitted ({size}), useful parts extracted — {reason}"
)
return self
def build(self) -> Dict[str, str]:
"""Return the omission metadata dict."""
return dict(self._fields)
STATS_ROW_CAP: int = 5000
def format_data_columns(
data: list[dict[str, Any]], raw_columns: list[str]
) -> list[DataColumn]:
"""Build column metadata from query result data.
Caps null_count/unique_count computation at STATS_ROW_CAP rows to avoid
O(rows*cols) overhead on large result sets. When the result exceeds the
cap, those counts are marked as sampled/approximate via ``statistics``
instead of being reported as exact full-dataset totals.
"""
# Local import breaks the chart.schemas ↔ response_utils circular dependency.
from superset.mcp_service.chart.schemas import DataColumn # noqa: PLC0415
stats_rows: list[dict[str, Any]] = data[:STATS_ROW_CAP]
is_sampled: bool = len(data) > STATS_ROW_CAP
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
]
data_type: str = "string"
if sample_values:
if all(isinstance(v, bool) for v in sample_values):
data_type = "boolean"
elif all(isinstance(v, (int, float)) for v in sample_values):
data_type = "numeric"
null_count = 0
unique_vals: set[str] = set()
for row in stats_rows:
val = row.get(col_name)
if val is None:
null_count += 1
else:
unique_vals.add(str(val))
columns_meta.append(
DataColumn(
name=col_name,
display_name=col_name.replace("_", " ").title(),
data_type=data_type,
sample_values=sample_values[:3],
null_count=null_count,
unique_count=len(unique_vals),
statistics={"sampled_rows": len(stats_rows)} if is_sampled else None,
)
)
return columns_meta