55execute queries, manage cards, and work with collections.
66"""
77
8+ import json
89from collections .abc import AsyncIterator
910from contextlib import asynccontextmanager
1011from 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
90103class 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 ()
463609async def execute_query (
464610 ctx : Context ,
0 commit comments