hanzo-mcp 0.5.2__py3-none-any.whl → 0.6.2__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 (114) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +32 -0
  3. hanzo_mcp/dev_server.py +246 -0
  4. hanzo_mcp/prompts/__init__.py +1 -1
  5. hanzo_mcp/prompts/project_system.py +43 -7
  6. hanzo_mcp/server.py +5 -1
  7. hanzo_mcp/tools/__init__.py +66 -35
  8. hanzo_mcp/tools/agent/__init__.py +1 -1
  9. hanzo_mcp/tools/agent/agent.py +401 -0
  10. hanzo_mcp/tools/agent/agent_tool.py +3 -4
  11. hanzo_mcp/tools/common/__init__.py +1 -1
  12. hanzo_mcp/tools/common/base.py +2 -2
  13. hanzo_mcp/tools/common/batch_tool.py +3 -5
  14. hanzo_mcp/tools/common/config_tool.py +1 -1
  15. hanzo_mcp/tools/common/context.py +1 -1
  16. hanzo_mcp/tools/common/palette.py +344 -0
  17. hanzo_mcp/tools/common/palette_loader.py +108 -0
  18. hanzo_mcp/tools/common/stats.py +1 -1
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +1 -1
  21. hanzo_mcp/tools/common/tool_enable.py +1 -1
  22. hanzo_mcp/tools/common/tool_list.py +49 -52
  23. hanzo_mcp/tools/config/__init__.py +10 -0
  24. hanzo_mcp/tools/config/config_tool.py +212 -0
  25. hanzo_mcp/tools/config/index_config.py +176 -0
  26. hanzo_mcp/tools/config/palette_tool.py +166 -0
  27. hanzo_mcp/tools/database/__init__.py +1 -1
  28. hanzo_mcp/tools/database/graph.py +482 -0
  29. hanzo_mcp/tools/database/graph_add.py +1 -1
  30. hanzo_mcp/tools/database/graph_query.py +1 -1
  31. hanzo_mcp/tools/database/graph_remove.py +1 -1
  32. hanzo_mcp/tools/database/graph_search.py +1 -1
  33. hanzo_mcp/tools/database/graph_stats.py +1 -1
  34. hanzo_mcp/tools/database/sql.py +411 -0
  35. hanzo_mcp/tools/database/sql_query.py +1 -1
  36. hanzo_mcp/tools/database/sql_search.py +1 -1
  37. hanzo_mcp/tools/database/sql_stats.py +1 -1
  38. hanzo_mcp/tools/editor/neovim_command.py +1 -1
  39. hanzo_mcp/tools/editor/neovim_edit.py +1 -1
  40. hanzo_mcp/tools/editor/neovim_session.py +1 -1
  41. hanzo_mcp/tools/filesystem/__init__.py +42 -13
  42. hanzo_mcp/tools/filesystem/base.py +1 -1
  43. hanzo_mcp/tools/filesystem/batch_search.py +4 -4
  44. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  45. hanzo_mcp/tools/filesystem/diff.py +193 -0
  46. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  47. hanzo_mcp/tools/filesystem/edit.py +3 -5
  48. hanzo_mcp/tools/filesystem/find.py +443 -0
  49. hanzo_mcp/tools/filesystem/find_files.py +1 -1
  50. hanzo_mcp/tools/filesystem/git_search.py +1 -1
  51. hanzo_mcp/tools/filesystem/grep.py +2 -2
  52. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  53. hanzo_mcp/tools/filesystem/read.py +17 -5
  54. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  55. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  56. hanzo_mcp/tools/filesystem/tree.py +268 -0
  57. hanzo_mcp/tools/filesystem/unified_search.py +711 -0
  58. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  59. hanzo_mcp/tools/filesystem/watch.py +174 -0
  60. hanzo_mcp/tools/filesystem/write.py +3 -5
  61. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  62. hanzo_mcp/tools/jupyter/base.py +1 -1
  63. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  64. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  65. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  66. hanzo_mcp/tools/llm/__init__.py +4 -0
  67. hanzo_mcp/tools/llm/consensus_tool.py +1 -1
  68. hanzo_mcp/tools/llm/llm_manage.py +1 -1
  69. hanzo_mcp/tools/llm/llm_tool.py +1 -1
  70. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  71. hanzo_mcp/tools/llm/provider_tools.py +1 -1
  72. hanzo_mcp/tools/mcp/__init__.py +4 -0
  73. hanzo_mcp/tools/mcp/mcp_add.py +1 -1
  74. hanzo_mcp/tools/mcp/mcp_remove.py +1 -1
  75. hanzo_mcp/tools/mcp/mcp_stats.py +1 -1
  76. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  77. hanzo_mcp/tools/shell/__init__.py +20 -42
  78. hanzo_mcp/tools/shell/base.py +1 -1
  79. hanzo_mcp/tools/shell/base_process.py +303 -0
  80. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  81. hanzo_mcp/tools/shell/logs.py +1 -1
  82. hanzo_mcp/tools/shell/npx.py +1 -1
  83. hanzo_mcp/tools/shell/npx_background.py +1 -1
  84. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  85. hanzo_mcp/tools/shell/open.py +107 -0
  86. hanzo_mcp/tools/shell/pkill.py +1 -1
  87. hanzo_mcp/tools/shell/process_unified.py +131 -0
  88. hanzo_mcp/tools/shell/processes.py +1 -1
  89. hanzo_mcp/tools/shell/run_background.py +1 -1
  90. hanzo_mcp/tools/shell/run_command.py +3 -4
  91. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  92. hanzo_mcp/tools/shell/uvx.py +1 -1
  93. hanzo_mcp/tools/shell/uvx_background.py +1 -1
  94. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  95. hanzo_mcp/tools/todo/__init__.py +1 -1
  96. hanzo_mcp/tools/todo/base.py +1 -1
  97. hanzo_mcp/tools/todo/todo.py +265 -0
  98. hanzo_mcp/tools/todo/todo_read.py +3 -5
  99. hanzo_mcp/tools/todo/todo_write.py +3 -5
  100. hanzo_mcp/tools/vector/__init__.py +1 -1
  101. hanzo_mcp/tools/vector/index_tool.py +1 -1
  102. hanzo_mcp/tools/vector/project_manager.py +27 -5
  103. hanzo_mcp/tools/vector/vector.py +311 -0
  104. hanzo_mcp/tools/vector/vector_index.py +1 -1
  105. hanzo_mcp/tools/vector/vector_search.py +1 -1
  106. hanzo_mcp-0.6.2.dist-info/METADATA +336 -0
  107. hanzo_mcp-0.6.2.dist-info/RECORD +134 -0
  108. hanzo_mcp-0.6.2.dist-info/entry_points.txt +3 -0
  109. hanzo_mcp-0.5.2.dist-info/METADATA +0 -276
  110. hanzo_mcp-0.5.2.dist-info/RECORD +0 -106
  111. hanzo_mcp-0.5.2.dist-info/entry_points.txt +0 -2
  112. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.2.dist-info}/WHEEL +0 -0
  113. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.2.dist-info}/licenses/LICENSE +0 -0
  114. {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,376 @@
1
+ """Unified symbols tool implementation.
2
+
3
+ This module provides the SymbolsTool for searching, indexing, and querying code symbols
4
+ using tree-sitter AST parsing. It can find function definitions, class declarations,
5
+ and other code structures with full context.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Annotated, TypedDict, Unpack, final, override, Optional, List, Dict, Any
11
+ import json
12
+
13
+ from mcp.server.fastmcp import Context as MCPContext
14
+ from grep_ast.grep_ast import TreeContext
15
+ from pydantic import Field
16
+
17
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
18
+
19
+
20
+ # Parameter types
21
+ Action = Annotated[
22
+ str,
23
+ Field(
24
+ description="Action: search (default), index, query, list",
25
+ default="search",
26
+ ),
27
+ ]
28
+
29
+ Pattern = Annotated[
30
+ Optional[str],
31
+ Field(
32
+ description="Pattern to search for in code",
33
+ default=None,
34
+ ),
35
+ ]
36
+
37
+ SearchPath = Annotated[
38
+ str,
39
+ Field(
40
+ description="Path to search/index (file or directory)",
41
+ default=".",
42
+ ),
43
+ ]
44
+
45
+ SymbolType = Annotated[
46
+ Optional[str],
47
+ Field(
48
+ description="Symbol type: function, class, method, variable",
49
+ default=None,
50
+ ),
51
+ ]
52
+
53
+ IgnoreCase = Annotated[
54
+ bool,
55
+ Field(
56
+ description="Ignore case when matching",
57
+ default=False,
58
+ ),
59
+ ]
60
+
61
+ ShowContext = Annotated[
62
+ bool,
63
+ Field(
64
+ description="Show AST context around matches",
65
+ default=True,
66
+ ),
67
+ ]
68
+
69
+ Limit = Annotated[
70
+ int,
71
+ Field(
72
+ description="Maximum results to return",
73
+ default=50,
74
+ ),
75
+ ]
76
+
77
+
78
+ class SymbolsParams(TypedDict, total=False):
79
+ """Parameters for symbols tool."""
80
+ action: str
81
+ pattern: Optional[str]
82
+ path: str
83
+ symbol_type: Optional[str]
84
+ ignore_case: bool
85
+ show_context: bool
86
+ limit: int
87
+
88
+
89
+ @final
90
+ class SymbolsTool(FilesystemBaseTool):
91
+ """Unified tool for code symbol operations using tree-sitter."""
92
+
93
+ def __init__(self, permission_manager):
94
+ """Initialize the symbols tool."""
95
+ super().__init__(permission_manager)
96
+ self._symbol_cache = {} # Cache for indexed symbols
97
+
98
+ @property
99
+ @override
100
+ def name(self) -> str:
101
+ """Get the tool name."""
102
+ return "symbols"
103
+
104
+ @property
105
+ @override
106
+ def description(self) -> str:
107
+ """Get the tool description."""
108
+ return """Code symbols with tree-sitter. Actions: search (default), index, query, list.
109
+
110
+ Usage:
111
+ symbols "function_name"
112
+ symbols --action query --symbol-type function --path ./src
113
+ symbols --action index --path ./project
114
+ symbols --action list --path ./src --symbol-type class"""
115
+
116
+ @override
117
+ async def call(
118
+ self,
119
+ ctx: MCPContext,
120
+ **params: Unpack[SymbolsParams],
121
+ ) -> str:
122
+ """Execute symbols operation."""
123
+ tool_ctx = self.create_tool_context(ctx)
124
+ self.set_tool_context_info(tool_ctx)
125
+
126
+ # Extract action
127
+ action = params.get("action", "search")
128
+
129
+ # Route to appropriate handler
130
+ if action == "search":
131
+ return await self._handle_search(params, tool_ctx)
132
+ elif action == "index":
133
+ return await self._handle_index(params, tool_ctx)
134
+ elif action == "query":
135
+ return await self._handle_query(params, tool_ctx)
136
+ elif action == "list":
137
+ return await self._handle_list(params, tool_ctx)
138
+ else:
139
+ return f"Error: Unknown action '{action}'. Valid actions: search, index, query, list"
140
+
141
+ async def _handle_search(self, params: Dict[str, Any], tool_ctx) -> str:
142
+ """Search for pattern in code with AST context."""
143
+ pattern = params.get("pattern")
144
+ if not pattern:
145
+ return "Error: pattern required for search action"
146
+
147
+ path = params.get("path", ".")
148
+ ignore_case = params.get("ignore_case", False)
149
+ show_context = params.get("show_context", True)
150
+ limit = params.get("limit", 50)
151
+
152
+ # Validate path
153
+ path_validation = self.validate_path(path)
154
+ if not path_validation.is_valid:
155
+ await tool_ctx.error(f"Invalid path: {path_validation.error_message}")
156
+ return f"Error: Invalid path: {path_validation.error_message}"
157
+
158
+ # Check permissions
159
+ is_allowed, error_message = await self.check_path_allowed(path, tool_ctx)
160
+ if not is_allowed:
161
+ return error_message
162
+
163
+ # Check existence
164
+ is_exists, error_message = await self.check_path_exists(path, tool_ctx)
165
+ if not is_exists:
166
+ return error_message
167
+
168
+ await tool_ctx.info(f"Searching for '{pattern}' in {path}")
169
+
170
+ # Get files to process
171
+ files_to_process = self._get_source_files(path)
172
+ if not files_to_process:
173
+ return f"No source code files found in {path}"
174
+
175
+ # Process files
176
+ results = []
177
+ match_count = 0
178
+
179
+ for file_path in files_to_process:
180
+ if match_count >= limit:
181
+ break
182
+
183
+ try:
184
+ with open(file_path, "r", encoding="utf-8") as f:
185
+ code = f.read()
186
+
187
+ tc = TreeContext(
188
+ file_path,
189
+ code,
190
+ color=False,
191
+ verbose=False,
192
+ line_number=True,
193
+ )
194
+
195
+ # Find matches
196
+ loi = tc.grep(pattern, ignore_case)
197
+
198
+ if loi:
199
+ if show_context:
200
+ tc.add_lines_of_interest(loi)
201
+ tc.add_context()
202
+ output = tc.format()
203
+ else:
204
+ # Just show matching lines
205
+ output = "\n".join([f"{line}: {code.splitlines()[line-1]}" for line in loi])
206
+
207
+ results.append(f"\n{file_path}:\n{output}\n")
208
+ match_count += len(loi)
209
+
210
+ except Exception as e:
211
+ await tool_ctx.warning(f"Could not parse {file_path}: {str(e)}")
212
+
213
+ if not results:
214
+ return f"No matches found for '{pattern}' in {path}"
215
+
216
+ output = [f"=== Symbol Search Results for '{pattern}' ==="]
217
+ output.append(f"Found {match_count} matches in {len(results)} files\n")
218
+ output.extend(results)
219
+
220
+ if match_count >= limit:
221
+ output.append(f"\n(Results limited to {limit} matches)")
222
+
223
+ return "\n".join(output)
224
+
225
+ async def _handle_index(self, params: Dict[str, Any], tool_ctx) -> str:
226
+ """Index symbols in a codebase."""
227
+ path = params.get("path", ".")
228
+
229
+ # Validate path
230
+ is_allowed, error_message = await self.check_path_allowed(path, tool_ctx)
231
+ if not is_allowed:
232
+ return error_message
233
+
234
+ await tool_ctx.info(f"Indexing symbols in {path}...")
235
+
236
+ files_to_process = self._get_source_files(path)
237
+ if not files_to_process:
238
+ return f"No source code files found in {path}"
239
+
240
+ # Clear cache for this path
241
+ self._symbol_cache[path] = {
242
+ "functions": [],
243
+ "classes": [],
244
+ "methods": [],
245
+ "variables": [],
246
+ }
247
+
248
+ indexed_count = 0
249
+ symbol_count = 0
250
+
251
+ for file_path in files_to_process:
252
+ try:
253
+ with open(file_path, "r", encoding="utf-8") as f:
254
+ code = f.read()
255
+
256
+ tc = TreeContext(file_path, code, color=False, verbose=False)
257
+
258
+ # Extract symbols (simplified - would need proper tree-sitter queries)
259
+ # This is a placeholder for actual symbol extraction
260
+ symbols = self._extract_symbols(tc, file_path)
261
+
262
+ for symbol_type, syms in symbols.items():
263
+ self._symbol_cache[path][symbol_type].extend(syms)
264
+ symbol_count += len(syms)
265
+
266
+ indexed_count += 1
267
+
268
+ except Exception as e:
269
+ await tool_ctx.warning(f"Could not index {file_path}: {str(e)}")
270
+
271
+ output = [f"=== Symbol Indexing Complete ==="]
272
+ output.append(f"Indexed {indexed_count} files")
273
+ output.append(f"Found {symbol_count} total symbols:")
274
+
275
+ for symbol_type, symbols in self._symbol_cache[path].items():
276
+ if symbols:
277
+ output.append(f" {symbol_type}: {len(symbols)}")
278
+
279
+ return "\n".join(output)
280
+
281
+ async def _handle_query(self, params: Dict[str, Any], tool_ctx) -> str:
282
+ """Query indexed symbols."""
283
+ path = params.get("path", ".")
284
+ symbol_type = params.get("symbol_type")
285
+ pattern = params.get("pattern")
286
+ limit = params.get("limit", 50)
287
+
288
+ # Check if we have indexed this path
289
+ if path not in self._symbol_cache:
290
+ return f"No symbols indexed for {path}. Run 'symbols --action index --path {path}' first."
291
+
292
+ symbols = self._symbol_cache[path]
293
+ results = []
294
+
295
+ # Filter by type if specified
296
+ if symbol_type:
297
+ if symbol_type in symbols:
298
+ candidates = symbols[symbol_type]
299
+ else:
300
+ return f"Unknown symbol type: {symbol_type}. Valid types: {', '.join(symbols.keys())}"
301
+ else:
302
+ # Combine all symbol types
303
+ candidates = []
304
+ for syms in symbols.values():
305
+ candidates.extend(syms)
306
+
307
+ # Filter by pattern if specified
308
+ if pattern:
309
+ filtered = []
310
+ for sym in candidates:
311
+ if pattern.lower() in sym["name"].lower():
312
+ filtered.append(sym)
313
+ candidates = filtered
314
+
315
+ # Limit results
316
+ candidates = candidates[:limit]
317
+
318
+ if not candidates:
319
+ return "No symbols found matching criteria"
320
+
321
+ output = [f"=== Symbol Query Results ==="]
322
+ output.append(f"Found {len(candidates)} symbols\n")
323
+
324
+ for sym in candidates:
325
+ output.append(f"{sym['type']}: {sym['name']}")
326
+ output.append(f" File: {sym['file']}:{sym['line']}")
327
+ if sym.get("signature"):
328
+ output.append(f" Signature: {sym['signature']}")
329
+ output.append("")
330
+
331
+ return "\n".join(output)
332
+
333
+ async def _handle_list(self, params: Dict[str, Any], tool_ctx) -> str:
334
+ """List all symbols in a path."""
335
+ # Similar to query but shows all symbols
336
+ params["pattern"] = None
337
+ return await self._handle_query(params, tool_ctx)
338
+
339
+ def _get_source_files(self, path: str) -> List[str]:
340
+ """Get all source code files in a path."""
341
+ path_obj = Path(path)
342
+ files_to_process = []
343
+
344
+ # Common source file extensions
345
+ extensions = {
346
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h",
347
+ ".hpp", ".cs", ".rb", ".go", ".rs", ".swift", ".kt", ".scala",
348
+ ".php", ".lua", ".r", ".jl", ".ex", ".exs", ".clj", ".cljs"
349
+ }
350
+
351
+ if path_obj.is_file():
352
+ if path_obj.suffix in extensions:
353
+ files_to_process.append(str(path_obj))
354
+ elif path_obj.is_dir():
355
+ for root, _, files in os.walk(path_obj):
356
+ for file in files:
357
+ file_path = Path(root) / file
358
+ if file_path.suffix in extensions and self.is_path_allowed(str(file_path)):
359
+ files_to_process.append(str(file_path))
360
+
361
+ return files_to_process
362
+
363
+ def _extract_symbols(self, tc: TreeContext, file_path: str) -> Dict[str, List[Dict[str, Any]]]:
364
+ """Extract symbols from a TreeContext (placeholder implementation)."""
365
+ # This would need proper tree-sitter queries to extract symbols
366
+ # For now, return empty structure
367
+ return {
368
+ "functions": [],
369
+ "classes": [],
370
+ "methods": [],
371
+ "variables": [],
372
+ }
373
+
374
+ def register(self, mcp_server) -> None:
375
+ """Register this tool with the MCP server."""
376
+ pass
@@ -0,0 +1,268 @@
1
+ """Tree tool implementation.
2
+
3
+ Unix-style tree command for directory visualization.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Annotated, TypedDict, Unpack, final, override, Optional, List
9
+
10
+ from mcp.server.fastmcp import Context as MCPContext
11
+ from pydantic import Field
12
+
13
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
14
+
15
+
16
+ # Parameter types
17
+ TreePath = Annotated[
18
+ str,
19
+ Field(
20
+ description="Directory path to display",
21
+ default=".",
22
+ ),
23
+ ]
24
+
25
+ Depth = Annotated[
26
+ Optional[int],
27
+ Field(
28
+ description="Maximum depth to display",
29
+ default=None,
30
+ ),
31
+ ]
32
+
33
+ ShowHidden = Annotated[
34
+ bool,
35
+ Field(
36
+ description="Show hidden files (starting with .)",
37
+ default=False,
38
+ ),
39
+ ]
40
+
41
+ DirsOnly = Annotated[
42
+ bool,
43
+ Field(
44
+ description="Show only directories",
45
+ default=False,
46
+ ),
47
+ ]
48
+
49
+ ShowSize = Annotated[
50
+ bool,
51
+ Field(
52
+ description="Show file sizes",
53
+ default=False,
54
+ ),
55
+ ]
56
+
57
+ Pattern = Annotated[
58
+ Optional[str],
59
+ Field(
60
+ description="Only show files matching pattern",
61
+ default=None,
62
+ ),
63
+ ]
64
+
65
+
66
+ class TreeParams(TypedDict, total=False):
67
+ """Parameters for tree tool."""
68
+ path: str
69
+ depth: Optional[int]
70
+ show_hidden: bool
71
+ dirs_only: bool
72
+ show_size: bool
73
+ pattern: Optional[str]
74
+
75
+
76
+ @final
77
+ class TreeTool(FilesystemBaseTool):
78
+ """Unix-style tree command for directory visualization."""
79
+
80
+ @property
81
+ @override
82
+ def name(self) -> str:
83
+ """Get the tool name."""
84
+ return "tree"
85
+
86
+ @property
87
+ @override
88
+ def description(self) -> str:
89
+ """Get the tool description."""
90
+ return """Directory tree visualization.
91
+
92
+ Usage:
93
+ tree
94
+ tree ./src --depth 2
95
+ tree --dirs-only
96
+ tree --pattern "*.py" --show-size"""
97
+
98
+ @override
99
+ async def call(
100
+ self,
101
+ ctx: MCPContext,
102
+ **params: Unpack[TreeParams],
103
+ ) -> str:
104
+ """Execute tree command."""
105
+ tool_ctx = self.create_tool_context(ctx)
106
+
107
+ # Extract parameters
108
+ path = params.get("path", ".")
109
+ max_depth = params.get("depth")
110
+ show_hidden = params.get("show_hidden", False)
111
+ dirs_only = params.get("dirs_only", False)
112
+ show_size = params.get("show_size", False)
113
+ pattern = params.get("pattern")
114
+
115
+ # Validate path
116
+ path_validation = self.validate_path(path)
117
+ if path_validation.is_error:
118
+ return f"Error: {path_validation.error_message}"
119
+
120
+ # Check permissions
121
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
122
+ if not allowed:
123
+ return error_msg
124
+
125
+ # Check existence
126
+ exists, error_msg = await self.check_path_exists(path, tool_ctx)
127
+ if not exists:
128
+ return error_msg
129
+
130
+ path_obj = Path(path)
131
+ if not path_obj.is_dir():
132
+ return f"Error: {path} is not a directory"
133
+
134
+ # Build tree
135
+ output = [str(path_obj)]
136
+ stats = {"dirs": 0, "files": 0}
137
+
138
+ self._build_tree(
139
+ path_obj,
140
+ output,
141
+ stats,
142
+ prefix="",
143
+ is_last=True,
144
+ current_depth=0,
145
+ max_depth=max_depth,
146
+ show_hidden=show_hidden,
147
+ dirs_only=dirs_only,
148
+ show_size=show_size,
149
+ pattern=pattern
150
+ )
151
+
152
+ # Add summary
153
+ output.append("")
154
+ if dirs_only:
155
+ output.append(f"{stats['dirs']} directories")
156
+ else:
157
+ output.append(f"{stats['dirs']} directories, {stats['files']} files")
158
+
159
+ return "\n".join(output)
160
+
161
+ def _build_tree(
162
+ self,
163
+ path: Path,
164
+ output: List[str],
165
+ stats: dict,
166
+ prefix: str,
167
+ is_last: bool,
168
+ current_depth: int,
169
+ max_depth: Optional[int],
170
+ show_hidden: bool,
171
+ dirs_only: bool,
172
+ show_size: bool,
173
+ pattern: Optional[str],
174
+ ) -> None:
175
+ """Recursively build tree structure."""
176
+ # Check depth limit
177
+ if max_depth is not None and current_depth >= max_depth:
178
+ return
179
+
180
+ try:
181
+ # Get entries
182
+ entries = list(path.iterdir())
183
+
184
+ # Filter hidden files
185
+ if not show_hidden:
186
+ entries = [e for e in entries if not e.name.startswith(".")]
187
+
188
+ # Filter by pattern
189
+ if pattern:
190
+ import fnmatch
191
+ entries = [e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()]
192
+
193
+ # Filter dirs only
194
+ if dirs_only:
195
+ entries = [e for e in entries if e.is_dir()]
196
+
197
+ # Sort entries (dirs first, then alphabetically)
198
+ entries.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
199
+
200
+ # Process each entry
201
+ for i, entry in enumerate(entries):
202
+ is_last_entry = i == len(entries) - 1
203
+
204
+ # Skip if not allowed
205
+ if not self.is_path_allowed(str(entry)):
206
+ continue
207
+
208
+ # Build the tree branch
209
+ if prefix:
210
+ if is_last_entry:
211
+ branch = prefix + "└── "
212
+ extension = prefix + " "
213
+ else:
214
+ branch = prefix + "├── "
215
+ extension = prefix + "│ "
216
+ else:
217
+ branch = ""
218
+ extension = ""
219
+
220
+ # Build entry line
221
+ line = branch + entry.name
222
+
223
+ # Add size if requested
224
+ if show_size and entry.is_file():
225
+ try:
226
+ size = entry.stat().st_size
227
+ line += f" ({self._format_size(size)})"
228
+ except:
229
+ pass
230
+
231
+ output.append(line)
232
+
233
+ # Update stats
234
+ if entry.is_dir():
235
+ stats["dirs"] += 1
236
+ # Recurse into directory
237
+ self._build_tree(
238
+ entry,
239
+ output,
240
+ stats,
241
+ prefix=extension,
242
+ is_last=is_last_entry,
243
+ current_depth=current_depth + 1,
244
+ max_depth=max_depth,
245
+ show_hidden=show_hidden,
246
+ dirs_only=dirs_only,
247
+ show_size=show_size,
248
+ pattern=pattern
249
+ )
250
+ else:
251
+ stats["files"] += 1
252
+
253
+ except PermissionError:
254
+ output.append(prefix + "[Permission Denied]")
255
+ except Exception as e:
256
+ output.append(prefix + f"[Error: {str(e)}]")
257
+
258
+ def _format_size(self, size: int) -> str:
259
+ """Format file size in human-readable format."""
260
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
261
+ if size < 1024.0:
262
+ return f"{size:.1f}{unit}"
263
+ size /= 1024.0
264
+ return f"{size:.1f}PB"
265
+
266
+ def register(self, mcp_server) -> None:
267
+ """Register this tool with the MCP server."""
268
+ pass