Skip to content

Commit cae05d5

Browse files
committed
feat: add pagination support for large card execution results
- Add _estimate_tokens helper function for rough token counting - Enhance execute_card to detect large results and suggest pagination - Add execute_card_paginated tool with server-side and client-side pagination fallback - Implement token-based result trimming to stay within context limits - Support configurable page sizes and token limits
1 parent 98cfb3c commit cae05d5

File tree

1 file changed

+148
-2
lines changed

1 file changed

+148
-2
lines changed

src/server.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
execute queries, manage cards, and work with collections.
66
"""
77

8+
import json
89
from collections.abc import AsyncIterator
910
from contextlib import asynccontextmanager
1011
from dataclasses import dataclass
@@ -86,6 +87,18 @@ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
8687
raise Exception(error_message)
8788

8889

90+
def _estimate_tokens(obj: Any) -> int:
91+
"""
92+
Very rough token estimate: ~4 chars per token.
93+
(Good enough to avoid giant pages; tune as needed.)
94+
"""
95+
try:
96+
s = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
97+
except Exception:
98+
s = str(obj)
99+
return max(1, len(s) // 4)
100+
101+
89102
@dataclass
90103
class AppContext:
91104
"""Type-safe application context for FastMCP lifespan."""
@@ -442,8 +455,10 @@ async def list_cards_by_collection(ctx: Context, collection_id: int) -> Any:
442455

443456

444457
@mcp.tool()
445-
async def execute_card(ctx: Context, card_id: int, parameters: dict[str, Any] | None = None) -> Any:
446-
"""Execute a Metabase question/card and get results"""
458+
async def execute_card(
459+
ctx: Context, card_id: int, parameters: dict[str, Any] | None = None
460+
) -> dict[str, Any]:
461+
"""Execute a Metabase question/card and get results. For large results, use execute_card_paginated instead."""
447462
try:
448463
# Access type-safe lifespan context
449464
metabase_client = ctx.request_context.lifespan_context.metabase_client
@@ -453,12 +468,143 @@ async def execute_card(ctx: Context, card_id: int, parameters: dict[str, Any] |
453468
payload["parameters"] = parameters
454469

455470
result = await metabase_client.request("POST", f"/card/{card_id}/query", json=payload)
471+
472+
# Check if result is too large (>20k tokens)
473+
if _estimate_tokens(result) > 20_000:
474+
return {
475+
"error": "Result too large for single response",
476+
"message": "This card returns a large dataset that exceeds the 20,000 token limit. Please use the 'execute_card_paginated' tool instead to get results in manageable chunks. For futher runs extract the sql from paginated results and use it directly",
477+
"estimated_tokens": _estimate_tokens(result),
478+
"suggested_action": f"Use execute_card_paginated({card_id}) to get paginated results",
479+
"card_id": card_id,
480+
}
481+
456482
return result
457483
except Exception as e:
458484
logger.error(f"Error executing card {card_id}: {e}")
459485
raise
460486

461487

488+
@mcp.tool()
489+
async def execute_card_paginated(
490+
ctx: Context,
491+
card_id: int,
492+
parameters: dict[str, Any] | None = None,
493+
page: int = 0,
494+
*,
495+
server_page_size_rows: int = 5_000,
496+
client_page_size_rows: int = 2_000,
497+
max_tokens_per_page: int = 20_000,
498+
) -> dict[str, Any]:
499+
"""
500+
Execute a Metabase question/card and return a specific page of results.
501+
502+
Args:
503+
card_id: The ID of the card to execute
504+
parameters: Parameters to pass to the card
505+
page: Page number to retrieve (0-based)
506+
server_page_size_rows: Rows per page for server-side pagination
507+
client_page_size_rows: Rows per page for client-side chunking
508+
max_tokens_per_page: Maximum tokens per page to avoid context limits
509+
510+
Returns:
511+
A dictionary containing the page data and pagination info
512+
"""
513+
try:
514+
# Access type-safe lifespan context
515+
metabase_client = ctx.request_context.lifespan_context.metabase_client
516+
517+
base_params = dict(parameters or {})
518+
519+
# Try server-side pagination first
520+
payload = {"parameters": dict(base_params)}
521+
payload["parameters"]["limit"] = server_page_size_rows
522+
payload["parameters"]["offset"] = page * server_page_size_rows
523+
524+
try:
525+
result = await metabase_client.request("POST", f"/card/{card_id}/query", json=payload)
526+
data = result.get("data", {})
527+
rows = data.get("rows", []) or data.get("results", [])
528+
529+
# If server-side pagination worked (got reasonable number of rows)
530+
if len(rows) <= server_page_size_rows:
531+
page_result = dict(result)
532+
if rows and _estimate_tokens(page_result) > max_tokens_per_page:
533+
# Trim rows to fit token budget
534+
trimmed_rows = []
535+
for row in rows:
536+
test_page = dict(result)
537+
test_page["data"] = dict(data, rows=trimmed_rows + [row])
538+
if _estimate_tokens(test_page) > max_tokens_per_page:
539+
break
540+
trimmed_rows.append(row)
541+
page_result["data"] = dict(data, rows=trimmed_rows)
542+
543+
has_more = len(rows) == server_page_size_rows
544+
page_result["pagination"] = {
545+
"page": page,
546+
"page_size": len(rows),
547+
"has_more": has_more,
548+
"pagination_type": "server_side",
549+
}
550+
return page_result
551+
552+
except Exception:
553+
# Server-side pagination failed, fall back to client-side
554+
pass
555+
556+
# Fall back to client-side pagination
557+
payload = {}
558+
if parameters:
559+
payload["parameters"] = parameters
560+
561+
full_result = await metabase_client.request("POST", f"/card/{card_id}/query", json=payload)
562+
data = full_result.get("data", {})
563+
all_rows = data.get("rows", []) or data.get("results", [])
564+
565+
# Calculate pagination bounds
566+
start_idx = page * client_page_size_rows
567+
end_idx = start_idx + client_page_size_rows
568+
page_rows = all_rows[start_idx:end_idx]
569+
570+
# Create page result
571+
page_result = dict(full_result)
572+
page_data = dict(data)
573+
page_data["rows"] = page_rows
574+
if "cols" in data:
575+
page_data["cols"] = data["cols"]
576+
page_result["data"] = page_data
577+
578+
# Trim by token budget if needed
579+
if page_rows and _estimate_tokens(page_result) > max_tokens_per_page:
580+
trimmed_rows = []
581+
for row in page_rows:
582+
test_page = dict(page_result)
583+
test_page["data"] = dict(page_data, rows=trimmed_rows + [row])
584+
if _estimate_tokens(test_page) > max_tokens_per_page:
585+
break
586+
trimmed_rows.append(row)
587+
page_result["data"] = dict(page_data, rows=trimmed_rows)
588+
589+
# Add pagination metadata
590+
total_rows = len(all_rows)
591+
total_pages = (total_rows + client_page_size_rows - 1) // client_page_size_rows
592+
page_result["pagination"] = {
593+
"page": page,
594+
"page_size": len(page_rows),
595+
"total_rows": total_rows,
596+
"total_pages": total_pages,
597+
"has_more": page < total_pages - 1,
598+
"pagination_type": "client_side",
599+
}
600+
601+
return page_result
602+
603+
except Exception as e:
604+
logger.error(f"Error executing card {card_id}: {e}")
605+
raise
606+
607+
462608
@mcp.tool()
463609
async def execute_query(
464610
ctx: Context,

0 commit comments

Comments
 (0)