mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Automatic pagination response wrapper for MCP tools.
|
|
|
|
This module provides utilities to automatically paginate tool responses
|
|
when they exceed token limits.
|
|
"""
|
|
|
|
import json
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from hanzo_mcp.tools.common.pagination import CursorManager
|
|
from hanzo_mcp.tools.common.truncate import estimate_tokens
|
|
|
|
|
|
class AutoPaginatedResponse:
|
|
"""Automatically paginate responses that exceed token limits."""
|
|
|
|
# MCP token limit with safety buffer
|
|
MAX_TOKENS = 20000 # Leave 5k buffer from 25k limit
|
|
|
|
@staticmethod
|
|
def create_response(
|
|
content: Union[str, Dict[str, Any], List[Any]],
|
|
cursor: Optional[str] = None,
|
|
max_tokens: int = MAX_TOKENS,
|
|
) -> Dict[str, Any]:
|
|
"""Create a response that automatically paginates if too large.
|
|
|
|
Args:
|
|
content: The content to return
|
|
cursor: Optional cursor from request
|
|
max_tokens: Maximum tokens allowed in response
|
|
|
|
Returns:
|
|
Dict with content and optional nextCursor
|
|
"""
|
|
# Handle different content types
|
|
if isinstance(content, str):
|
|
return AutoPaginatedResponse._handle_string_response(
|
|
content, cursor, max_tokens
|
|
)
|
|
elif isinstance(content, list):
|
|
return AutoPaginatedResponse._handle_list_response(
|
|
content, cursor, max_tokens
|
|
)
|
|
elif isinstance(content, dict):
|
|
# If dict already has pagination info, return as-is
|
|
if "nextCursor" in content or "cursor" in content:
|
|
return content
|
|
# Otherwise treat as single item
|
|
return AutoPaginatedResponse._handle_dict_response(
|
|
content, cursor, max_tokens
|
|
)
|
|
else:
|
|
# Convert to string for other types
|
|
return AutoPaginatedResponse._handle_string_response(
|
|
str(content), cursor, max_tokens
|
|
)
|
|
|
|
@staticmethod
|
|
def _handle_string_response(
|
|
content: str, cursor: Optional[str], max_tokens: int
|
|
) -> Dict[str, Any]:
|
|
"""Handle pagination for string responses."""
|
|
# Parse cursor to get offset
|
|
offset = 0
|
|
if cursor:
|
|
cursor_data = CursorManager.parse_cursor(cursor)
|
|
if cursor_data and "offset" in cursor_data:
|
|
offset = cursor_data["offset"]
|
|
|
|
# For strings, paginate by lines
|
|
lines = content.split("\n")
|
|
|
|
if offset >= len(lines):
|
|
return {"content": "", "message": "No more content"}
|
|
|
|
# Build response line by line, checking tokens
|
|
result_lines = []
|
|
current_tokens = 0
|
|
line_index = offset
|
|
|
|
# Add header if this is a continuation
|
|
if offset > 0:
|
|
header = f"[Continued from line {offset + 1}]\n"
|
|
current_tokens = estimate_tokens(header)
|
|
result_lines.append(header)
|
|
|
|
while line_index < len(lines):
|
|
line = lines[line_index]
|
|
line_tokens = estimate_tokens(line + "\n")
|
|
|
|
# Check if adding this line would exceed limit
|
|
if current_tokens + line_tokens > max_tokens:
|
|
# Need to paginate
|
|
if not result_lines:
|
|
# Single line too long, truncate it
|
|
truncated_line = line[:1000] + "... [line truncated]"
|
|
result_lines.append(truncated_line)
|
|
line_index += 1
|
|
break
|
|
|
|
result_lines.append(line)
|
|
current_tokens += line_tokens
|
|
line_index += 1
|
|
|
|
# Build response
|
|
response = {"content": "\n".join(result_lines)}
|
|
|
|
# Add pagination info
|
|
if line_index < len(lines):
|
|
response["nextCursor"] = CursorManager.create_offset_cursor(line_index)
|
|
response["pagination_info"] = {
|
|
"current_lines": f"{offset + 1}-{line_index}",
|
|
"total_lines": len(lines),
|
|
"has_more": True,
|
|
}
|
|
else:
|
|
response["pagination_info"] = {
|
|
"current_lines": f"{offset + 1}-{len(lines)}",
|
|
"total_lines": len(lines),
|
|
"has_more": False,
|
|
}
|
|
|
|
return response
|
|
|
|
@staticmethod
|
|
def _handle_list_response(
|
|
items: List[Any], cursor: Optional[str], max_tokens: int
|
|
) -> Dict[str, Any]:
|
|
"""Handle pagination for list responses."""
|
|
# Parse cursor to get offset
|
|
offset = 0
|
|
if cursor:
|
|
cursor_data = CursorManager.parse_cursor(cursor)
|
|
if cursor_data and "offset" in cursor_data:
|
|
offset = cursor_data["offset"]
|
|
|
|
if offset >= len(items):
|
|
return {"items": [], "message": "No more items"}
|
|
|
|
# Build response item by item, checking tokens
|
|
result_items = []
|
|
current_tokens = 100 # Base overhead
|
|
item_index = offset
|
|
|
|
# Add header if continuation
|
|
header_obj = {}
|
|
if offset > 0:
|
|
header_obj["continuation_from"] = offset
|
|
current_tokens += 50
|
|
|
|
while item_index < len(items):
|
|
item = items[item_index]
|
|
|
|
# Estimate tokens for this item
|
|
item_str = json.dumps(item) if not isinstance(item, str) else item
|
|
item_tokens = estimate_tokens(item_str)
|
|
|
|
# Check if adding this item would exceed limit
|
|
if current_tokens + item_tokens > max_tokens:
|
|
if not result_items:
|
|
# Single item too large, truncate it
|
|
if isinstance(item, str):
|
|
truncated = item[:5000] + "... [truncated]"
|
|
result_items.append(truncated)
|
|
else:
|
|
result_items.append(
|
|
{"error": "Item too large", "index": item_index}
|
|
)
|
|
item_index += 1
|
|
break
|
|
|
|
result_items.append(item)
|
|
current_tokens += item_tokens
|
|
item_index += 1
|
|
|
|
# Build response
|
|
response = {"items": result_items}
|
|
|
|
if header_obj:
|
|
response.update(header_obj)
|
|
|
|
# Add pagination info
|
|
if item_index < len(items):
|
|
response["nextCursor"] = CursorManager.create_offset_cursor(item_index)
|
|
response["pagination_info"] = {
|
|
"returned_items": len(result_items),
|
|
"total_items": len(items),
|
|
"has_more": True,
|
|
"next_index": item_index,
|
|
}
|
|
else:
|
|
response["pagination_info"] = {
|
|
"returned_items": len(result_items),
|
|
"total_items": len(items),
|
|
"has_more": False,
|
|
}
|
|
|
|
return response
|
|
|
|
@staticmethod
|
|
def _handle_dict_response(
|
|
content: Dict[str, Any], cursor: Optional[str], max_tokens: int
|
|
) -> Dict[str, Any]:
|
|
"""Handle pagination for dict responses."""
|
|
# For dicts, check if it's too large as-is
|
|
content_str = json.dumps(content, indent=2)
|
|
content_tokens = estimate_tokens(content_str)
|
|
|
|
if content_tokens <= max_tokens:
|
|
# Fits within limit
|
|
return content
|
|
|
|
# Too large - need to paginate
|
|
# Strategy: Convert to key-value pairs and paginate
|
|
items = list(content.items())
|
|
offset = 0
|
|
|
|
if cursor:
|
|
cursor_data = CursorManager.parse_cursor(cursor)
|
|
if cursor_data and "offset" in cursor_data:
|
|
offset = cursor_data["offset"]
|
|
|
|
if offset >= len(items):
|
|
return {"content": {}, "message": "No more content"}
|
|
|
|
# Build paginated dict
|
|
result = {}
|
|
current_tokens = 100 # Base overhead
|
|
|
|
for i in range(offset, len(items)):
|
|
key, value = items[i]
|
|
|
|
# Estimate tokens for this entry
|
|
entry_str = json.dumps({key: value})
|
|
entry_tokens = estimate_tokens(entry_str)
|
|
|
|
if current_tokens + entry_tokens > max_tokens:
|
|
if not result:
|
|
# Single entry too large
|
|
result[key] = "[Value too large - use specific key access]"
|
|
break
|
|
|
|
result[key] = value
|
|
current_tokens += entry_tokens
|
|
|
|
# Wrap in response
|
|
response = {"content": result}
|
|
|
|
# Add pagination info
|
|
processed = offset + len(result)
|
|
if processed < len(items):
|
|
response["nextCursor"] = CursorManager.create_offset_cursor(processed)
|
|
response["pagination_info"] = {
|
|
"keys_returned": len(result),
|
|
"total_keys": len(items),
|
|
"has_more": True,
|
|
}
|
|
else:
|
|
response["pagination_info"] = {
|
|
"keys_returned": len(result),
|
|
"total_keys": len(items),
|
|
"has_more": False,
|
|
}
|
|
|
|
return response
|
|
|
|
|
|
def paginate_if_needed(
|
|
response: Any, cursor: Optional[str] = None, force_pagination: bool = False
|
|
) -> Union[str, Dict[str, Any]]:
|
|
"""Wrap a response with automatic pagination if needed.
|
|
|
|
Args:
|
|
response: The response to potentially paginate
|
|
cursor: Optional cursor from request
|
|
force_pagination: Force pagination even for small responses
|
|
|
|
Returns:
|
|
Original response if small enough, otherwise paginated dict
|
|
"""
|
|
# Quick check - if response is already paginated, return as-is
|
|
if isinstance(response, dict) and (
|
|
"nextCursor" in response or "pagination_info" in response
|
|
):
|
|
return response
|
|
|
|
# For small responses, don't paginate unless forced
|
|
if not force_pagination:
|
|
try:
|
|
response_str = (
|
|
json.dumps(response) if not isinstance(response, str) else response
|
|
)
|
|
if len(response_str) < 10000: # Quick heuristic
|
|
return response
|
|
except Exception:
|
|
pass
|
|
|
|
# Create paginated response
|
|
return AutoPaginatedResponse.create_response(response, cursor)
|