hanzo-mcp 0.6.13__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/analytics/__init__.py +5 -0
- hanzo_mcp/analytics/posthog_analytics.py +364 -0
- hanzo_mcp/cli.py +3 -3
- hanzo_mcp/cli_enhanced.py +3 -3
- hanzo_mcp/config/settings.py +1 -1
- hanzo_mcp/config/tool_config.py +18 -4
- hanzo_mcp/server.py +34 -1
- hanzo_mcp/tools/__init__.py +65 -2
- hanzo_mcp/tools/agent/__init__.py +84 -3
- hanzo_mcp/tools/agent/agent_tool.py +102 -4
- hanzo_mcp/tools/agent/agent_tool_v2.py +459 -0
- hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
- hanzo_mcp/tools/agent/clarification_tool.py +68 -0
- hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
- hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
- hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
- hanzo_mcp/tools/agent/code_auth.py +436 -0
- hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
- hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
- hanzo_mcp/tools/agent/critic_tool.py +376 -0
- hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/iching_tool.py +380 -0
- hanzo_mcp/tools/agent/network_tool.py +273 -0
- hanzo_mcp/tools/agent/prompt.py +62 -20
- hanzo_mcp/tools/agent/review_tool.py +433 -0
- hanzo_mcp/tools/agent/swarm_tool.py +535 -0
- hanzo_mcp/tools/agent/swarm_tool_v2.py +594 -0
- hanzo_mcp/tools/common/base.py +1 -0
- hanzo_mcp/tools/common/batch_tool.py +102 -10
- hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
- hanzo_mcp/tools/common/forgiving_edit.py +243 -0
- hanzo_mcp/tools/common/paginated_base.py +230 -0
- hanzo_mcp/tools/common/paginated_response.py +307 -0
- hanzo_mcp/tools/common/pagination.py +226 -0
- hanzo_mcp/tools/common/tool_list.py +3 -0
- hanzo_mcp/tools/common/truncate.py +101 -0
- hanzo_mcp/tools/filesystem/__init__.py +29 -0
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
- hanzo_mcp/tools/lsp/__init__.py +5 -0
- hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
- hanzo_mcp/tools/memory/__init__.py +76 -0
- hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
- hanzo_mcp/tools/memory/memory_tools.py +456 -0
- hanzo_mcp/tools/search/__init__.py +6 -0
- hanzo_mcp/tools/search/find_tool.py +581 -0
- hanzo_mcp/tools/search/unified_search.py +953 -0
- hanzo_mcp/tools/shell/__init__.py +5 -0
- hanzo_mcp/tools/shell/auto_background.py +203 -0
- hanzo_mcp/tools/shell/base_process.py +53 -27
- hanzo_mcp/tools/shell/bash_tool.py +17 -33
- hanzo_mcp/tools/shell/npx_tool.py +15 -32
- hanzo_mcp/tools/shell/streaming_command.py +594 -0
- hanzo_mcp/tools/shell/uvx_tool.py +15 -32
- hanzo_mcp/types.py +23 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/METADATA +228 -71
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/RECORD +61 -24
- hanzo_mcp-0.6.13.dist-info/licenses/LICENSE +0 -21
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -13,6 +13,12 @@ from pydantic import Field
|
|
|
13
13
|
|
|
14
14
|
from hanzo_mcp.tools.common.base import BaseTool
|
|
15
15
|
from hanzo_mcp.tools.common.context import create_tool_context
|
|
16
|
+
from hanzo_mcp.tools.common.truncate import truncate_response, estimate_tokens
|
|
17
|
+
from hanzo_mcp.tools.common.fastmcp_pagination import (
|
|
18
|
+
create_paginated_response,
|
|
19
|
+
CursorData,
|
|
20
|
+
TokenAwarePaginator
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
class InvocationItem(TypedDict):
|
|
@@ -54,6 +60,14 @@ Invocations = Annotated[
|
|
|
54
60
|
),
|
|
55
61
|
]
|
|
56
62
|
|
|
63
|
+
Cursor = Annotated[
|
|
64
|
+
str | None,
|
|
65
|
+
Field(
|
|
66
|
+
description="Pagination cursor to continue from previous batch results",
|
|
67
|
+
default=None,
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
|
|
57
71
|
|
|
58
72
|
class BatchToolParams(TypedDict):
|
|
59
73
|
"""Parameters for the BatchTool.
|
|
@@ -61,10 +75,12 @@ class BatchToolParams(TypedDict):
|
|
|
61
75
|
Attributes:
|
|
62
76
|
description: A short (3-5 word) description of the batch operation
|
|
63
77
|
invocations: The list of tool invocations to execute (required -- you MUST provide at least one tool invocation)
|
|
78
|
+
cursor: Optional pagination cursor
|
|
64
79
|
"""
|
|
65
80
|
|
|
66
81
|
description: Description
|
|
67
82
|
invocations: Invocations
|
|
83
|
+
cursor: Cursor
|
|
68
84
|
|
|
69
85
|
|
|
70
86
|
@final
|
|
@@ -271,13 +287,78 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
|
|
271
287
|
{"invocation": invocation, "result": f"Error: {error_message}"}
|
|
272
288
|
)
|
|
273
289
|
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
# Extract cursor if provided
|
|
291
|
+
cursor = params.get("cursor")
|
|
292
|
+
cursor_offset = 0
|
|
293
|
+
|
|
294
|
+
# If cursor provided, we need to resume from where we left off
|
|
295
|
+
if cursor:
|
|
296
|
+
cursor_data = CursorData.from_cursor(cursor)
|
|
297
|
+
if cursor_data and cursor_data.offset < len(results):
|
|
298
|
+
# Skip already returned results
|
|
299
|
+
cursor_offset = cursor_data.offset
|
|
300
|
+
results = results[cursor_offset:]
|
|
301
|
+
|
|
302
|
+
# Format results
|
|
303
|
+
formatted_results = []
|
|
304
|
+
for i, result in enumerate(results):
|
|
305
|
+
invocation = result["invocation"]
|
|
306
|
+
tool_name = invocation.get("tool_name", "unknown")
|
|
307
|
+
formatted_results.append({
|
|
308
|
+
"tool": tool_name,
|
|
309
|
+
"result": result["result"],
|
|
310
|
+
"index": i + cursor_offset
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
# Create paginated response with token awareness
|
|
314
|
+
paginated_response = create_paginated_response(
|
|
315
|
+
formatted_results,
|
|
316
|
+
cursor=cursor,
|
|
317
|
+
use_token_limit=True
|
|
278
318
|
)
|
|
279
|
-
|
|
280
|
-
|
|
319
|
+
|
|
320
|
+
# Convert paginated response to string format for MCP
|
|
321
|
+
if isinstance(paginated_response, dict) and "items" in paginated_response:
|
|
322
|
+
# Format the items as a readable string
|
|
323
|
+
result_parts = []
|
|
324
|
+
|
|
325
|
+
# Add header
|
|
326
|
+
result_parts.append(f"=== Batch operation: {description} ===")
|
|
327
|
+
result_parts.append(f"Total invocations: {len(invocations)}")
|
|
328
|
+
result_parts.append(f"Showing results: {len(paginated_response['items'])} of {len(results)}")
|
|
329
|
+
if paginated_response.get('hasMore'):
|
|
330
|
+
result_parts.append(f"More results available - use cursor: {paginated_response.get('nextCursor')}")
|
|
331
|
+
result_parts.append("")
|
|
332
|
+
|
|
333
|
+
# Format each result
|
|
334
|
+
for item in paginated_response['items']:
|
|
335
|
+
result_parts.append(f"### Result {item['index'] + 1}: {item['tool']}")
|
|
336
|
+
result_content = item['result']
|
|
337
|
+
|
|
338
|
+
# Add the result content - use multi-line code blocks for code outputs
|
|
339
|
+
if isinstance(result_content, str) and "\n" in result_content:
|
|
340
|
+
result_parts.append(f"```\n{result_content}\n```")
|
|
341
|
+
else:
|
|
342
|
+
result_parts.append(str(result_content))
|
|
343
|
+
result_parts.append("")
|
|
344
|
+
|
|
345
|
+
# Join all parts
|
|
346
|
+
formatted_output = "\n".join(result_parts)
|
|
347
|
+
|
|
348
|
+
# If there's a next cursor, we need to preserve it in the response
|
|
349
|
+
# For now, append it as a note at the end
|
|
350
|
+
if paginated_response.get('hasMore') and paginated_response.get('nextCursor'):
|
|
351
|
+
formatted_output += f"\n\n[To continue, use cursor: {paginated_response['nextCursor']}]"
|
|
352
|
+
|
|
353
|
+
await tool_ctx.info(
|
|
354
|
+
f"Batch operation '{description}' completed with {len(paginated_response['items'])} results"
|
|
355
|
+
f"{' (more available)' if paginated_response.get('hasMore') else ''}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return formatted_output
|
|
359
|
+
else:
|
|
360
|
+
# Fallback if pagination didn't work as expected
|
|
361
|
+
return self._format_results(results)
|
|
281
362
|
|
|
282
363
|
def _format_results(self, results: list[dict[str, dict[str, Any]]]) -> str:
|
|
283
364
|
"""Format the results from multiple tool invocations.
|
|
@@ -295,11 +376,21 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
|
|
295
376
|
|
|
296
377
|
# Add the result header
|
|
297
378
|
formatted_parts.append(f"### Result {i + 1}: {tool_name}")
|
|
379
|
+
|
|
380
|
+
# Truncate individual results if they're too large
|
|
381
|
+
result_content = result["result"]
|
|
382
|
+
if len(result_content) > 50000: # If individual result > 50k chars
|
|
383
|
+
result_content = truncate_response(
|
|
384
|
+
result_content,
|
|
385
|
+
max_tokens=5000, # Limit individual results to ~5k tokens
|
|
386
|
+
truncation_message=f"\n\n[Result from {tool_name} truncated. Use the tool directly with pagination/filtering for full output.]"
|
|
387
|
+
)
|
|
388
|
+
|
|
298
389
|
# Add the result content - use multi-line code blocks for code outputs
|
|
299
|
-
if "\n" in
|
|
300
|
-
formatted_parts.append(f"```\n{
|
|
390
|
+
if "\n" in result_content:
|
|
391
|
+
formatted_parts.append(f"```\n{result_content}\n```")
|
|
301
392
|
else:
|
|
302
|
-
formatted_parts.append(
|
|
393
|
+
formatted_parts.append(result_content)
|
|
303
394
|
# Add a separator
|
|
304
395
|
formatted_parts.append("")
|
|
305
396
|
|
|
@@ -321,8 +412,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
|
|
321
412
|
async def batch(
|
|
322
413
|
description: Description,
|
|
323
414
|
invocations: Invocations,
|
|
415
|
+
cursor: Cursor,
|
|
324
416
|
ctx: MCPContext
|
|
325
417
|
) -> str:
|
|
326
418
|
return await tool_self.call(
|
|
327
|
-
ctx, description=description, invocations=invocations
|
|
419
|
+
ctx, description=description, invocations=invocations, cursor=cursor
|
|
328
420
|
)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""FastMCP-compatible pagination implementation.
|
|
2
|
+
|
|
3
|
+
This module provides pagination utilities optimized for FastMCP with minimal latency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, TypeVar, Generic, Union
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
T = TypeVar('T')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CursorData:
|
|
19
|
+
"""Cursor data structure for efficient pagination."""
|
|
20
|
+
# Primary cursor fields (indexed)
|
|
21
|
+
last_id: Optional[str] = None
|
|
22
|
+
last_timestamp: Optional[float] = None
|
|
23
|
+
offset: int = 0
|
|
24
|
+
|
|
25
|
+
# Metadata for validation and optimization
|
|
26
|
+
page_size: int = 100
|
|
27
|
+
sort_field: str = "id"
|
|
28
|
+
sort_order: str = "asc"
|
|
29
|
+
|
|
30
|
+
# Security and validation
|
|
31
|
+
created_at: float = field(default_factory=time.time)
|
|
32
|
+
expires_at: Optional[float] = None
|
|
33
|
+
checksum: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def to_cursor(self) -> str:
|
|
36
|
+
"""Convert to opaque cursor string."""
|
|
37
|
+
data = {
|
|
38
|
+
"id": self.last_id,
|
|
39
|
+
"ts": self.last_timestamp,
|
|
40
|
+
"o": self.offset,
|
|
41
|
+
"ps": self.page_size,
|
|
42
|
+
"sf": self.sort_field,
|
|
43
|
+
"so": self.sort_order,
|
|
44
|
+
"ca": self.created_at,
|
|
45
|
+
}
|
|
46
|
+
if self.expires_at:
|
|
47
|
+
data["ea"] = self.expires_at
|
|
48
|
+
|
|
49
|
+
# Add checksum for integrity
|
|
50
|
+
data_str = json.dumps(data, sort_keys=True, separators=(',', ':'))
|
|
51
|
+
data["cs"] = hashlib.md5(data_str.encode()).hexdigest()[:8]
|
|
52
|
+
|
|
53
|
+
# Encode as base64
|
|
54
|
+
final_str = json.dumps(data, separators=(',', ':'))
|
|
55
|
+
return base64.urlsafe_b64encode(final_str.encode()).decode().rstrip('=')
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_cursor(cls, cursor: str) -> Optional['CursorData']:
|
|
59
|
+
"""Parse cursor string back to CursorData."""
|
|
60
|
+
try:
|
|
61
|
+
# Add padding if needed
|
|
62
|
+
padding = 4 - (len(cursor) % 4)
|
|
63
|
+
if padding != 4:
|
|
64
|
+
cursor += '=' * padding
|
|
65
|
+
|
|
66
|
+
decoded = base64.urlsafe_b64decode(cursor.encode())
|
|
67
|
+
data = json.loads(decoded)
|
|
68
|
+
|
|
69
|
+
# Validate checksum
|
|
70
|
+
checksum = data.pop("cs", None)
|
|
71
|
+
if checksum:
|
|
72
|
+
data_str = json.dumps({k: v for k, v in data.items() if k != "cs"},
|
|
73
|
+
sort_keys=True, separators=(',', ':'))
|
|
74
|
+
expected = hashlib.md5(data_str.encode()).hexdigest()[:8]
|
|
75
|
+
if checksum != expected:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Check expiration
|
|
79
|
+
expires_at = data.get("ea")
|
|
80
|
+
if expires_at and time.time() > expires_at:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
return cls(
|
|
84
|
+
last_id=data.get("id"),
|
|
85
|
+
last_timestamp=data.get("ts"),
|
|
86
|
+
offset=data.get("o", 0),
|
|
87
|
+
page_size=data.get("ps", 100),
|
|
88
|
+
sort_field=data.get("sf", "id"),
|
|
89
|
+
sort_order=data.get("so", "asc"),
|
|
90
|
+
created_at=data.get("ca", time.time()),
|
|
91
|
+
expires_at=expires_at
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FastMCPPaginator(Generic[T]):
|
|
98
|
+
"""High-performance paginator for FastMCP responses."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
page_size: int = 100,
|
|
103
|
+
max_page_size: int = 1000,
|
|
104
|
+
cursor_ttl: int = 3600, # 1 hour
|
|
105
|
+
enable_prefetch: bool = False
|
|
106
|
+
):
|
|
107
|
+
"""Initialize the paginator.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
page_size: Default page size
|
|
111
|
+
max_page_size: Maximum allowed page size
|
|
112
|
+
cursor_ttl: Cursor time-to-live in seconds
|
|
113
|
+
enable_prefetch: Enable prefetching for next page
|
|
114
|
+
"""
|
|
115
|
+
self.page_size = page_size
|
|
116
|
+
self.max_page_size = max_page_size
|
|
117
|
+
self.cursor_ttl = cursor_ttl
|
|
118
|
+
self.enable_prefetch = enable_prefetch
|
|
119
|
+
self._cache: Dict[str, Any] = {}
|
|
120
|
+
|
|
121
|
+
def paginate_list(
|
|
122
|
+
self,
|
|
123
|
+
items: List[T],
|
|
124
|
+
cursor: Optional[str] = None,
|
|
125
|
+
page_size: Optional[int] = None,
|
|
126
|
+
sort_key: Optional[str] = None
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""Paginate a list with optimal performance.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
items: List to paginate
|
|
132
|
+
cursor: Optional cursor from previous request
|
|
133
|
+
page_size: Override default page size
|
|
134
|
+
sort_key: Sort field for consistent ordering
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with items and optional nextCursor
|
|
138
|
+
"""
|
|
139
|
+
# Parse cursor or create new
|
|
140
|
+
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
141
|
+
|
|
142
|
+
# Use provided page size or default
|
|
143
|
+
actual_page_size = min(
|
|
144
|
+
page_size or cursor_data.page_size or self.page_size,
|
|
145
|
+
self.max_page_size
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Get starting position
|
|
149
|
+
start_idx = cursor_data.offset
|
|
150
|
+
|
|
151
|
+
# Validate bounds
|
|
152
|
+
if start_idx >= len(items):
|
|
153
|
+
return {"items": [], "hasMore": False}
|
|
154
|
+
|
|
155
|
+
# Slice the page
|
|
156
|
+
end_idx = min(start_idx + actual_page_size, len(items))
|
|
157
|
+
page_items = items[start_idx:end_idx]
|
|
158
|
+
|
|
159
|
+
# Build response
|
|
160
|
+
response = {
|
|
161
|
+
"items": page_items,
|
|
162
|
+
"pageInfo": {
|
|
163
|
+
"startIndex": start_idx,
|
|
164
|
+
"endIndex": end_idx,
|
|
165
|
+
"pageSize": len(page_items),
|
|
166
|
+
"totalItems": len(items)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Create next cursor if more items exist
|
|
171
|
+
if end_idx < len(items):
|
|
172
|
+
next_cursor_data = CursorData(
|
|
173
|
+
offset=end_idx,
|
|
174
|
+
page_size=actual_page_size,
|
|
175
|
+
expires_at=time.time() + self.cursor_ttl if self.cursor_ttl else None
|
|
176
|
+
)
|
|
177
|
+
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
178
|
+
response["hasMore"] = True
|
|
179
|
+
else:
|
|
180
|
+
response["hasMore"] = False
|
|
181
|
+
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
def paginate_query(
|
|
185
|
+
self,
|
|
186
|
+
query_func,
|
|
187
|
+
cursor: Optional[str] = None,
|
|
188
|
+
page_size: Optional[int] = None,
|
|
189
|
+
**query_params
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""Paginate results from a query function.
|
|
192
|
+
|
|
193
|
+
This is optimized for database queries using indexed fields.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
query_func: Function that accepts (last_id, last_timestamp, limit, **params)
|
|
197
|
+
cursor: Optional cursor
|
|
198
|
+
page_size: Override page size
|
|
199
|
+
**query_params: Additional query parameters
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Paginated response
|
|
203
|
+
"""
|
|
204
|
+
# Parse cursor
|
|
205
|
+
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
206
|
+
|
|
207
|
+
# Determine page size
|
|
208
|
+
limit = min(
|
|
209
|
+
page_size or cursor_data.page_size or self.page_size,
|
|
210
|
+
self.max_page_size
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Execute query with cursor position
|
|
214
|
+
results = query_func(
|
|
215
|
+
last_id=cursor_data.last_id,
|
|
216
|
+
last_timestamp=cursor_data.last_timestamp,
|
|
217
|
+
limit=limit + 1, # Fetch one extra to detect more
|
|
218
|
+
**query_params
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Check if there are more results
|
|
222
|
+
has_more = len(results) > limit
|
|
223
|
+
if has_more:
|
|
224
|
+
results = results[:limit] # Remove the extra item
|
|
225
|
+
|
|
226
|
+
# Build response
|
|
227
|
+
response = {
|
|
228
|
+
"items": results,
|
|
229
|
+
"pageInfo": {
|
|
230
|
+
"pageSize": len(results),
|
|
231
|
+
"hasMore": has_more
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Create next cursor if needed
|
|
236
|
+
if has_more and results:
|
|
237
|
+
last_item = results[-1]
|
|
238
|
+
next_cursor_data = CursorData(
|
|
239
|
+
last_id=getattr(last_item, 'id', None),
|
|
240
|
+
last_timestamp=getattr(last_item, 'timestamp', None),
|
|
241
|
+
page_size=limit,
|
|
242
|
+
sort_field=cursor_data.sort_field,
|
|
243
|
+
sort_order=cursor_data.sort_order,
|
|
244
|
+
expires_at=time.time() + self.cursor_ttl if self.cursor_ttl else None
|
|
245
|
+
)
|
|
246
|
+
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
247
|
+
|
|
248
|
+
return response
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TokenAwarePaginator:
|
|
252
|
+
"""Paginator that respects token limits for LLM responses."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, max_tokens: int = 20000):
|
|
255
|
+
"""Initialize token-aware paginator.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
max_tokens: Maximum tokens per response
|
|
259
|
+
"""
|
|
260
|
+
self.max_tokens = max_tokens
|
|
261
|
+
self.paginator = FastMCPPaginator()
|
|
262
|
+
|
|
263
|
+
def paginate_by_tokens(
|
|
264
|
+
self,
|
|
265
|
+
items: List[Any],
|
|
266
|
+
cursor: Optional[str] = None,
|
|
267
|
+
estimate_func=None
|
|
268
|
+
) -> Dict[str, Any]:
|
|
269
|
+
"""Paginate items based on token count.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
items: Items to paginate
|
|
273
|
+
cursor: Optional cursor
|
|
274
|
+
estimate_func: Function to estimate tokens for an item
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Paginated response
|
|
278
|
+
"""
|
|
279
|
+
from hanzo_mcp.tools.common.truncate import estimate_tokens
|
|
280
|
+
|
|
281
|
+
# Default token estimation
|
|
282
|
+
if not estimate_func:
|
|
283
|
+
estimate_func = lambda x: estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
|
|
284
|
+
|
|
285
|
+
# Parse cursor
|
|
286
|
+
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
287
|
+
start_idx = cursor_data.offset
|
|
288
|
+
|
|
289
|
+
# Build page respecting token limit
|
|
290
|
+
page_items = []
|
|
291
|
+
current_tokens = 100 # Base overhead
|
|
292
|
+
current_idx = start_idx
|
|
293
|
+
|
|
294
|
+
while current_idx < len(items) and current_tokens < self.max_tokens:
|
|
295
|
+
item = items[current_idx]
|
|
296
|
+
item_tokens = estimate_func(item)
|
|
297
|
+
|
|
298
|
+
# Check if adding this item would exceed limit
|
|
299
|
+
if current_tokens + item_tokens > self.max_tokens and page_items:
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
page_items.append(item)
|
|
303
|
+
current_tokens += item_tokens
|
|
304
|
+
current_idx += 1
|
|
305
|
+
|
|
306
|
+
# Build response
|
|
307
|
+
response = {
|
|
308
|
+
"items": page_items,
|
|
309
|
+
"pageInfo": {
|
|
310
|
+
"itemCount": len(page_items),
|
|
311
|
+
"estimatedTokens": current_tokens,
|
|
312
|
+
"hasMore": current_idx < len(items)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Add next cursor if needed
|
|
317
|
+
if current_idx < len(items):
|
|
318
|
+
next_cursor_data = CursorData(offset=current_idx)
|
|
319
|
+
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
320
|
+
|
|
321
|
+
return response
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# FastMCP integration helpers
|
|
325
|
+
def create_paginated_response(
|
|
326
|
+
items: Union[List[Any], Dict[str, Any], str],
|
|
327
|
+
cursor: Optional[str] = None,
|
|
328
|
+
page_size: int = 100,
|
|
329
|
+
use_token_limit: bool = True
|
|
330
|
+
) -> Dict[str, Any]:
|
|
331
|
+
"""Create a paginated response compatible with FastMCP.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
items: The items to paginate
|
|
335
|
+
cursor: Optional cursor from request
|
|
336
|
+
page_size: Items per page
|
|
337
|
+
use_token_limit: Whether to use token-based pagination
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
FastMCP-compatible paginated response
|
|
341
|
+
"""
|
|
342
|
+
if use_token_limit:
|
|
343
|
+
paginator = TokenAwarePaginator()
|
|
344
|
+
|
|
345
|
+
# Convert different types to list
|
|
346
|
+
if isinstance(items, str):
|
|
347
|
+
# Split string by lines for pagination
|
|
348
|
+
items = items.split('\n')
|
|
349
|
+
elif isinstance(items, dict):
|
|
350
|
+
# Convert dict to list of key-value pairs
|
|
351
|
+
items = [{"key": k, "value": v} for k, v in items.items()]
|
|
352
|
+
|
|
353
|
+
return paginator.paginate_by_tokens(items, cursor)
|
|
354
|
+
else:
|
|
355
|
+
paginator = FastMCPPaginator(page_size=page_size)
|
|
356
|
+
|
|
357
|
+
# Handle different input types
|
|
358
|
+
if isinstance(items, list):
|
|
359
|
+
return paginator.paginate_list(items, cursor, page_size)
|
|
360
|
+
else:
|
|
361
|
+
# Convert to list first
|
|
362
|
+
if isinstance(items, str):
|
|
363
|
+
items = items.split('\n')
|
|
364
|
+
elif isinstance(items, dict):
|
|
365
|
+
items = [{"key": k, "value": v} for k, v in items.items()]
|
|
366
|
+
else:
|
|
367
|
+
items = [items]
|
|
368
|
+
|
|
369
|
+
return paginator.paginate_list(items, cursor, page_size)
|