hanzo-mcp 0.1.25__py3-none-any.whl → 0.1.30__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/__init__.py +2 -2
- hanzo_mcp/cli.py +80 -9
- hanzo_mcp/server.py +41 -10
- hanzo_mcp/tools/__init__.py +51 -32
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +17 -0
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +7 -3
- hanzo_mcp/tools/common/permissions.py +63 -119
- hanzo_mcp/tools/common/session.py +91 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/filesystem/__init__.py +85 -5
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +67 -4
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +72 -112
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -1
- hanzo_mcp/tools/project/analysis.py +9 -6
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -1
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +203 -322
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/METADATA +72 -77
- hanzo_mcp-0.1.30.dist-info/RECORD +45 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/licenses/LICENSE +2 -2
- hanzo_mcp/tools/common/thinking.py +0 -65
- hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
- hanzo_mcp-0.1.25.dist-info/RECORD +0 -24
- hanzo_mcp-0.1.25.dist-info/zip-safe +0 -1
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Content replace tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ContentReplaceTool for replacing text patterns in files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, final, override
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class ContentReplaceTool(FilesystemBaseTool):
|
|
18
|
+
"""Tool for replacing text patterns in files."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@override
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Get the tool name.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tool name
|
|
27
|
+
"""
|
|
28
|
+
return "content_replace"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@override
|
|
32
|
+
def description(self) -> str:
|
|
33
|
+
"""Get the tool description.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tool description
|
|
37
|
+
"""
|
|
38
|
+
return """Replace a pattern in file contents across multiple files.
|
|
39
|
+
|
|
40
|
+
Searches for text patterns across all files in the specified directory
|
|
41
|
+
that match the file pattern and replaces them with the specified text.
|
|
42
|
+
Can be run in dry-run mode to preview changes without applying them.
|
|
43
|
+
Only works within allowed directories."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@override
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
"""Get the parameter specifications for the tool.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Parameter specifications
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
"properties": {
|
|
55
|
+
"pattern": {
|
|
56
|
+
"title": "Pattern",
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"replacement": {
|
|
60
|
+
"title": "Replacement",
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"path": {
|
|
64
|
+
"title": "Path",
|
|
65
|
+
"type": "string"
|
|
66
|
+
},
|
|
67
|
+
"file_pattern": {
|
|
68
|
+
"default": "*",
|
|
69
|
+
"title": "File Pattern",
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"dry_run": {
|
|
73
|
+
"default": False,
|
|
74
|
+
"title": "Dry Run",
|
|
75
|
+
"type": "boolean"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"required": ["pattern", "replacement", "path"],
|
|
79
|
+
"title": "content_replaceArguments",
|
|
80
|
+
"type": "object"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
@override
|
|
85
|
+
def required(self) -> list[str]:
|
|
86
|
+
"""Get the list of required parameter names.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of required parameter names
|
|
90
|
+
"""
|
|
91
|
+
return ["pattern", "replacement", "path"]
|
|
92
|
+
|
|
93
|
+
@override
|
|
94
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
95
|
+
"""Execute the tool with the given parameters.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
ctx: MCP context
|
|
99
|
+
**params: Tool parameters
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tool result
|
|
103
|
+
"""
|
|
104
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
105
|
+
|
|
106
|
+
# Extract parameters
|
|
107
|
+
pattern = params.get("pattern")
|
|
108
|
+
replacement = params.get("replacement")
|
|
109
|
+
path = params.get("path")
|
|
110
|
+
file_pattern = params.get("file_pattern", "*") # Default to all files
|
|
111
|
+
dry_run = params.get("dry_run", False) # Default to False
|
|
112
|
+
|
|
113
|
+
# Validate required parameters
|
|
114
|
+
if not pattern:
|
|
115
|
+
await tool_ctx.error("Parameter 'pattern' is required but was None")
|
|
116
|
+
return "Error: Parameter 'pattern' is required but was None"
|
|
117
|
+
|
|
118
|
+
if pattern.strip() == "":
|
|
119
|
+
await tool_ctx.error("Parameter 'pattern' cannot be empty")
|
|
120
|
+
return "Error: Parameter 'pattern' cannot be empty"
|
|
121
|
+
|
|
122
|
+
if replacement is None:
|
|
123
|
+
await tool_ctx.error("Parameter 'replacement' is required but was None")
|
|
124
|
+
return "Error: Parameter 'replacement' is required but was None"
|
|
125
|
+
|
|
126
|
+
if not path:
|
|
127
|
+
await tool_ctx.error("Parameter 'path' is required but was None")
|
|
128
|
+
return "Error: Parameter 'path' is required but was None"
|
|
129
|
+
|
|
130
|
+
if path.strip() == "":
|
|
131
|
+
await tool_ctx.error("Parameter 'path' cannot be empty")
|
|
132
|
+
return "Error: Parameter 'path' cannot be empty"
|
|
133
|
+
|
|
134
|
+
# Note: replacement can be an empty string as sometimes you want to delete the pattern
|
|
135
|
+
|
|
136
|
+
path_validation = self.validate_path(path)
|
|
137
|
+
if path_validation.is_error:
|
|
138
|
+
await tool_ctx.error(path_validation.error_message)
|
|
139
|
+
return f"Error: {path_validation.error_message}"
|
|
140
|
+
|
|
141
|
+
# file_pattern and dry_run can be None safely as they have default values
|
|
142
|
+
|
|
143
|
+
await tool_ctx.info(
|
|
144
|
+
f"Replacing pattern '{pattern}' with '{replacement}' in files matching '{file_pattern}' in {path}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Check if path is allowed
|
|
148
|
+
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
149
|
+
if not allowed:
|
|
150
|
+
return error_msg
|
|
151
|
+
|
|
152
|
+
# Additional check already verified by is_path_allowed above
|
|
153
|
+
await tool_ctx.info(
|
|
154
|
+
f"Replacing pattern '{pattern}' with '{replacement}' in files matching '{file_pattern}' in {path}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
input_path = Path(path)
|
|
159
|
+
|
|
160
|
+
# Check if path exists
|
|
161
|
+
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
162
|
+
if not exists:
|
|
163
|
+
return error_msg
|
|
164
|
+
|
|
165
|
+
# Find matching files
|
|
166
|
+
matching_files: list[Path] = []
|
|
167
|
+
|
|
168
|
+
# Process based on whether path is a file or directory
|
|
169
|
+
if input_path.is_file():
|
|
170
|
+
# Single file search
|
|
171
|
+
if file_pattern == "*" or fnmatch.fnmatch(input_path.name, file_pattern):
|
|
172
|
+
matching_files.append(input_path)
|
|
173
|
+
await tool_ctx.info(f"Searching single file: {path}")
|
|
174
|
+
else:
|
|
175
|
+
await tool_ctx.info(f"File does not match pattern '{file_pattern}': {path}")
|
|
176
|
+
return f"File does not match pattern '{file_pattern}': {path}"
|
|
177
|
+
elif input_path.is_dir():
|
|
178
|
+
# Directory search - optimized file finding
|
|
179
|
+
await tool_ctx.info(f"Finding files in directory: {path}")
|
|
180
|
+
|
|
181
|
+
# Keep track of allowed paths for filtering
|
|
182
|
+
allowed_paths: set[str] = set()
|
|
183
|
+
|
|
184
|
+
# Collect all allowed paths first for faster filtering
|
|
185
|
+
for entry in input_path.rglob("*"):
|
|
186
|
+
entry_path = str(entry)
|
|
187
|
+
if self.is_path_allowed(entry_path):
|
|
188
|
+
allowed_paths.add(entry_path)
|
|
189
|
+
|
|
190
|
+
# Find matching files efficiently
|
|
191
|
+
for entry in input_path.rglob("*"):
|
|
192
|
+
entry_path = str(entry)
|
|
193
|
+
if entry_path in allowed_paths and entry.is_file():
|
|
194
|
+
if file_pattern == "*" or fnmatch.fnmatch(entry.name, file_pattern):
|
|
195
|
+
matching_files.append(entry)
|
|
196
|
+
|
|
197
|
+
await tool_ctx.info(f"Found {len(matching_files)} matching files")
|
|
198
|
+
else:
|
|
199
|
+
# This shouldn't happen since we already checked for existence
|
|
200
|
+
await tool_ctx.error(f"Path is neither a file nor a directory: {path}")
|
|
201
|
+
return f"Error: Path is neither a file nor a directory: {path}"
|
|
202
|
+
|
|
203
|
+
# Report progress
|
|
204
|
+
total_files = len(matching_files)
|
|
205
|
+
await tool_ctx.info(f"Processing {total_files} files")
|
|
206
|
+
|
|
207
|
+
# Process files
|
|
208
|
+
results: list[str] = []
|
|
209
|
+
files_modified = 0
|
|
210
|
+
replacements_made = 0
|
|
211
|
+
|
|
212
|
+
for i, file_path in enumerate(matching_files):
|
|
213
|
+
# Report progress every 10 files
|
|
214
|
+
if i % 10 == 0:
|
|
215
|
+
await tool_ctx.report_progress(i, total_files)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Read file
|
|
219
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
220
|
+
content = f.read()
|
|
221
|
+
|
|
222
|
+
# Count occurrences
|
|
223
|
+
count = content.count(pattern)
|
|
224
|
+
|
|
225
|
+
if count > 0:
|
|
226
|
+
# Replace pattern
|
|
227
|
+
new_content = content.replace(pattern, replacement)
|
|
228
|
+
|
|
229
|
+
# Add to results
|
|
230
|
+
replacements_made += count
|
|
231
|
+
files_modified += 1
|
|
232
|
+
results.append(f"{file_path}: {count} replacements")
|
|
233
|
+
|
|
234
|
+
# Write file if not a dry run
|
|
235
|
+
if not dry_run:
|
|
236
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
237
|
+
f.write(new_content)
|
|
238
|
+
|
|
239
|
+
# Update document context
|
|
240
|
+
self.document_context.update_document(
|
|
241
|
+
str(file_path), new_content
|
|
242
|
+
)
|
|
243
|
+
except UnicodeDecodeError:
|
|
244
|
+
# Skip binary files
|
|
245
|
+
continue
|
|
246
|
+
except Exception as e:
|
|
247
|
+
await tool_ctx.warning(
|
|
248
|
+
f"Error processing {file_path}: {str(e)}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Final progress report
|
|
252
|
+
await tool_ctx.report_progress(total_files, total_files)
|
|
253
|
+
|
|
254
|
+
if replacements_made == 0:
|
|
255
|
+
return f"No occurrences of pattern '{pattern}' found in files matching '{file_pattern}' in {path}"
|
|
256
|
+
|
|
257
|
+
if dry_run:
|
|
258
|
+
await tool_ctx.info(
|
|
259
|
+
f"Dry run: {replacements_made} replacements would be made in {files_modified} files"
|
|
260
|
+
)
|
|
261
|
+
message = f"Dry run: {replacements_made} replacements of '{pattern}' with '{replacement}' would be made in {files_modified} files:"
|
|
262
|
+
else:
|
|
263
|
+
await tool_ctx.info(
|
|
264
|
+
f"Made {replacements_made} replacements in {files_modified} files"
|
|
265
|
+
)
|
|
266
|
+
message = f"Made {replacements_made} replacements of '{pattern}' with '{replacement}' in {files_modified} files:"
|
|
267
|
+
|
|
268
|
+
return message + "\n\n" + "\n".join(results)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
await tool_ctx.error(f"Error replacing content: {str(e)}")
|
|
271
|
+
return f"Error replacing content: {str(e)}"
|
|
272
|
+
|
|
273
|
+
@override
|
|
274
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
275
|
+
"""Register this content replace tool with the MCP server.
|
|
276
|
+
|
|
277
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
278
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
mcp_server: The FastMCP server instance
|
|
282
|
+
"""
|
|
283
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
284
|
+
|
|
285
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
286
|
+
async def content_replace(ctx: MCPContext, pattern: str, replacement: str, path: str, file_pattern: str = "*", dry_run: bool = False) -> str:
|
|
287
|
+
return await tool_self.call(ctx, pattern=pattern, replacement=replacement, path=path, file_pattern=file_pattern, dry_run=dry_run)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Directory tree tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the DirectoryTreeTool for viewing file and directory structures.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, final, override
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class DirectoryTreeTool(FilesystemBaseTool):
|
|
17
|
+
"""Tool for viewing directory structure as a tree."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@override
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
"""Get the tool name.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Tool name
|
|
26
|
+
"""
|
|
27
|
+
return "directory_tree"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@override
|
|
31
|
+
def description(self) -> str:
|
|
32
|
+
"""Get the tool description.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tool description
|
|
36
|
+
"""
|
|
37
|
+
return """Get a recursive tree view of files and directories with customizable depth and filtering.
|
|
38
|
+
|
|
39
|
+
Returns a structured view of the directory tree with files and subdirectories.
|
|
40
|
+
Directories are marked with trailing slashes. The output is formatted as an
|
|
41
|
+
indented list for readability. By default, common development directories like
|
|
42
|
+
.git, node_modules, and venv are noted but not traversed unless explicitly
|
|
43
|
+
requested. Only works within allowed directories."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@override
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
"""Get the parameter specifications for the tool.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Parameter specifications
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
"properties": {
|
|
55
|
+
"path": {
|
|
56
|
+
"title": "Path",
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description":"The path to the directory to view"
|
|
59
|
+
},
|
|
60
|
+
"depth": {
|
|
61
|
+
"default": 3,
|
|
62
|
+
"title": "Depth",
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"description": "The maximum depth to traverse (0 for unlimited)"
|
|
65
|
+
},
|
|
66
|
+
"include_filtered": {
|
|
67
|
+
"default": False,
|
|
68
|
+
"title": "Include Filtered",
|
|
69
|
+
"type": "boolean",
|
|
70
|
+
"description": "Include directories that are normally filtered"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"required": ["path"],
|
|
74
|
+
"title": "directory_treeArguments",
|
|
75
|
+
"type": "object"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
@override
|
|
80
|
+
def required(self) -> list[str]:
|
|
81
|
+
"""Get the list of required parameter names.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of required parameter names
|
|
85
|
+
"""
|
|
86
|
+
return ["path"]
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
90
|
+
"""Execute the tool with the given parameters.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
ctx: MCP context
|
|
94
|
+
**params: Tool parameters
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tool result
|
|
98
|
+
"""
|
|
99
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
100
|
+
|
|
101
|
+
# Extract parameters
|
|
102
|
+
path = params.get("path")
|
|
103
|
+
depth = params.get("depth", 3) # Default depth is 3
|
|
104
|
+
include_filtered = params.get("include_filtered", False) # Default to False
|
|
105
|
+
|
|
106
|
+
if not path:
|
|
107
|
+
await tool_ctx.error("Parameter 'path' is required but was None")
|
|
108
|
+
return "Error: Parameter 'path' is required but was None"
|
|
109
|
+
|
|
110
|
+
if path.strip() == "":
|
|
111
|
+
await tool_ctx.error("Parameter 'path' cannot be empty")
|
|
112
|
+
return "Error: Parameter 'path' cannot be empty"
|
|
113
|
+
|
|
114
|
+
# Validate path parameter
|
|
115
|
+
path_validation = self.validate_path(path)
|
|
116
|
+
if path_validation.is_error:
|
|
117
|
+
await tool_ctx.error(path_validation.error_message)
|
|
118
|
+
return f"Error: {path_validation.error_message}"
|
|
119
|
+
|
|
120
|
+
await tool_ctx.info(f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})")
|
|
121
|
+
|
|
122
|
+
# Check if path is allowed
|
|
123
|
+
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
124
|
+
if not allowed:
|
|
125
|
+
return error_msg
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
dir_path = Path(path)
|
|
129
|
+
|
|
130
|
+
# Check if path exists
|
|
131
|
+
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
132
|
+
if not exists:
|
|
133
|
+
return error_msg
|
|
134
|
+
|
|
135
|
+
# Check if path is a directory
|
|
136
|
+
is_dir, error_msg = await self.check_is_directory(path, tool_ctx)
|
|
137
|
+
if not is_dir:
|
|
138
|
+
return error_msg
|
|
139
|
+
|
|
140
|
+
# Define filtered directories
|
|
141
|
+
FILTERED_DIRECTORIES = {
|
|
142
|
+
".git", "node_modules", ".venv", "venv",
|
|
143
|
+
"__pycache__", ".pytest_cache", ".idea",
|
|
144
|
+
".vs", ".vscode", "dist", "build", "target",
|
|
145
|
+
".ruff_cache",".llm-context"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Log filtering settings
|
|
149
|
+
await tool_ctx.info(f"Directory tree filtering: include_filtered={include_filtered}")
|
|
150
|
+
|
|
151
|
+
# Check if a directory should be filtered
|
|
152
|
+
def should_filter(current_path: Path) -> bool:
|
|
153
|
+
# Don't filter if it's the explicitly requested path
|
|
154
|
+
if str(current_path.absolute()) == str(dir_path.absolute()):
|
|
155
|
+
# Don't filter explicitly requested paths
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
# Filter based on directory name if filtering is enabled
|
|
159
|
+
return current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
|
160
|
+
|
|
161
|
+
# Track stats for summary
|
|
162
|
+
stats = {
|
|
163
|
+
"directories": 0,
|
|
164
|
+
"files": 0,
|
|
165
|
+
"skipped_depth": 0,
|
|
166
|
+
"skipped_filtered": 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Build the tree recursively
|
|
170
|
+
async def build_tree(current_path: Path, current_depth: int = 0) -> list[dict[str, Any]]:
|
|
171
|
+
result: list[dict[str, Any]] = []
|
|
172
|
+
|
|
173
|
+
# Skip processing if path isn't allowed
|
|
174
|
+
if not self.is_path_allowed(str(current_path)):
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
# Sort entries: directories first, then files alphabetically
|
|
179
|
+
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
|
180
|
+
|
|
181
|
+
for entry in entries:
|
|
182
|
+
# Skip entries that aren't allowed
|
|
183
|
+
if not self.is_path_allowed(str(entry)):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if entry.is_dir():
|
|
187
|
+
stats["directories"] += 1
|
|
188
|
+
entry_data: dict[str, Any] = {
|
|
189
|
+
"name": entry.name,
|
|
190
|
+
"type": "directory",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Check if we should filter this directory
|
|
194
|
+
if should_filter(entry):
|
|
195
|
+
entry_data["skipped"] = "filtered-directory"
|
|
196
|
+
stats["skipped_filtered"] += 1
|
|
197
|
+
result.append(entry_data)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Check depth limit (if enabled)
|
|
201
|
+
if depth > 0 and current_depth >= depth:
|
|
202
|
+
entry_data["skipped"] = "depth-limit"
|
|
203
|
+
stats["skipped_depth"] += 1
|
|
204
|
+
result.append(entry_data)
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Process children recursively with depth increment
|
|
208
|
+
entry_data["children"] = await build_tree(entry, current_depth + 1)
|
|
209
|
+
result.append(entry_data)
|
|
210
|
+
else:
|
|
211
|
+
# Files should be at the same level check as directories
|
|
212
|
+
if depth <= 0 or current_depth < depth:
|
|
213
|
+
stats["files"] += 1
|
|
214
|
+
# Add file entry
|
|
215
|
+
result.append({
|
|
216
|
+
"name": entry.name,
|
|
217
|
+
"type": "file"
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
await tool_ctx.warning(
|
|
222
|
+
f"Error processing {current_path}: {str(e)}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
# Format the tree as a simple indented structure
|
|
228
|
+
def format_tree(tree_data: list[dict[str, Any]], level: int = 0) -> list[str]:
|
|
229
|
+
lines = []
|
|
230
|
+
|
|
231
|
+
for item in tree_data:
|
|
232
|
+
# Indentation based on level
|
|
233
|
+
indent = " " * level
|
|
234
|
+
|
|
235
|
+
# Format based on type
|
|
236
|
+
if item["type"] == "directory":
|
|
237
|
+
if "skipped" in item:
|
|
238
|
+
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
|
|
239
|
+
else:
|
|
240
|
+
lines.append(f"{indent}{item['name']}/")
|
|
241
|
+
# Add children with increased indentation if present
|
|
242
|
+
if "children" in item:
|
|
243
|
+
lines.extend(format_tree(item["children"], level + 1))
|
|
244
|
+
else:
|
|
245
|
+
# File
|
|
246
|
+
lines.append(f"{indent}{item['name']}")
|
|
247
|
+
|
|
248
|
+
return lines
|
|
249
|
+
|
|
250
|
+
# Build tree starting from the requested directory
|
|
251
|
+
tree_data = await build_tree(dir_path)
|
|
252
|
+
|
|
253
|
+
# Format as simple text
|
|
254
|
+
formatted_output = "\n".join(format_tree(tree_data))
|
|
255
|
+
|
|
256
|
+
# Add stats summary
|
|
257
|
+
summary = (
|
|
258
|
+
f"\nDirectory Stats: {stats['directories']} directories, {stats['files']} files "
|
|
259
|
+
f"({stats['skipped_depth']} skipped due to depth limit, "
|
|
260
|
+
f"{stats['skipped_filtered']} filtered directories skipped)"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
await tool_ctx.info(
|
|
264
|
+
f"Generated directory tree for {path} (depth: {depth}, include_filtered: {include_filtered})"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return formatted_output + summary
|
|
268
|
+
except Exception as e:
|
|
269
|
+
await tool_ctx.error(f"Error generating directory tree: {str(e)}")
|
|
270
|
+
return f"Error generating directory tree: {str(e)}"
|
|
271
|
+
|
|
272
|
+
@override
|
|
273
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
274
|
+
"""Register this directory tree tool with the MCP server.
|
|
275
|
+
|
|
276
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
277
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
mcp_server: The FastMCP server instance
|
|
281
|
+
"""
|
|
282
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
283
|
+
|
|
284
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
285
|
+
async def directory_tree(ctx: MCPContext, path: str, depth: int = 3, include_filtered: bool = False) -> str:
|
|
286
|
+
return await tool_self.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
|