hanzo-mcp 0.6.13__py3-none-any.whl → 0.7.1__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.

Files changed (62) hide show
  1. hanzo_mcp/analytics/__init__.py +5 -0
  2. hanzo_mcp/analytics/posthog_analytics.py +364 -0
  3. hanzo_mcp/cli.py +3 -3
  4. hanzo_mcp/cli_enhanced.py +3 -3
  5. hanzo_mcp/config/settings.py +1 -1
  6. hanzo_mcp/config/tool_config.py +18 -4
  7. hanzo_mcp/server.py +34 -1
  8. hanzo_mcp/tools/__init__.py +65 -2
  9. hanzo_mcp/tools/agent/__init__.py +84 -3
  10. hanzo_mcp/tools/agent/agent_tool.py +102 -4
  11. hanzo_mcp/tools/agent/agent_tool_v2.py +492 -0
  12. hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
  13. hanzo_mcp/tools/agent/clarification_tool.py +68 -0
  14. hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
  15. hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
  16. hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
  17. hanzo_mcp/tools/agent/code_auth.py +436 -0
  18. hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
  19. hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
  20. hanzo_mcp/tools/agent/critic_tool.py +376 -0
  21. hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
  22. hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
  23. hanzo_mcp/tools/agent/iching_tool.py +380 -0
  24. hanzo_mcp/tools/agent/network_tool.py +273 -0
  25. hanzo_mcp/tools/agent/prompt.py +62 -20
  26. hanzo_mcp/tools/agent/review_tool.py +433 -0
  27. hanzo_mcp/tools/agent/swarm_tool.py +535 -0
  28. hanzo_mcp/tools/agent/swarm_tool_v2.py +654 -0
  29. hanzo_mcp/tools/common/base.py +1 -0
  30. hanzo_mcp/tools/common/batch_tool.py +102 -10
  31. hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
  32. hanzo_mcp/tools/common/forgiving_edit.py +243 -0
  33. hanzo_mcp/tools/common/paginated_base.py +230 -0
  34. hanzo_mcp/tools/common/paginated_response.py +307 -0
  35. hanzo_mcp/tools/common/pagination.py +226 -0
  36. hanzo_mcp/tools/common/tool_list.py +3 -0
  37. hanzo_mcp/tools/common/truncate.py +101 -0
  38. hanzo_mcp/tools/filesystem/__init__.py +29 -0
  39. hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
  40. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
  41. hanzo_mcp/tools/lsp/__init__.py +5 -0
  42. hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
  43. hanzo_mcp/tools/memory/__init__.py +76 -0
  44. hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
  45. hanzo_mcp/tools/memory/memory_tools.py +456 -0
  46. hanzo_mcp/tools/search/__init__.py +6 -0
  47. hanzo_mcp/tools/search/find_tool.py +581 -0
  48. hanzo_mcp/tools/search/unified_search.py +953 -0
  49. hanzo_mcp/tools/shell/__init__.py +5 -0
  50. hanzo_mcp/tools/shell/auto_background.py +203 -0
  51. hanzo_mcp/tools/shell/base_process.py +53 -27
  52. hanzo_mcp/tools/shell/bash_tool.py +17 -33
  53. hanzo_mcp/tools/shell/npx_tool.py +15 -32
  54. hanzo_mcp/tools/shell/streaming_command.py +594 -0
  55. hanzo_mcp/tools/shell/uvx_tool.py +15 -32
  56. hanzo_mcp/types.py +23 -0
  57. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/METADATA +229 -71
  58. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/RECORD +61 -24
  59. hanzo_mcp-0.6.13.dist-info/licenses/LICENSE +0 -21
  60. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/WHEEL +0 -0
  61. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/entry_points.txt +0 -0
  62. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.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
- # Format the results
275
- formatted_results = self._format_results(results)
276
- await tool_ctx.info(
277
- f"Batch operation '{description}' completed with {len(results)} results"
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
- return formatted_results
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 result["result"]:
300
- formatted_parts.append(f"```\n{result['result']}\n```")
390
+ if "\n" in result_content:
391
+ formatted_parts.append(f"```\n{result_content}\n```")
301
392
  else:
302
- formatted_parts.append(result["result"])
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)