hanzo-mcp 0.8.11__py3-none-any.whl → 0.9.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.

Files changed (166) hide show
  1. hanzo_mcp/__init__.py +1 -3
  2. hanzo_mcp/analytics/posthog_analytics.py +3 -9
  3. hanzo_mcp/bridge.py +9 -25
  4. hanzo_mcp/cli.py +6 -15
  5. hanzo_mcp/cli_enhanced.py +5 -14
  6. hanzo_mcp/cli_plugin.py +3 -9
  7. hanzo_mcp/config/settings.py +6 -20
  8. hanzo_mcp/config/tool_config.py +1 -3
  9. hanzo_mcp/core/base_agent.py +88 -88
  10. hanzo_mcp/core/model_registry.py +238 -210
  11. hanzo_mcp/dev_server.py +5 -15
  12. hanzo_mcp/prompts/__init__.py +2 -6
  13. hanzo_mcp/prompts/project_todo_reminder.py +3 -9
  14. hanzo_mcp/prompts/tool_explorer.py +1 -3
  15. hanzo_mcp/prompts/utils.py +7 -21
  16. hanzo_mcp/server.py +2 -6
  17. hanzo_mcp/tools/__init__.py +26 -27
  18. hanzo_mcp/tools/agent/__init__.py +2 -1
  19. hanzo_mcp/tools/agent/agent.py +10 -30
  20. hanzo_mcp/tools/agent/agent_tool.py +22 -15
  21. hanzo_mcp/tools/agent/claude_desktop_auth.py +3 -9
  22. hanzo_mcp/tools/agent/cli_agent_base.py +7 -24
  23. hanzo_mcp/tools/agent/cli_tools.py +75 -74
  24. hanzo_mcp/tools/agent/code_auth.py +1 -3
  25. hanzo_mcp/tools/agent/code_auth_tool.py +2 -6
  26. hanzo_mcp/tools/agent/critic_tool.py +8 -24
  27. hanzo_mcp/tools/agent/iching_tool.py +12 -36
  28. hanzo_mcp/tools/agent/network_tool.py +7 -18
  29. hanzo_mcp/tools/agent/prompt.py +1 -5
  30. hanzo_mcp/tools/agent/review_tool.py +10 -25
  31. hanzo_mcp/tools/agent/swarm_alias.py +1 -3
  32. hanzo_mcp/tools/agent/unified_cli_tools.py +38 -38
  33. hanzo_mcp/tools/common/batch_tool.py +15 -45
  34. hanzo_mcp/tools/common/config_tool.py +9 -28
  35. hanzo_mcp/tools/common/context.py +1 -3
  36. hanzo_mcp/tools/common/critic_tool.py +1 -3
  37. hanzo_mcp/tools/common/decorators.py +2 -6
  38. hanzo_mcp/tools/common/enhanced_base.py +2 -6
  39. hanzo_mcp/tools/common/fastmcp_pagination.py +4 -12
  40. hanzo_mcp/tools/common/forgiving_edit.py +9 -28
  41. hanzo_mcp/tools/common/mode.py +1 -5
  42. hanzo_mcp/tools/common/paginated_base.py +3 -11
  43. hanzo_mcp/tools/common/paginated_response.py +10 -30
  44. hanzo_mcp/tools/common/pagination.py +3 -9
  45. hanzo_mcp/tools/common/path_utils.py +34 -0
  46. hanzo_mcp/tools/common/permissions.py +14 -13
  47. hanzo_mcp/tools/common/personality.py +983 -701
  48. hanzo_mcp/tools/common/plugin_loader.py +3 -15
  49. hanzo_mcp/tools/common/stats.py +6 -18
  50. hanzo_mcp/tools/common/thinking_tool.py +1 -3
  51. hanzo_mcp/tools/common/tool_disable.py +2 -6
  52. hanzo_mcp/tools/common/tool_list.py +2 -6
  53. hanzo_mcp/tools/common/validation.py +1 -3
  54. hanzo_mcp/tools/compiler/__init__.py +8 -0
  55. hanzo_mcp/tools/compiler/sandboxed_compiler.py +681 -0
  56. hanzo_mcp/tools/config/config_tool.py +7 -13
  57. hanzo_mcp/tools/config/index_config.py +1 -3
  58. hanzo_mcp/tools/config/mode_tool.py +5 -15
  59. hanzo_mcp/tools/database/database_manager.py +3 -9
  60. hanzo_mcp/tools/database/graph.py +1 -3
  61. hanzo_mcp/tools/database/graph_add.py +3 -9
  62. hanzo_mcp/tools/database/graph_query.py +11 -34
  63. hanzo_mcp/tools/database/graph_remove.py +3 -9
  64. hanzo_mcp/tools/database/graph_search.py +6 -20
  65. hanzo_mcp/tools/database/graph_stats.py +11 -33
  66. hanzo_mcp/tools/database/sql.py +4 -12
  67. hanzo_mcp/tools/database/sql_query.py +6 -10
  68. hanzo_mcp/tools/database/sql_search.py +2 -6
  69. hanzo_mcp/tools/database/sql_stats.py +5 -15
  70. hanzo_mcp/tools/editor/neovim_command.py +1 -3
  71. hanzo_mcp/tools/editor/neovim_session.py +7 -13
  72. hanzo_mcp/tools/environment/__init__.py +8 -0
  73. hanzo_mcp/tools/environment/environment_detector.py +594 -0
  74. hanzo_mcp/tools/filesystem/__init__.py +28 -26
  75. hanzo_mcp/tools/filesystem/ast_multi_edit.py +14 -43
  76. hanzo_mcp/tools/filesystem/ast_tool.py +3 -0
  77. hanzo_mcp/tools/filesystem/base.py +20 -12
  78. hanzo_mcp/tools/filesystem/content_replace.py +7 -12
  79. hanzo_mcp/tools/filesystem/diff.py +2 -10
  80. hanzo_mcp/tools/filesystem/directory_tree.py +285 -51
  81. hanzo_mcp/tools/filesystem/edit.py +10 -18
  82. hanzo_mcp/tools/filesystem/find.py +312 -179
  83. hanzo_mcp/tools/filesystem/git_search.py +12 -24
  84. hanzo_mcp/tools/filesystem/multi_edit.py +10 -18
  85. hanzo_mcp/tools/filesystem/read.py +14 -30
  86. hanzo_mcp/tools/filesystem/rules_tool.py +9 -17
  87. hanzo_mcp/tools/filesystem/search.py +1160 -0
  88. hanzo_mcp/tools/filesystem/watch.py +2 -4
  89. hanzo_mcp/tools/filesystem/write.py +7 -10
  90. hanzo_mcp/tools/framework/__init__.py +8 -0
  91. hanzo_mcp/tools/framework/framework_modes.py +714 -0
  92. hanzo_mcp/tools/jupyter/base.py +6 -20
  93. hanzo_mcp/tools/jupyter/jupyter.py +4 -12
  94. hanzo_mcp/tools/llm/consensus_tool.py +8 -24
  95. hanzo_mcp/tools/llm/llm_manage.py +2 -6
  96. hanzo_mcp/tools/llm/llm_tool.py +17 -58
  97. hanzo_mcp/tools/llm/llm_unified.py +18 -59
  98. hanzo_mcp/tools/llm/provider_tools.py +1 -3
  99. hanzo_mcp/tools/lsp/lsp_tool.py +621 -481
  100. hanzo_mcp/tools/mcp/mcp_add.py +1 -3
  101. hanzo_mcp/tools/mcp/mcp_stats.py +1 -3
  102. hanzo_mcp/tools/mcp/mcp_tool.py +9 -23
  103. hanzo_mcp/tools/memory/__init__.py +10 -27
  104. hanzo_mcp/tools/memory/conversation_memory.py +636 -0
  105. hanzo_mcp/tools/memory/knowledge_tools.py +7 -25
  106. hanzo_mcp/tools/memory/memory_tools.py +6 -18
  107. hanzo_mcp/tools/search/find_tool.py +12 -34
  108. hanzo_mcp/tools/search/unified_search.py +24 -78
  109. hanzo_mcp/tools/shell/__init__.py +16 -4
  110. hanzo_mcp/tools/shell/auto_background.py +2 -6
  111. hanzo_mcp/tools/shell/base.py +1 -5
  112. hanzo_mcp/tools/shell/base_process.py +5 -7
  113. hanzo_mcp/tools/shell/bash_session.py +7 -24
  114. hanzo_mcp/tools/shell/bash_session_executor.py +5 -15
  115. hanzo_mcp/tools/shell/bash_tool.py +3 -7
  116. hanzo_mcp/tools/shell/command_executor.py +26 -79
  117. hanzo_mcp/tools/shell/logs.py +4 -16
  118. hanzo_mcp/tools/shell/npx.py +2 -8
  119. hanzo_mcp/tools/shell/npx_tool.py +1 -3
  120. hanzo_mcp/tools/shell/pkill.py +4 -12
  121. hanzo_mcp/tools/shell/process_tool.py +2 -8
  122. hanzo_mcp/tools/shell/processes.py +5 -17
  123. hanzo_mcp/tools/shell/run_background.py +1 -3
  124. hanzo_mcp/tools/shell/run_command.py +1 -3
  125. hanzo_mcp/tools/shell/run_command_windows.py +1 -3
  126. hanzo_mcp/tools/shell/run_tool.py +56 -0
  127. hanzo_mcp/tools/shell/session_manager.py +2 -6
  128. hanzo_mcp/tools/shell/session_storage.py +2 -6
  129. hanzo_mcp/tools/shell/streaming_command.py +7 -23
  130. hanzo_mcp/tools/shell/uvx.py +4 -14
  131. hanzo_mcp/tools/shell/uvx_background.py +2 -6
  132. hanzo_mcp/tools/shell/uvx_tool.py +1 -3
  133. hanzo_mcp/tools/shell/zsh_tool.py +12 -20
  134. hanzo_mcp/tools/todo/todo.py +1 -3
  135. hanzo_mcp/tools/vector/__init__.py +97 -50
  136. hanzo_mcp/tools/vector/ast_analyzer.py +6 -20
  137. hanzo_mcp/tools/vector/git_ingester.py +10 -30
  138. hanzo_mcp/tools/vector/index_tool.py +3 -9
  139. hanzo_mcp/tools/vector/infinity_store.py +7 -27
  140. hanzo_mcp/tools/vector/mock_infinity.py +1 -3
  141. hanzo_mcp/tools/vector/node_tool.py +538 -0
  142. hanzo_mcp/tools/vector/project_manager.py +4 -12
  143. hanzo_mcp/tools/vector/unified_vector.py +384 -0
  144. hanzo_mcp/tools/vector/vector.py +2 -6
  145. hanzo_mcp/tools/vector/vector_index.py +8 -8
  146. hanzo_mcp/tools/vector/vector_search.py +7 -21
  147. {hanzo_mcp-0.8.11.dist-info → hanzo_mcp-0.9.0.dist-info}/METADATA +2 -2
  148. hanzo_mcp-0.9.0.dist-info/RECORD +191 -0
  149. hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +0 -645
  150. hanzo_mcp/tools/agent/swarm_tool.py +0 -718
  151. hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +0 -577
  152. hanzo_mcp/tools/filesystem/batch_search.py +0 -900
  153. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +0 -350
  154. hanzo_mcp/tools/filesystem/find_files.py +0 -369
  155. hanzo_mcp/tools/filesystem/grep.py +0 -467
  156. hanzo_mcp/tools/filesystem/search_tool.py +0 -767
  157. hanzo_mcp/tools/filesystem/symbols_tool.py +0 -515
  158. hanzo_mcp/tools/filesystem/tree.py +0 -270
  159. hanzo_mcp/tools/jupyter/notebook_edit.py +0 -317
  160. hanzo_mcp/tools/jupyter/notebook_read.py +0 -147
  161. hanzo_mcp/tools/todo/todo_read.py +0 -143
  162. hanzo_mcp/tools/todo/todo_write.py +0 -374
  163. hanzo_mcp-0.8.11.dist-info/RECORD +0 -193
  164. {hanzo_mcp-0.8.11.dist-info → hanzo_mcp-0.9.0.dist-info}/WHEEL +0 -0
  165. {hanzo_mcp-0.8.11.dist-info → hanzo_mcp-0.9.0.dist-info}/entry_points.txt +0 -0
  166. {hanzo_mcp-0.8.11.dist-info → hanzo_mcp-0.9.0.dist-info}/top_level.txt +0 -0
@@ -87,17 +87,11 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
87
87
 
88
88
  try:
89
89
  if action == "get":
90
- return await self._get_config(
91
- scope, setting_path, project_name, project_path
92
- )
90
+ return await self._get_config(scope, setting_path, project_name, project_path)
93
91
  elif action == "set":
94
- return await self._set_config(
95
- scope, setting_path, value, project_name, project_path
96
- )
92
+ return await self._set_config(scope, setting_path, value, project_name, project_path)
97
93
  elif action == "add_server":
98
- return await self._add_mcp_server(
99
- server_name, server_config, scope, project_name
100
- )
94
+ return await self._add_mcp_server(server_name, server_config, scope, project_name)
101
95
  elif action == "remove_server":
102
96
  return await self._remove_mcp_server(server_name, scope, project_name)
103
97
  elif action == "enable_server":
@@ -158,10 +152,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
158
152
  "agent": settings.agent.__dict__,
159
153
  "vector_store": settings.vector_store.__dict__,
160
154
  "hub_enabled": settings.hub_enabled,
161
- "mcp_servers": {
162
- name: server.__dict__
163
- for name, server in settings.mcp_servers.items()
164
- },
155
+ "mcp_servers": {name: server.__dict__ for name, server in settings.mcp_servers.items()},
165
156
  "current_project": settings.current_project,
166
157
  "projects": list(settings.projects.keys()),
167
158
  }
@@ -239,9 +230,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
239
230
  else:
240
231
  return f"Error: MCP server '{server_name}' already exists"
241
232
 
242
- async def _remove_mcp_server(
243
- self, server_name: Optional[str], scope: str, project_name: Optional[str]
244
- ) -> str:
233
+ async def _remove_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
245
234
  """Remove an MCP server."""
246
235
  if not server_name:
247
236
  return "Error: server_name is required"
@@ -254,9 +243,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
254
243
  else:
255
244
  return f"Error: MCP server '{server_name}' not found"
256
245
 
257
- async def _enable_mcp_server(
258
- self, server_name: Optional[str], scope: str, project_name: Optional[str]
259
- ) -> str:
246
+ async def _enable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
260
247
  """Enable an MCP server."""
261
248
  if not server_name:
262
249
  return "Error: server_name is required"
@@ -269,9 +256,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
269
256
  else:
270
257
  return f"Error: MCP server '{server_name}' not found"
271
258
 
272
- async def _disable_mcp_server(
273
- self, server_name: Optional[str], scope: str, project_name: Optional[str]
274
- ) -> str:
259
+ async def _disable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
275
260
  """Disable an MCP server."""
276
261
  if not server_name:
277
262
  return "Error: server_name is required"
@@ -297,9 +282,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
297
282
  else:
298
283
  return f"Error: MCP server '{server_name}' not found"
299
284
 
300
- async def _add_project(
301
- self, project_name: Optional[str], project_path: Optional[str]
302
- ) -> str:
285
+ async def _add_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
303
286
  """Add a project configuration."""
304
287
  if not project_path:
305
288
  return "Error: project_path is required"
@@ -325,9 +308,7 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
325
308
  else:
326
309
  return f"Error: Project '{project_name}' already exists"
327
310
 
328
- async def _set_current_project(
329
- self, project_name: Optional[str], project_path: Optional[str]
330
- ) -> str:
311
+ async def _set_current_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
331
312
  """Set the current active project."""
332
313
  settings = load_settings()
333
314
 
@@ -67,9 +67,7 @@ class ToolContext:
67
67
  """
68
68
  return self._mcp_context.client_id
69
69
 
70
- async def set_tool_info(
71
- self, tool_name: str, execution_id: str | None = None
72
- ) -> None:
70
+ async def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
73
71
  """Set information about the currently executing tool.
74
72
 
75
73
  Args:
@@ -153,9 +153,7 @@ Recommendations:
153
153
 
154
154
  # Validate required analysis parameter
155
155
  if not analysis:
156
- await tool_ctx.error(
157
- "Parameter 'analysis' is required but was None or empty"
158
- )
156
+ await tool_ctx.error("Parameter 'analysis' is required but was None or empty")
159
157
  return "Error: Parameter 'analysis' is required but was None or empty"
160
158
 
161
159
  if analysis.strip() == "":
@@ -120,9 +120,7 @@ def _is_valid_context(ctx: Any) -> bool:
120
120
  )
121
121
 
122
122
 
123
- def mcp_tool(
124
- server: Any, name: str | None = None, description: str | None = None
125
- ) -> Callable[[F], F]:
123
+ def mcp_tool(server: Any, name: str | None = None, description: str | None = None) -> Callable[[F], F]:
126
124
  """Enhanced MCP tool decorator that includes context normalization.
127
125
 
128
126
  This decorator combines the standard MCP tool registration with
@@ -192,9 +190,7 @@ def create_tool_handler(server: Any, tool: Any) -> Callable[[], None]:
192
190
  # Apply context normalization
193
191
  normalized = with_context_normalization(func)
194
192
  # Apply original decorator
195
- return original_tool_decorator(name=name, description=description)(
196
- normalized
197
- )
193
+ return original_tool_decorator(name=name, description=description)(normalized)
198
194
 
199
195
  return decorator
200
196
 
@@ -85,9 +85,7 @@ class AutoRegisterTool(BaseTool, ABC):
85
85
  params = list(sig.parameters.items())
86
86
 
87
87
  # Skip 'self' and 'ctx' parameters
88
- tool_params = [
89
- (name, param) for name, param in params if name not in ("self", "ctx")
90
- ]
88
+ tool_params = [(name, param) for name, param in params if name not in ("self", "ctx")]
91
89
 
92
90
  # Create the handler function dynamically
93
91
  async def handler(ctx: MCPContext, **kwargs: Any) -> Any:
@@ -98,6 +96,4 @@ class AutoRegisterTool(BaseTool, ABC):
98
96
  normalized_handler = with_context_normalization(handler)
99
97
 
100
98
  # Register with the server
101
- mcp_server.tool(name=self.name, description=self.description)(
102
- normalized_handler
103
- )
99
+ mcp_server.tool(name=self.name, description=self.description)(normalized_handler)
@@ -143,9 +143,7 @@ class FastMCPPaginator(Generic[T]):
143
143
  cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
144
144
 
145
145
  # Use provided page size or default
146
- actual_page_size = min(
147
- page_size or cursor_data.page_size or self.page_size, self.max_page_size
148
- )
146
+ actual_page_size = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
149
147
 
150
148
  # Get starting position
151
149
  start_idx = cursor_data.offset
@@ -207,9 +205,7 @@ class FastMCPPaginator(Generic[T]):
207
205
  cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
208
206
 
209
207
  # Determine page size
210
- limit = min(
211
- page_size or cursor_data.page_size or self.page_size, self.max_page_size
212
- )
208
+ limit = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
213
209
 
214
210
  # Execute query with cursor position
215
211
  results = query_func(
@@ -258,9 +254,7 @@ class TokenAwarePaginator:
258
254
  self.max_tokens = max_tokens
259
255
  self.paginator = FastMCPPaginator()
260
256
 
261
- def paginate_by_tokens(
262
- self, items: List[Any], cursor: Optional[str] = None, estimate_func=None
263
- ) -> Dict[str, Any]:
257
+ def paginate_by_tokens(self, items: List[Any], cursor: Optional[str] = None, estimate_func=None) -> Dict[str, Any]:
264
258
  """Paginate items based on token count.
265
259
 
266
260
  Args:
@@ -275,9 +269,7 @@ class TokenAwarePaginator:
275
269
 
276
270
  # Default token estimation
277
271
  if not estimate_func:
278
- estimate_func = lambda x: estimate_tokens(
279
- json.dumps(x) if not isinstance(x, str) else x
280
- )
272
+ estimate_func = lambda x: estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
281
273
 
282
274
  # Parse cursor
283
275
  cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
@@ -42,9 +42,7 @@ class ForgivingEditHelper:
42
42
  return "\n".join(lines)
43
43
 
44
44
  @staticmethod
45
- def find_fuzzy_match(
46
- haystack: str, needle: str, threshold: float = 0.85
47
- ) -> Optional[Tuple[int, int, str]]:
45
+ def find_fuzzy_match(haystack: str, needle: str, threshold: float = 0.85) -> Optional[Tuple[int, int, str]]:
48
46
  """Find a fuzzy match for the needle in the haystack.
49
47
 
50
48
  Args:
@@ -81,13 +79,9 @@ class ForgivingEditHelper:
81
79
 
82
80
  # Find end position by counting lines in needle
83
81
  needle_lines = norm_needle.count("\n") + 1
84
- end_pos = sum(
85
- len(line) + 1 for line in original_lines[: lines_before + needle_lines]
86
- )
82
+ end_pos = sum(len(line) + 1 for line in original_lines[: lines_before + needle_lines])
87
83
 
88
- matched = "\n".join(
89
- original_lines[lines_before : lines_before + needle_lines]
90
- )
84
+ matched = "\n".join(original_lines[lines_before : lines_before + needle_lines])
91
85
  return (start_pos, end_pos - 1, matched)
92
86
 
93
87
  # Try fuzzy matching on lines
@@ -119,9 +113,7 @@ class ForgivingEditHelper:
119
113
  candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
120
114
  needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
121
115
 
122
- ratio = difflib.SequenceMatcher(
123
- None, candidate_norm, needle_norm
124
- ).ratio()
116
+ ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
125
117
 
126
118
  if ratio >= threshold:
127
119
  start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
@@ -130,9 +122,7 @@ class ForgivingEditHelper:
130
122
  return None
131
123
 
132
124
  @staticmethod
133
- def suggest_matches(
134
- haystack: str, needle: str, max_suggestions: int = 3
135
- ) -> List[Tuple[float, str]]:
125
+ def suggest_matches(haystack: str, needle: str, max_suggestions: int = 3) -> List[Tuple[float, str]]:
136
126
  """Suggest possible matches when exact match fails.
137
127
 
138
128
  Args:
@@ -154,9 +144,7 @@ class ForgivingEditHelper:
154
144
  for line in haystack.split("\n"):
155
145
  if line.strip(): # Skip empty lines
156
146
  line_norm = ForgivingEditHelper.normalize_whitespace(line)
157
- ratio = difflib.SequenceMatcher(
158
- None, line_norm, needle_norm
159
- ).ratio()
147
+ ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
160
148
  if ratio > 0.5: # Only reasonably similar lines
161
149
  suggestions.append((ratio, line))
162
150
 
@@ -170,9 +158,7 @@ class ForgivingEditHelper:
170
158
  candidate = "\n".join(candidate_lines)
171
159
  candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
172
160
 
173
- ratio = difflib.SequenceMatcher(
174
- None, candidate_norm, needle_norm
175
- ).ratio()
161
+ ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
176
162
  if ratio > 0.5:
177
163
  suggestions.append((ratio, candidate))
178
164
 
@@ -181,9 +167,7 @@ class ForgivingEditHelper:
181
167
  return suggestions[:max_suggestions]
182
168
 
183
169
  @staticmethod
184
- def create_edit_suggestion(
185
- file_content: str, old_string: str, new_string: str
186
- ) -> dict:
170
+ def create_edit_suggestion(file_content: str, old_string: str, new_string: str) -> dict:
187
171
  """Create a helpful edit suggestion when match fails.
188
172
 
189
173
  Args:
@@ -212,10 +196,7 @@ class ForgivingEditHelper:
212
196
  if suggestions:
213
197
  return {
214
198
  "error": "Could not find exact or fuzzy match",
215
- "suggestions": [
216
- {"similarity": f"{score:.0%}", "text": text}
217
- for score, text in suggestions
218
- ],
199
+ "suggestions": [{"similarity": f"{score:.0%}", "text": text} for score, text in suggestions],
219
200
  "hint": "Try using one of these suggestions as old_string",
220
201
  }
221
202
 
@@ -94,11 +94,7 @@ def register_default_modes():
94
94
  def get_mode_from_env() -> Optional[str]:
95
95
  """Get mode name from environment variables."""
96
96
  # Check for HANZO_MODE, PERSONALITY, or MODE env vars
97
- return (
98
- os.environ.get("HANZO_MODE")
99
- or os.environ.get("PERSONALITY")
100
- or os.environ.get("MODE")
101
- )
97
+ return os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE")
102
98
 
103
99
 
104
100
  def activate_mode_from_env():
@@ -68,11 +68,7 @@ class PaginatedBaseTool(BaseTool):
68
68
  if cursor:
69
69
  # For continuation, check if we have cached results
70
70
  cursor_data = CursorManager.parse_cursor(cursor)
71
- if (
72
- cursor_data
73
- and "tool" in cursor_data
74
- and cursor_data["tool"] != self.name
75
- ):
71
+ if cursor_data and "tool" in cursor_data and cursor_data["tool"] != self.name:
76
72
  return {"error": "Cursor is for a different tool", "code": -32602}
77
73
 
78
74
  # Execute the tool
@@ -90,15 +86,11 @@ class PaginatedBaseTool(BaseTool):
90
86
  if isinstance(paginated_result, dict) and "nextCursor" in paginated_result:
91
87
  # Enhance the cursor with tool information
92
88
  if "nextCursor" in paginated_result:
93
- cursor_data = CursorManager.parse_cursor(
94
- paginated_result["nextCursor"]
95
- )
89
+ cursor_data = CursorManager.parse_cursor(paginated_result["nextCursor"])
96
90
  if cursor_data:
97
91
  cursor_data["tool"] = self.name
98
92
  cursor_data["params"] = params # Store params for continuation
99
- paginated_result["nextCursor"] = CursorManager.create_cursor(
100
- cursor_data
101
- )
93
+ paginated_result["nextCursor"] = CursorManager.create_cursor(cursor_data)
102
94
 
103
95
  return paginated_result
104
96
  else:
@@ -35,31 +35,21 @@ class AutoPaginatedResponse:
35
35
  """
36
36
  # Handle different content types
37
37
  if isinstance(content, str):
38
- return AutoPaginatedResponse._handle_string_response(
39
- content, cursor, max_tokens
40
- )
38
+ return AutoPaginatedResponse._handle_string_response(content, cursor, max_tokens)
41
39
  elif isinstance(content, list):
42
- return AutoPaginatedResponse._handle_list_response(
43
- content, cursor, max_tokens
44
- )
40
+ return AutoPaginatedResponse._handle_list_response(content, cursor, max_tokens)
45
41
  elif isinstance(content, dict):
46
42
  # If dict already has pagination info, return as-is
47
43
  if "nextCursor" in content or "cursor" in content:
48
44
  return content
49
45
  # Otherwise treat as single item
50
- return AutoPaginatedResponse._handle_dict_response(
51
- content, cursor, max_tokens
52
- )
46
+ return AutoPaginatedResponse._handle_dict_response(content, cursor, max_tokens)
53
47
  else:
54
48
  # Convert to string for other types
55
- return AutoPaginatedResponse._handle_string_response(
56
- str(content), cursor, max_tokens
57
- )
49
+ return AutoPaginatedResponse._handle_string_response(str(content), cursor, max_tokens)
58
50
 
59
51
  @staticmethod
60
- def _handle_string_response(
61
- content: str, cursor: Optional[str], max_tokens: int
62
- ) -> Dict[str, Any]:
52
+ def _handle_string_response(content: str, cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
63
53
  """Handle pagination for string responses."""
64
54
  # Parse cursor to get offset
65
55
  offset = 0
@@ -124,9 +114,7 @@ class AutoPaginatedResponse:
124
114
  return response
125
115
 
126
116
  @staticmethod
127
- def _handle_list_response(
128
- items: List[Any], cursor: Optional[str], max_tokens: int
129
- ) -> Dict[str, Any]:
117
+ def _handle_list_response(items: List[Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
130
118
  """Handle pagination for list responses."""
131
119
  # Parse cursor to get offset
132
120
  offset = 0
@@ -164,9 +152,7 @@ class AutoPaginatedResponse:
164
152
  truncated = item[:5000] + "... [truncated]"
165
153
  result_items.append(truncated)
166
154
  else:
167
- result_items.append(
168
- {"error": "Item too large", "index": item_index}
169
- )
155
+ result_items.append({"error": "Item too large", "index": item_index})
170
156
  item_index += 1
171
157
  break
172
158
 
@@ -199,9 +185,7 @@ class AutoPaginatedResponse:
199
185
  return response
200
186
 
201
187
  @staticmethod
202
- def _handle_dict_response(
203
- content: Dict[str, Any], cursor: Optional[str], max_tokens: int
204
- ) -> Dict[str, Any]:
188
+ def _handle_dict_response(content: Dict[str, Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
205
189
  """Handle pagination for dict responses."""
206
190
  # For dicts, check if it's too large as-is
207
191
  content_str = json.dumps(content, indent=2)
@@ -280,17 +264,13 @@ def paginate_if_needed(
280
264
  Original response if small enough, otherwise paginated dict
281
265
  """
282
266
  # Quick check - if response is already paginated, return as-is
283
- if isinstance(response, dict) and (
284
- "nextCursor" in response or "pagination_info" in response
285
- ):
267
+ if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
286
268
  return response
287
269
 
288
270
  # For small responses, don't paginate unless forced
289
271
  if not force_pagination:
290
272
  try:
291
- response_str = (
292
- json.dumps(response) if not isinstance(response, str) else response
293
- )
273
+ response_str = json.dumps(response) if not isinstance(response, str) else response
294
274
  if len(response_str) < 10000: # Quick heuristic
295
275
  return response
296
276
  except Exception:
@@ -154,9 +154,7 @@ class StreamPaginator(Generic[T]):
154
154
  """
155
155
  self.page_size = page_size
156
156
 
157
- def paginate_stream(
158
- self, stream_generator, cursor: Optional[str] = None
159
- ) -> PaginatedResponse[T]:
157
+ def paginate_stream(self, stream_generator, cursor: Optional[str] = None) -> PaginatedResponse[T]:
160
158
  """Paginate results from a stream/generator.
161
159
 
162
160
  Args:
@@ -185,18 +183,14 @@ class StreamPaginator(Generic[T]):
185
183
  items.append(item)
186
184
  if len(items) >= self.page_size:
187
185
  # We have a full page, create cursor for next page
188
- next_cursor = CursorManager.create_cursor(
189
- {"skip": skip_count + len(items)}
190
- )
186
+ next_cursor = CursorManager.create_cursor({"skip": skip_count + len(items)})
191
187
  return PaginatedResponse(items=items, next_cursor=next_cursor)
192
188
 
193
189
  # No more items
194
190
  return PaginatedResponse(items=items, next_cursor=None)
195
191
 
196
192
 
197
- def paginate_list(
198
- items: List[T], cursor: Optional[str] = None, page_size: int = 100
199
- ) -> PaginatedResponse[T]:
193
+ def paginate_list(items: List[T], cursor: Optional[str] = None, page_size: int = 100) -> PaginatedResponse[T]:
200
194
  """Convenience function to paginate a list.
201
195
 
202
196
  Args:
@@ -0,0 +1,34 @@
1
+ """Path utility functions for consistent path handling across MCP tools."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def resolve_path(path: str) -> str:
8
+ """Resolve a path by expanding user home directory (~) and environment variables.
9
+
10
+ This is the centralized path resolution function used by all filesystem tools
11
+ to ensure consistent path handling across the MCP system.
12
+
13
+ Args:
14
+ path: The path to resolve, may contain ~ or environment variables
15
+
16
+ Returns:
17
+ The fully resolved absolute path
18
+
19
+ Examples:
20
+ >>> resolve_path('~/Documents/file.txt')
21
+ '/Users/username/Documents/file.txt'
22
+
23
+ >>> os.environ['MYDIR'] = '/tmp/test'
24
+ >>> resolve_path('$MYDIR/file.txt')
25
+ '/tmp/test/file.txt'
26
+ """
27
+ # First expand environment variables, then user home directory
28
+ # This order is important for cases like $HOME_ALIAS where the env var contains ~
29
+ expanded = os.path.expandvars(path)
30
+ expanded = os.path.expanduser(expanded)
31
+
32
+ # Convert to Path object and resolve to get absolute path
33
+ # This handles relative paths and resolves symlinks
34
+ return str(Path(expanded).resolve())
@@ -8,6 +8,9 @@ from typing import Any, TypeVar, final
8
8
  from pathlib import Path
9
9
  from collections.abc import Callable, Awaitable
10
10
 
11
+ # Import centralized path resolution
12
+ from hanzo_mcp.tools.common.path_utils import resolve_path
13
+
11
14
  # Define type variables for better type annotations
12
15
  T = TypeVar("T")
13
16
  P = TypeVar("P")
@@ -81,7 +84,8 @@ class PermissionManager:
81
84
  Args:
82
85
  path: The path to allow
83
86
  """
84
- resolved_path: Path = Path(path).resolve()
87
+ # Use centralized path resolution
88
+ resolved_path = Path(resolve_path(path))
85
89
  self.allowed_paths.add(resolved_path)
86
90
 
87
91
  def remove_allowed_path(self, path: str) -> None:
@@ -90,7 +94,8 @@ class PermissionManager:
90
94
  Args:
91
95
  path: The path to remove
92
96
  """
93
- resolved_path: Path = Path(path).resolve()
97
+ # Use centralized path resolution
98
+ resolved_path = Path(resolve_path(path))
94
99
  if resolved_path in self.allowed_paths:
95
100
  self.allowed_paths.remove(resolved_path)
96
101
 
@@ -100,7 +105,8 @@ class PermissionManager:
100
105
  Args:
101
106
  path: The path to exclude
102
107
  """
103
- resolved_path: Path = Path(path).resolve()
108
+ # Use centralized path resolution
109
+ resolved_path = Path(resolve_path(path))
104
110
  self.excluded_paths.add(resolved_path)
105
111
 
106
112
  def add_exclusion_pattern(self, pattern: str) -> None:
@@ -120,7 +126,8 @@ class PermissionManager:
120
126
  Returns:
121
127
  True if the path is allowed, False otherwise
122
128
  """
123
- resolved_path: Path = Path(path).resolve()
129
+ # Use centralized path resolution
130
+ resolved_path = Path(resolve_path(path))
124
131
 
125
132
  # Check exclusions first
126
133
  if self._is_path_excluded(resolved_path):
@@ -225,13 +232,9 @@ class PermissibleOperation:
225
232
  """
226
233
  self.permission_manager: PermissionManager = permission_manager
227
234
  self.operation: str = operation
228
- self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = (
229
- get_path_fn
230
- )
235
+ self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = get_path_fn
231
236
 
232
- def __call__(
233
- self, func: Callable[..., Awaitable[T]]
234
- ) -> Callable[..., Awaitable[T]]:
237
+ def __call__(self, func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
235
238
  """Decorate the function.
236
239
 
237
240
  Args:
@@ -255,9 +258,7 @@ class PermissibleOperation:
255
258
 
256
259
  # Check permission
257
260
  if not self.permission_manager.is_path_allowed(path):
258
- raise PermissionError(
259
- f"Operation '{self.operation}' not allowed for path: {path}"
260
- )
261
+ raise PermissionError(f"Operation '{self.operation}' not allowed for path: {path}")
261
262
 
262
263
  # Call the function
263
264
  return await func(*args, **kwargs)