Skip to content

Commit 4764eea

Browse files
aminghadersohimichael-s-molina
authored andcommitted
fix(mcp): add dynamic response truncation for oversized info tool responses (#39107)
(cherry picked from commit 83ad1ec)
1 parent bc86901 commit 4764eea

4 files changed

Lines changed: 643 additions & 4 deletions

File tree

superset/mcp_service/middleware.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,72 @@ def __init__(
936936
excluded_tools = [excluded_tools]
937937
self.excluded_tools = set(excluded_tools or [])
938938

939+
def _try_truncate_info_response(
940+
self,
941+
tool_name: str,
942+
response: Any,
943+
estimated_tokens: int,
944+
) -> Any | None:
945+
"""Attempt to dynamically truncate an info tool response to fit the limit.
946+
947+
Returns the truncated response if successful, None otherwise.
948+
"""
949+
from superset.mcp_service.utils.token_utils import (
950+
estimate_response_tokens,
951+
truncate_oversized_response,
952+
)
953+
954+
try:
955+
truncated, was_truncated, notes = truncate_oversized_response(
956+
response, self.token_limit
957+
)
958+
except (MemoryError, RecursionError) as trunc_error:
959+
logger.warning(
960+
"Truncation failed for %s due to %s: %s",
961+
tool_name,
962+
type(trunc_error).__name__,
963+
trunc_error,
964+
)
965+
return None
966+
967+
if not was_truncated:
968+
return None
969+
970+
truncated_tokens = estimate_response_tokens(truncated)
971+
if truncated_tokens > self.token_limit:
972+
return None
973+
974+
logger.warning(
975+
"Response for %s truncated from ~%d to ~%d tokens (limit: %d). Fields: %s",
976+
tool_name,
977+
estimated_tokens,
978+
truncated_tokens,
979+
self.token_limit,
980+
"; ".join(notes),
981+
)
982+
983+
try:
984+
user_id = get_user_id()
985+
event_logger.log(
986+
user_id=user_id,
987+
action="mcp_response_truncated",
988+
curated_payload={
989+
"tool": tool_name,
990+
"original_tokens": estimated_tokens,
991+
"truncated_tokens": truncated_tokens,
992+
"token_limit": self.token_limit,
993+
"truncation_notes": notes,
994+
},
995+
)
996+
except Exception as log_error: # noqa: BLE001
997+
logger.warning("Failed to log truncation event: %s", log_error)
998+
999+
if isinstance(truncated, dict):
1000+
truncated["_response_truncated"] = True
1001+
truncated["_truncation_notes"] = notes
1002+
1003+
return truncated
1004+
9391005
async def on_call_tool(
9401006
self,
9411007
context: MiddlewareContext,
@@ -984,9 +1050,18 @@ async def on_call_tool(
9841050

9851051
# Block if over limit
9861052
if estimated_tokens > self.token_limit:
987-
# Extract params for smart suggestions
9881053
params = getattr(context.message, "params", {}) or {}
9891054

1055+
# For info tools, try dynamic truncation before blocking
1056+
from superset.mcp_service.utils.token_utils import INFO_TOOLS
1057+
1058+
if tool_name in INFO_TOOLS:
1059+
truncated = self._try_truncate_info_response(
1060+
tool_name, response, estimated_tokens
1061+
)
1062+
if truncated is not None:
1063+
return truncated
1064+
9901065
# Log the blocked response
9911066
logger.error(
9921067
"Response blocked for %s: ~%d tokens exceeds limit of %d",
@@ -1011,9 +1086,6 @@ async def on_call_tool(
10111086
except Exception as log_error: # noqa: BLE001
10121087
logger.warning("Failed to log size exceeded event: %s", log_error)
10131088

1014-
# Generate helpful error message with suggestions
1015-
# Avoid passing the full `response` (which may be huge) into the formatter
1016-
# to prevent large-memory operations during error formatting.
10171089
error_message = format_size_limit_error(
10181090
tool_name=tool_name,
10191091
params=params,

superset/mcp_service/utils/token_utils.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,210 @@ def _get_tool_specific_suggestions(
382382
return suggestions
383383

384384

385+
# Tools eligible for dynamic response truncation instead of hard blocking.
386+
# These tools return single objects (not paginated lists) where truncation
387+
# is preferable to returning an error.
388+
INFO_TOOLS = frozenset(
389+
{
390+
"get_chart_info",
391+
"get_dataset_info",
392+
"get_dashboard_info",
393+
"get_instance_info",
394+
}
395+
)
396+
397+
# Maximum character length for string fields before truncation
398+
_MAX_STRING_CHARS = 500
399+
# Maximum items to keep in list fields before truncation
400+
_MAX_LIST_ITEMS = 30
401+
# Maximum keys to keep when summarizing large dict fields
402+
_MAX_DICT_KEYS = 20
403+
404+
405+
def _truncate_strings(
406+
data: Dict[str, Any], notes: List[str], max_chars: int = _MAX_STRING_CHARS
407+
) -> bool:
408+
"""Truncate string fields exceeding max_chars at the top level only."""
409+
changed = False
410+
for key, value in data.items():
411+
if isinstance(value, str) and len(value) > max_chars:
412+
original_len = len(value)
413+
data[key] = value[:max_chars] + f"... [truncated from {original_len} chars]"
414+
notes.append(f"Field '{key}' truncated from {original_len} chars")
415+
changed = True
416+
return changed
417+
418+
419+
def _truncate_strings_recursive(
420+
data: Any,
421+
notes: List[str],
422+
max_chars: int = _MAX_STRING_CHARS,
423+
path: str = "",
424+
_depth: int = 0,
425+
) -> bool:
426+
"""Recursively truncate strings throughout the entire data tree.
427+
428+
Walks nested dicts and list items to catch strings like
429+
``charts[0].description`` that top-level truncation misses.
430+
Depth is capped at 10 to avoid runaway recursion.
431+
"""
432+
if _depth > 10:
433+
return False
434+
changed = False
435+
if isinstance(data, dict):
436+
for key, value in data.items():
437+
field_path = f"{path}.{key}" if path else key
438+
if isinstance(value, str) and len(value) > max_chars:
439+
original_len = len(value)
440+
data[key] = (
441+
value[:max_chars] + f"... [truncated from {original_len} chars]"
442+
)
443+
notes.append(
444+
f"Field '{field_path}' truncated from {original_len} chars"
445+
)
446+
changed = True
447+
elif isinstance(value, (dict, list)):
448+
changed |= _truncate_strings_recursive(
449+
value, notes, max_chars, field_path, _depth + 1
450+
)
451+
elif isinstance(data, list):
452+
for i, item in enumerate(data):
453+
if isinstance(item, (dict, list)):
454+
changed |= _truncate_strings_recursive(
455+
item, notes, max_chars, f"{path}[{i}]", _depth + 1
456+
)
457+
return changed
458+
459+
460+
def _truncate_lists(data: Dict[str, Any], notes: List[str], max_items: int) -> bool:
461+
"""Truncate list fields exceeding max_items. Returns True if any truncated.
462+
463+
Does NOT append marker objects into the list to preserve the element type
464+
contract (e.g. ``List[TableColumnInfo]`` stays homogeneous). Truncation
465+
metadata is communicated through the *notes* list and top-level response
466+
fields ``_response_truncated`` / ``_truncation_notes``.
467+
"""
468+
changed = False
469+
for key, value in data.items():
470+
if isinstance(value, list) and len(value) > max_items:
471+
original_len = len(value)
472+
data[key] = value[:max_items]
473+
notes.append(
474+
f"Field '{key}' truncated from {original_len} to {max_items} items"
475+
)
476+
changed = True
477+
return changed
478+
479+
480+
def _summarize_large_dicts(
481+
data: Dict[str, Any], notes: List[str], max_keys: int = _MAX_DICT_KEYS
482+
) -> bool:
483+
"""Replace large dict fields with key summaries. Returns True if any changed."""
484+
changed = False
485+
for key, value in data.items():
486+
if isinstance(value, dict) and len(value) > max_keys:
487+
keys_list = list(value.keys())[:max_keys]
488+
data[key] = {
489+
"_truncated": True,
490+
"_message": (
491+
f"Dict with {len(value)} keys truncated. "
492+
f"Keys: {', '.join(str(k) for k in keys_list)}..."
493+
),
494+
}
495+
notes.append(f"Field '{key}' dict summarized ({len(value)} keys)")
496+
changed = True
497+
return changed
498+
499+
500+
def _replace_collections_with_summaries(data: Dict[str, Any], notes: List[str]) -> bool:
501+
"""Replace all non-empty list/dict fields with empty/minimal values.
502+
503+
Lists are emptied (preserving the list type) rather than replaced with
504+
marker objects to avoid breaking typed list contracts.
505+
"""
506+
changed = False
507+
for key, value in list(data.items()):
508+
if not isinstance(value, (list, dict)) or not value:
509+
continue
510+
count = len(value)
511+
if isinstance(value, list):
512+
data[key] = []
513+
notes.append(f"Field '{key}' list ({count} items) cleared to fit limit")
514+
else:
515+
data[key] = {}
516+
notes.append(f"Field '{key}' dict ({count} keys) cleared to fit limit")
517+
changed = True
518+
return changed
519+
520+
521+
def _is_under_limit(data: Dict[str, Any], token_limit: int) -> bool:
522+
"""Check if the serialized data fits within the token limit."""
523+
from superset.utils import json as utils_json
524+
525+
return estimate_token_count(utils_json.dumps(data)) <= token_limit
526+
527+
528+
def truncate_oversized_response(
529+
response: ToolResponse,
530+
token_limit: int,
531+
) -> tuple[ToolResponse, bool, list[str]]:
532+
"""
533+
Dynamically truncate large fields in a response to fit within the token limit.
534+
535+
Applies five progressive phases of truncation:
536+
1. Truncate long top-level string fields
537+
2. Truncate large list fields to _MAX_LIST_ITEMS
538+
3. Recursively truncate strings in nested structures (list items, nested dicts)
539+
4. Aggressively reduce lists to 10 items and summarize large dicts
540+
5. Replace all collections with empty values
541+
542+
Args:
543+
response: The tool response (Pydantic model, dict, or other).
544+
token_limit: Maximum estimated tokens allowed.
545+
546+
Returns:
547+
A tuple of (possibly-truncated response, was_truncated, list of notes).
548+
"""
549+
notes: list[str] = []
550+
551+
# Convert to a mutable dict for manipulation
552+
if hasattr(response, "model_dump"):
553+
data = response.model_dump()
554+
elif isinstance(response, dict):
555+
data = dict(response)
556+
else:
557+
return response, False, notes
558+
559+
was_truncated = False
560+
561+
# Phase 1: Truncate long string fields
562+
was_truncated |= _truncate_strings(data, notes)
563+
if _is_under_limit(data, token_limit):
564+
return data, was_truncated, notes
565+
566+
# Phase 2: Truncate large list fields
567+
was_truncated |= _truncate_lists(data, notes, _MAX_LIST_ITEMS)
568+
if _is_under_limit(data, token_limit):
569+
return data, was_truncated, notes
570+
571+
# Phase 3: Recursively truncate strings inside nested structures
572+
# (e.g. charts[i].description, native_filters[i].config, etc.)
573+
was_truncated |= _truncate_strings_recursive(data, notes)
574+
if _is_under_limit(data, token_limit):
575+
return data, was_truncated, notes
576+
577+
# Phase 4: Aggressively reduce lists and summarize large dicts
578+
was_truncated |= _truncate_lists(data, notes, max_items=10)
579+
was_truncated |= _summarize_large_dicts(data, notes)
580+
if _is_under_limit(data, token_limit):
581+
return data, was_truncated, notes
582+
583+
# Phase 5: Nuclear — replace all collections with empty values
584+
was_truncated |= _replace_collections_with_summaries(data, notes)
585+
586+
return data, was_truncated, notes
587+
588+
385589
def format_size_limit_error(
386590
tool_name: str,
387591
params: Dict[str, Any] | None,

0 commit comments

Comments
 (0)