hanzo-mcp 0.1.25__py3-none-any.whl → 0.1.32__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 +54 -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 +29 -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/common/version_tool.py +62 -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.32.dist-info}/METADATA +83 -77
- hanzo_mcp-0.1.32.dist-info/RECORD +46 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.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.32.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/top_level.txt +0 -0
|
@@ -1,1050 +0,0 @@
|
|
|
1
|
-
"""Filesystem operations tools for Hanzo MCP.
|
|
2
|
-
|
|
3
|
-
This module provides comprehensive tools for interacting with the filesystem,
|
|
4
|
-
including reading, writing, editing files, directory operations, and searching.
|
|
5
|
-
All operations are secured through permission validation and path checking.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import time
|
|
9
|
-
from difflib import unified_diff
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Any, final
|
|
12
|
-
|
|
13
|
-
from mcp.server.fastmcp import Context as MCPContext
|
|
14
|
-
from mcp.server.fastmcp import FastMCP
|
|
15
|
-
|
|
16
|
-
from hanzo_mcp.tools.common.context import DocumentContext, create_tool_context
|
|
17
|
-
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
18
|
-
from hanzo_mcp.tools.common.validation import validate_path_parameter
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@final
|
|
22
|
-
class FileOperations:
|
|
23
|
-
"""File and filesystem operations tools for Hanzo MCP."""
|
|
24
|
-
|
|
25
|
-
def __init__(
|
|
26
|
-
self, document_context: DocumentContext, permission_manager: PermissionManager
|
|
27
|
-
) -> None:
|
|
28
|
-
"""Initialize file operations.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
document_context: Document context for tracking file contents
|
|
32
|
-
permission_manager: Permission manager for access control
|
|
33
|
-
"""
|
|
34
|
-
self.document_context: DocumentContext = document_context
|
|
35
|
-
self.permission_manager: PermissionManager = permission_manager
|
|
36
|
-
|
|
37
|
-
def register_tools(self, mcp_server: FastMCP) -> None:
|
|
38
|
-
"""Register file operation tools with the MCP server.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
mcp_server: The FastMCP server instance
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
# Read files tool
|
|
45
|
-
@mcp_server.tool()
|
|
46
|
-
async def read_files(paths: list[str] | str, ctx: MCPContext) -> str:
|
|
47
|
-
"""Read the contents of one or multiple files.
|
|
48
|
-
|
|
49
|
-
Can read a single file or multiple files simultaneously. When reading multiple files,
|
|
50
|
-
each file's content is returned with its path as a reference. Failed reads for
|
|
51
|
-
individual files won't stop the entire operation. Only works within allowed directories.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
paths: Either a single absolute file path (string) or a list of absolute file paths
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Contents of the file(s) with path references
|
|
58
|
-
"""
|
|
59
|
-
tool_ctx = create_tool_context(ctx)
|
|
60
|
-
tool_ctx.set_tool_info("read_files")
|
|
61
|
-
|
|
62
|
-
# Validate the 'paths' parameter
|
|
63
|
-
if not paths:
|
|
64
|
-
await tool_ctx.error("Parameter 'paths' is required but was None")
|
|
65
|
-
return "Error: Parameter 'paths' is required but was None"
|
|
66
|
-
|
|
67
|
-
# Convert single path to list if necessary
|
|
68
|
-
path_list: list[str] = [paths] if isinstance(paths, str) else paths
|
|
69
|
-
|
|
70
|
-
# Handle empty list case
|
|
71
|
-
if not path_list:
|
|
72
|
-
await tool_ctx.warning("No files specified to read")
|
|
73
|
-
return "Error: No files specified to read"
|
|
74
|
-
|
|
75
|
-
# For a single file with direct string return
|
|
76
|
-
single_file_mode = isinstance(paths, str)
|
|
77
|
-
|
|
78
|
-
await tool_ctx.info(f"Reading {len(path_list)} file(s)")
|
|
79
|
-
|
|
80
|
-
results: list[str] = []
|
|
81
|
-
|
|
82
|
-
# Read each file
|
|
83
|
-
for i, path in enumerate(path_list):
|
|
84
|
-
# Report progress
|
|
85
|
-
await tool_ctx.report_progress(i, len(path_list))
|
|
86
|
-
|
|
87
|
-
# Check if path is allowed
|
|
88
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
89
|
-
await tool_ctx.error(
|
|
90
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
91
|
-
)
|
|
92
|
-
results.append(
|
|
93
|
-
f"{path}: Error - Access denied - path outside allowed directories"
|
|
94
|
-
)
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
try:
|
|
98
|
-
file_path = Path(path)
|
|
99
|
-
|
|
100
|
-
if not file_path.exists():
|
|
101
|
-
await tool_ctx.error(f"File does not exist: {path}")
|
|
102
|
-
results.append(f"{path}: Error - File does not exist")
|
|
103
|
-
continue
|
|
104
|
-
|
|
105
|
-
if not file_path.is_file():
|
|
106
|
-
await tool_ctx.error(f"Path is not a file: {path}")
|
|
107
|
-
results.append(f"{path}: Error - Path is not a file")
|
|
108
|
-
continue
|
|
109
|
-
|
|
110
|
-
# Read the file
|
|
111
|
-
try:
|
|
112
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
113
|
-
content = f.read()
|
|
114
|
-
|
|
115
|
-
# Add to document context
|
|
116
|
-
self.document_context.add_document(path, content)
|
|
117
|
-
|
|
118
|
-
results.append(f"{path}:\n{content}")
|
|
119
|
-
except UnicodeDecodeError:
|
|
120
|
-
try:
|
|
121
|
-
with open(file_path, "r", encoding="latin-1") as f:
|
|
122
|
-
content = f.read()
|
|
123
|
-
await tool_ctx.warning(
|
|
124
|
-
f"File read with latin-1 encoding: {path}"
|
|
125
|
-
)
|
|
126
|
-
results.append(f"{path} (latin-1 encoding):\n{content}")
|
|
127
|
-
except Exception:
|
|
128
|
-
await tool_ctx.error(f"Cannot read binary file: {path}")
|
|
129
|
-
results.append(f"{path}: Error - Cannot read binary file")
|
|
130
|
-
except Exception as e:
|
|
131
|
-
await tool_ctx.error(f"Error reading file: {str(e)}")
|
|
132
|
-
results.append(f"{path}: Error - {str(e)}")
|
|
133
|
-
|
|
134
|
-
# Final progress report
|
|
135
|
-
await tool_ctx.report_progress(len(path_list), len(path_list))
|
|
136
|
-
|
|
137
|
-
await tool_ctx.info(f"Read {len(path_list)} file(s)")
|
|
138
|
-
|
|
139
|
-
# For single file mode with direct string input, return just the content
|
|
140
|
-
# if successful, otherwise return the error
|
|
141
|
-
if single_file_mode and len(results) == 1:
|
|
142
|
-
result_text = results[0]
|
|
143
|
-
# If it's a successful read (doesn't contain "Error - ")
|
|
144
|
-
if not result_text.split(":", 1)[1].strip().startswith("Error - "):
|
|
145
|
-
# Just return the content part (after the first colon and newline)
|
|
146
|
-
return result_text.split(":", 1)[1].strip()
|
|
147
|
-
else:
|
|
148
|
-
# Return just the error message
|
|
149
|
-
return "Error: " + result_text.split("Error - ", 1)[1]
|
|
150
|
-
|
|
151
|
-
# For multiple files or failed single file read, return all results
|
|
152
|
-
return "\n\n---\n\n".join(results)
|
|
153
|
-
|
|
154
|
-
# Write file tool
|
|
155
|
-
@mcp_server.tool()
|
|
156
|
-
async def write_file(path: str, content: str, ctx: MCPContext) -> str:
|
|
157
|
-
"""Create a new file or completely overwrite an existing file with new content.
|
|
158
|
-
|
|
159
|
-
Use with caution as it will overwrite existing files without warning.
|
|
160
|
-
Handles text content with proper encoding. Only works within allowed directories.
|
|
161
|
-
|
|
162
|
-
Args:
|
|
163
|
-
path: Absolute path to the file to write
|
|
164
|
-
content: Content to write to the file
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
Result of the write operation
|
|
168
|
-
"""
|
|
169
|
-
tool_ctx = create_tool_context(ctx)
|
|
170
|
-
tool_ctx.set_tool_info("write_file")
|
|
171
|
-
|
|
172
|
-
# Validate parameters
|
|
173
|
-
path_validation = validate_path_parameter(path)
|
|
174
|
-
if path_validation.is_error:
|
|
175
|
-
await tool_ctx.error(path_validation.error_message)
|
|
176
|
-
return f"Error: {path_validation.error_message}"
|
|
177
|
-
|
|
178
|
-
if not content:
|
|
179
|
-
await tool_ctx.error("Parameter 'content' is required but was None")
|
|
180
|
-
return "Error: Parameter 'content' is required but was None"
|
|
181
|
-
|
|
182
|
-
await tool_ctx.info(f"Writing file: {path}")
|
|
183
|
-
|
|
184
|
-
# Check if file is allowed to be written
|
|
185
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
186
|
-
await tool_ctx.error(
|
|
187
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
188
|
-
)
|
|
189
|
-
return (
|
|
190
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
# Additional check already verified by is_path_allowed above
|
|
194
|
-
await tool_ctx.info(f"Writing file: {path}")
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
file_path = Path(path)
|
|
198
|
-
|
|
199
|
-
# Check if parent directory is allowed
|
|
200
|
-
parent_dir = str(file_path.parent)
|
|
201
|
-
if not self.permission_manager.is_path_allowed(parent_dir):
|
|
202
|
-
await tool_ctx.error(f"Parent directory not allowed: {parent_dir}")
|
|
203
|
-
return f"Error: Parent directory not allowed: {parent_dir}"
|
|
204
|
-
|
|
205
|
-
# Create parent directories if they don't exist
|
|
206
|
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
-
|
|
208
|
-
# Write the file
|
|
209
|
-
with open(file_path, "w", encoding="utf-8") as f:
|
|
210
|
-
f.write(content)
|
|
211
|
-
|
|
212
|
-
# Add to document context
|
|
213
|
-
self.document_context.add_document(path, content)
|
|
214
|
-
|
|
215
|
-
await tool_ctx.info(
|
|
216
|
-
f"Successfully wrote file: {path} ({len(content)} bytes)"
|
|
217
|
-
)
|
|
218
|
-
return f"Successfully wrote file: {path} ({len(content)} bytes)"
|
|
219
|
-
except Exception as e:
|
|
220
|
-
await tool_ctx.error(f"Error writing file: {str(e)}")
|
|
221
|
-
return f"Error writing file: {str(e)}"
|
|
222
|
-
|
|
223
|
-
# Edit file tool
|
|
224
|
-
@mcp_server.tool()
|
|
225
|
-
async def edit_file(
|
|
226
|
-
path: str, edits: list[dict[str, str]], dry_run: bool, ctx: MCPContext
|
|
227
|
-
) -> str:
|
|
228
|
-
"""Make line-based edits to a text file.
|
|
229
|
-
|
|
230
|
-
Each edit replaces exact line sequences with new content.
|
|
231
|
-
Returns a git-style diff showing the changes made.
|
|
232
|
-
Only works within allowed directories.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
path: Absolute path to the file to edit
|
|
236
|
-
edits: List of edit operations [{"oldText": "...", "newText": "..."}]
|
|
237
|
-
dry_run: Preview changes without applying them (default: False)
|
|
238
|
-
|
|
239
|
-
Returns:
|
|
240
|
-
Git-style diff of the changes
|
|
241
|
-
"""
|
|
242
|
-
tool_ctx = create_tool_context(ctx)
|
|
243
|
-
tool_ctx.set_tool_info("edit_file")
|
|
244
|
-
|
|
245
|
-
# Validate parameters
|
|
246
|
-
path_validation = validate_path_parameter(path)
|
|
247
|
-
if path_validation.is_error:
|
|
248
|
-
await tool_ctx.error(path_validation.error_message)
|
|
249
|
-
return f"Error: {path_validation.error_message}"
|
|
250
|
-
|
|
251
|
-
if not edits:
|
|
252
|
-
await tool_ctx.error("Parameter 'edits' is required but was None")
|
|
253
|
-
return "Error: Parameter 'edits' is required but was None"
|
|
254
|
-
|
|
255
|
-
if not edits: # Check for empty list
|
|
256
|
-
await tool_ctx.warning("No edits specified")
|
|
257
|
-
return "Error: No edits specified"
|
|
258
|
-
|
|
259
|
-
# Validate each edit to ensure oldText is not empty
|
|
260
|
-
for i, edit in enumerate(edits):
|
|
261
|
-
old_text = edit.get("oldText", "")
|
|
262
|
-
if not old_text or old_text.strip() == "":
|
|
263
|
-
await tool_ctx.error(
|
|
264
|
-
f"Parameter 'oldText' in edit at index {i} is empty"
|
|
265
|
-
)
|
|
266
|
-
return f"Error: Parameter 'oldText' in edit at index {i} cannot be empty - must provide text to match"
|
|
267
|
-
|
|
268
|
-
# dry_run parameter can be None safely as it has a default value in the function signature
|
|
269
|
-
|
|
270
|
-
await tool_ctx.info(f"Editing file: {path}")
|
|
271
|
-
|
|
272
|
-
# Check if file is allowed to be edited
|
|
273
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
274
|
-
await tool_ctx.error(
|
|
275
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
276
|
-
)
|
|
277
|
-
return (
|
|
278
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
# Additional check already verified by is_path_allowed above
|
|
282
|
-
await tool_ctx.info(f"Editing file: {path}")
|
|
283
|
-
|
|
284
|
-
try:
|
|
285
|
-
file_path = Path(path)
|
|
286
|
-
|
|
287
|
-
if not file_path.exists():
|
|
288
|
-
await tool_ctx.error(f"File does not exist: {path}")
|
|
289
|
-
return f"Error: File does not exist: {path}"
|
|
290
|
-
|
|
291
|
-
if not file_path.is_file():
|
|
292
|
-
await tool_ctx.error(f"Path is not a file: {path}")
|
|
293
|
-
return f"Error: Path is not a file: {path}"
|
|
294
|
-
|
|
295
|
-
# Read the file
|
|
296
|
-
try:
|
|
297
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
298
|
-
original_content = f.read()
|
|
299
|
-
|
|
300
|
-
# Apply edits
|
|
301
|
-
modified_content = original_content
|
|
302
|
-
edits_applied = 0
|
|
303
|
-
|
|
304
|
-
for edit in edits:
|
|
305
|
-
old_text = edit.get("oldText", "")
|
|
306
|
-
new_text = edit.get("newText", "")
|
|
307
|
-
|
|
308
|
-
if old_text in modified_content:
|
|
309
|
-
modified_content = modified_content.replace(
|
|
310
|
-
old_text, new_text
|
|
311
|
-
)
|
|
312
|
-
edits_applied += 1
|
|
313
|
-
else:
|
|
314
|
-
# Try line-by-line matching for whitespace flexibility
|
|
315
|
-
old_lines = old_text.splitlines()
|
|
316
|
-
content_lines = modified_content.splitlines()
|
|
317
|
-
|
|
318
|
-
for i in range(len(content_lines) - len(old_lines) + 1):
|
|
319
|
-
current_chunk = content_lines[i : i + len(old_lines)]
|
|
320
|
-
|
|
321
|
-
# Compare with whitespace normalization
|
|
322
|
-
matches = all(
|
|
323
|
-
old_line.strip() == content_line.strip()
|
|
324
|
-
for old_line, content_line in zip(
|
|
325
|
-
old_lines, current_chunk
|
|
326
|
-
)
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
if matches:
|
|
330
|
-
# Replace the matching lines
|
|
331
|
-
new_lines = new_text.splitlines()
|
|
332
|
-
content_lines[i : i + len(old_lines)] = new_lines
|
|
333
|
-
modified_content = "\n".join(content_lines)
|
|
334
|
-
edits_applied += 1
|
|
335
|
-
break
|
|
336
|
-
|
|
337
|
-
if edits_applied < len(edits):
|
|
338
|
-
await tool_ctx.warning(
|
|
339
|
-
f"Some edits could not be applied: {edits_applied}/{len(edits)}"
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
# Generate diff
|
|
343
|
-
original_lines = original_content.splitlines(keepends=True)
|
|
344
|
-
modified_lines = modified_content.splitlines(keepends=True)
|
|
345
|
-
|
|
346
|
-
diff_lines = list(
|
|
347
|
-
unified_diff(
|
|
348
|
-
original_lines,
|
|
349
|
-
modified_lines,
|
|
350
|
-
fromfile=f"{path} (original)",
|
|
351
|
-
tofile=f"{path} (modified)",
|
|
352
|
-
n=3,
|
|
353
|
-
)
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
diff_text = "".join(diff_lines)
|
|
357
|
-
|
|
358
|
-
# Determine the number of backticks needed
|
|
359
|
-
num_backticks = 3
|
|
360
|
-
while f"```{num_backticks}" in diff_text:
|
|
361
|
-
num_backticks += 1
|
|
362
|
-
|
|
363
|
-
# Format diff with appropriate number of backticks
|
|
364
|
-
formatted_diff = (
|
|
365
|
-
f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
# Write the file if not a dry run
|
|
369
|
-
if not dry_run and diff_text: # Only write if there are changes
|
|
370
|
-
with open(file_path, "w", encoding="utf-8") as f:
|
|
371
|
-
f.write(modified_content)
|
|
372
|
-
|
|
373
|
-
# Update document context
|
|
374
|
-
self.document_context.update_document(path, modified_content)
|
|
375
|
-
|
|
376
|
-
await tool_ctx.info(
|
|
377
|
-
f"Successfully edited file: {path} ({edits_applied} edits applied)"
|
|
378
|
-
)
|
|
379
|
-
return f"Successfully edited file: {path} ({edits_applied} edits applied)\n\n{formatted_diff}"
|
|
380
|
-
elif not diff_text:
|
|
381
|
-
return f"No changes made to file: {path}"
|
|
382
|
-
else:
|
|
383
|
-
await tool_ctx.info(
|
|
384
|
-
f"Dry run: {edits_applied} edits would be applied"
|
|
385
|
-
)
|
|
386
|
-
return f"Dry run: {edits_applied} edits would be applied\n\n{formatted_diff}"
|
|
387
|
-
except UnicodeDecodeError:
|
|
388
|
-
await tool_ctx.error(f"Cannot edit binary file: {path}")
|
|
389
|
-
return f"Error: Cannot edit binary file: {path}"
|
|
390
|
-
except Exception as e:
|
|
391
|
-
await tool_ctx.error(f"Error editing file: {str(e)}")
|
|
392
|
-
return f"Error editing file: {str(e)}"
|
|
393
|
-
|
|
394
|
-
# Directory tree tool
|
|
395
|
-
@mcp_server.tool()
|
|
396
|
-
async def directory_tree(
|
|
397
|
-
path: str, ctx: MCPContext, depth: int = 3, include_filtered: bool = False
|
|
398
|
-
) -> str:
|
|
399
|
-
"""Get a recursive tree view of files and directories with customizable depth and filtering.
|
|
400
|
-
|
|
401
|
-
Returns a structured view of the directory tree with files and subdirectories.
|
|
402
|
-
Directories are marked with trailing slashes. The output is formatted as an
|
|
403
|
-
indented list for readability. By default, common development directories like
|
|
404
|
-
.git, node_modules, and venv are noted but not traversed unless explicitly
|
|
405
|
-
requested. Only works within allowed directories.
|
|
406
|
-
|
|
407
|
-
Args:
|
|
408
|
-
path: Absolute path to the directory to explore
|
|
409
|
-
depth: Maximum depth to traverse (default: 3, 0 or -1 for unlimited)
|
|
410
|
-
include_filtered: Whether to include normally filtered directories (default: False)
|
|
411
|
-
|
|
412
|
-
Returns:
|
|
413
|
-
Structured tree view of the directory
|
|
414
|
-
"""
|
|
415
|
-
tool_ctx = create_tool_context(ctx)
|
|
416
|
-
tool_ctx.set_tool_info("directory_tree")
|
|
417
|
-
|
|
418
|
-
# Validate path parameter
|
|
419
|
-
path_validation = validate_path_parameter(path)
|
|
420
|
-
if path_validation.is_error:
|
|
421
|
-
await tool_ctx.error(path_validation.error_message)
|
|
422
|
-
return f"Error: {path_validation.error_message}"
|
|
423
|
-
|
|
424
|
-
await tool_ctx.info(
|
|
425
|
-
f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})"
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
# Check if path is allowed
|
|
429
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
430
|
-
await tool_ctx.error(
|
|
431
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
432
|
-
)
|
|
433
|
-
return (
|
|
434
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
try:
|
|
438
|
-
dir_path = Path(path)
|
|
439
|
-
|
|
440
|
-
if not dir_path.exists():
|
|
441
|
-
await tool_ctx.error(f"Directory does not exist: {path}")
|
|
442
|
-
return f"Error: Directory does not exist: {path}"
|
|
443
|
-
|
|
444
|
-
if not dir_path.is_dir():
|
|
445
|
-
await tool_ctx.error(f"Path is not a directory: {path}")
|
|
446
|
-
return f"Error: Path is not a directory: {path}"
|
|
447
|
-
|
|
448
|
-
# Define filtered directories
|
|
449
|
-
FILTERED_DIRECTORIES = {
|
|
450
|
-
".git",
|
|
451
|
-
"node_modules",
|
|
452
|
-
".venv",
|
|
453
|
-
"venv",
|
|
454
|
-
"__pycache__",
|
|
455
|
-
".pytest_cache",
|
|
456
|
-
".idea",
|
|
457
|
-
".vs",
|
|
458
|
-
".vscode",
|
|
459
|
-
"dist",
|
|
460
|
-
"build",
|
|
461
|
-
"target",
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
# Log filtering settings
|
|
465
|
-
await tool_ctx.info(
|
|
466
|
-
f"Directory tree filtering: include_filtered={include_filtered}"
|
|
467
|
-
)
|
|
468
|
-
|
|
469
|
-
# Check if a directory should be filtered
|
|
470
|
-
def should_filter(current_path: Path) -> bool:
|
|
471
|
-
# Don't filter if it's the explicitly requested path
|
|
472
|
-
if str(current_path.absolute()) == str(dir_path.absolute()):
|
|
473
|
-
# Don't filter explicitly requested paths
|
|
474
|
-
return False
|
|
475
|
-
|
|
476
|
-
# Filter based on directory name if filtering is enabled
|
|
477
|
-
return (
|
|
478
|
-
current_path.name in FILTERED_DIRECTORIES
|
|
479
|
-
and not include_filtered
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
# Track stats for summary
|
|
483
|
-
stats = {
|
|
484
|
-
"directories": 0,
|
|
485
|
-
"files": 0,
|
|
486
|
-
"skipped_depth": 0,
|
|
487
|
-
"skipped_filtered": 0,
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
# Build the tree recursively
|
|
491
|
-
async def build_tree(
|
|
492
|
-
current_path: Path, current_depth: int = 0
|
|
493
|
-
) -> list[dict[str, Any]]:
|
|
494
|
-
result: list[dict[str, Any]] = []
|
|
495
|
-
|
|
496
|
-
# Skip processing if path isn't allowed
|
|
497
|
-
if not self.permission_manager.is_path_allowed(str(current_path)):
|
|
498
|
-
return result
|
|
499
|
-
|
|
500
|
-
try:
|
|
501
|
-
# Sort entries: directories first, then files alphabetically
|
|
502
|
-
entries = sorted(
|
|
503
|
-
current_path.iterdir(),
|
|
504
|
-
key=lambda x: (not x.is_dir(), x.name),
|
|
505
|
-
)
|
|
506
|
-
|
|
507
|
-
for entry in entries:
|
|
508
|
-
# Skip entries that aren't allowed
|
|
509
|
-
if not self.permission_manager.is_path_allowed(str(entry)):
|
|
510
|
-
continue
|
|
511
|
-
|
|
512
|
-
if entry.is_dir():
|
|
513
|
-
stats["directories"] += 1
|
|
514
|
-
entry_data: dict[str, Any] = {
|
|
515
|
-
"name": entry.name,
|
|
516
|
-
"type": "directory",
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
# Check if we should filter this directory
|
|
520
|
-
if should_filter(entry):
|
|
521
|
-
entry_data["skipped"] = "filtered-directory"
|
|
522
|
-
stats["skipped_filtered"] += 1
|
|
523
|
-
result.append(entry_data)
|
|
524
|
-
continue
|
|
525
|
-
|
|
526
|
-
# Check depth limit (if enabled)
|
|
527
|
-
if depth > 0 and current_depth >= depth:
|
|
528
|
-
entry_data["skipped"] = "depth-limit"
|
|
529
|
-
stats["skipped_depth"] += 1
|
|
530
|
-
result.append(entry_data)
|
|
531
|
-
continue
|
|
532
|
-
|
|
533
|
-
# Process children recursively with depth increment
|
|
534
|
-
entry_data["children"] = await build_tree(
|
|
535
|
-
entry, current_depth + 1
|
|
536
|
-
)
|
|
537
|
-
result.append(entry_data)
|
|
538
|
-
else:
|
|
539
|
-
# Files should be at the same level check as directories
|
|
540
|
-
if depth <= 0 or current_depth < depth:
|
|
541
|
-
stats["files"] += 1
|
|
542
|
-
# Add file entry
|
|
543
|
-
result.append({"name": entry.name, "type": "file"})
|
|
544
|
-
|
|
545
|
-
except Exception as e:
|
|
546
|
-
await tool_ctx.warning(
|
|
547
|
-
f"Error processing {current_path}: {str(e)}"
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
return result
|
|
551
|
-
|
|
552
|
-
# Format the tree as a simple indented structure
|
|
553
|
-
def format_tree(
|
|
554
|
-
tree_data: list[dict[str, Any]], level: int = 0
|
|
555
|
-
) -> list[str]:
|
|
556
|
-
lines = []
|
|
557
|
-
|
|
558
|
-
for item in tree_data:
|
|
559
|
-
# Indentation based on level
|
|
560
|
-
indent = " " * level
|
|
561
|
-
|
|
562
|
-
# Format based on type
|
|
563
|
-
if item["type"] == "directory":
|
|
564
|
-
if "skipped" in item:
|
|
565
|
-
lines.append(
|
|
566
|
-
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
|
|
567
|
-
)
|
|
568
|
-
else:
|
|
569
|
-
lines.append(f"{indent}{item['name']}/")
|
|
570
|
-
# Add children with increased indentation if present
|
|
571
|
-
if "children" in item:
|
|
572
|
-
lines.extend(
|
|
573
|
-
format_tree(item["children"], level + 1)
|
|
574
|
-
)
|
|
575
|
-
else:
|
|
576
|
-
# File
|
|
577
|
-
lines.append(f"{indent}{item['name']}")
|
|
578
|
-
|
|
579
|
-
return lines
|
|
580
|
-
|
|
581
|
-
# Build tree starting from the requested directory
|
|
582
|
-
tree_data = await build_tree(dir_path)
|
|
583
|
-
|
|
584
|
-
# Format as simple text
|
|
585
|
-
formatted_output = "\n".join(format_tree(tree_data))
|
|
586
|
-
|
|
587
|
-
# Add stats summary
|
|
588
|
-
summary = (
|
|
589
|
-
f"\nDirectory Stats: {stats['directories']} directories, {stats['files']} files "
|
|
590
|
-
f"({stats['skipped_depth']} skipped due to depth limit, "
|
|
591
|
-
f"{stats['skipped_filtered']} filtered directories skipped)"
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
await tool_ctx.info(
|
|
595
|
-
f"Generated directory tree for {path} (depth: {depth}, include_filtered: {include_filtered})"
|
|
596
|
-
)
|
|
597
|
-
|
|
598
|
-
return formatted_output + summary
|
|
599
|
-
except Exception as e:
|
|
600
|
-
await tool_ctx.error(f"Error generating directory tree: {str(e)}")
|
|
601
|
-
return f"Error generating directory tree: {str(e)}"
|
|
602
|
-
|
|
603
|
-
# Get file info tool
|
|
604
|
-
@mcp_server.tool()
|
|
605
|
-
async def get_file_info(path: str, ctx: MCPContext) -> str:
|
|
606
|
-
"""Retrieve detailed metadata about a file or directory.
|
|
607
|
-
|
|
608
|
-
Returns comprehensive information including size, creation time,
|
|
609
|
-
last modified time, permissions, and type. This tool is perfect for
|
|
610
|
-
understanding file characteristics without reading the actual content.
|
|
611
|
-
Only works within allowed directories.
|
|
612
|
-
|
|
613
|
-
Args:
|
|
614
|
-
path: Absolute path to the file to write
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
Returns:
|
|
618
|
-
Detailed metadata about the file or directory
|
|
619
|
-
"""
|
|
620
|
-
tool_ctx = create_tool_context(ctx)
|
|
621
|
-
tool_ctx.set_tool_info("get_file_info")
|
|
622
|
-
|
|
623
|
-
# Validate path parameter
|
|
624
|
-
path_validation = validate_path_parameter(path)
|
|
625
|
-
if path_validation.is_error:
|
|
626
|
-
await tool_ctx.error(path_validation.error_message)
|
|
627
|
-
return f"Error: {path_validation.error_message}"
|
|
628
|
-
|
|
629
|
-
await tool_ctx.info(f"Getting file info: {path}")
|
|
630
|
-
|
|
631
|
-
# Check if path is allowed
|
|
632
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
633
|
-
await tool_ctx.error(
|
|
634
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
635
|
-
)
|
|
636
|
-
return (
|
|
637
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
try:
|
|
641
|
-
file_path = Path(path)
|
|
642
|
-
|
|
643
|
-
if not file_path.exists():
|
|
644
|
-
await tool_ctx.error(f"Path does not exist: {path}")
|
|
645
|
-
return f"Error: Path does not exist: {path}"
|
|
646
|
-
|
|
647
|
-
# Get file stats
|
|
648
|
-
stats = file_path.stat()
|
|
649
|
-
|
|
650
|
-
# Format timestamps
|
|
651
|
-
created_time = time.strftime(
|
|
652
|
-
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_ctime)
|
|
653
|
-
)
|
|
654
|
-
modified_time = time.strftime(
|
|
655
|
-
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_mtime)
|
|
656
|
-
)
|
|
657
|
-
accessed_time = time.strftime(
|
|
658
|
-
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_atime)
|
|
659
|
-
)
|
|
660
|
-
|
|
661
|
-
# Format permissions in octal
|
|
662
|
-
permissions = oct(stats.st_mode)[-3:]
|
|
663
|
-
|
|
664
|
-
# Build info dictionary
|
|
665
|
-
file_info: dict[str, Any] = {
|
|
666
|
-
"name": file_path.name,
|
|
667
|
-
"type": "directory" if file_path.is_dir() else "file",
|
|
668
|
-
"size": stats.st_size,
|
|
669
|
-
"created": created_time,
|
|
670
|
-
"modified": modified_time,
|
|
671
|
-
"accessed": accessed_time,
|
|
672
|
-
"permissions": permissions,
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
# Format the output
|
|
676
|
-
result = [f"{key}: {value}" for key, value in file_info.items()]
|
|
677
|
-
|
|
678
|
-
await tool_ctx.info(f"Retrieved info for {path}")
|
|
679
|
-
return "\n".join(result)
|
|
680
|
-
except Exception as e:
|
|
681
|
-
await tool_ctx.error(f"Error getting file info: {str(e)}")
|
|
682
|
-
return f"Error getting file info: {str(e)}"
|
|
683
|
-
|
|
684
|
-
# Search content tool (grep-like functionality)
|
|
685
|
-
@mcp_server.tool()
|
|
686
|
-
async def search_content(
|
|
687
|
-
ctx: MCPContext, pattern: str, path: str, file_pattern: str = "*"
|
|
688
|
-
) -> str:
|
|
689
|
-
"""Search for a pattern in file contents.
|
|
690
|
-
|
|
691
|
-
Similar to grep, this tool searches for text patterns within files.
|
|
692
|
-
Searches recursively through all files in the specified directory
|
|
693
|
-
that match the file pattern. Returns matching lines with file and
|
|
694
|
-
line number references. Only searches within allowed directories.
|
|
695
|
-
|
|
696
|
-
Args:
|
|
697
|
-
pattern: Text pattern to search for
|
|
698
|
-
path: Absolute directory or file to search in
|
|
699
|
-
file_pattern: File pattern to match (e.g., "*.py" for Python files)
|
|
700
|
-
|
|
701
|
-
Returns:
|
|
702
|
-
Matching lines with file and line number references
|
|
703
|
-
"""
|
|
704
|
-
tool_ctx = create_tool_context(ctx)
|
|
705
|
-
tool_ctx.set_tool_info("search_content")
|
|
706
|
-
|
|
707
|
-
# Validate required parameters
|
|
708
|
-
if not pattern:
|
|
709
|
-
await tool_ctx.error("Parameter 'pattern' is required but was None")
|
|
710
|
-
return "Error: Parameter 'pattern' is required but was None"
|
|
711
|
-
|
|
712
|
-
if pattern.strip() == "":
|
|
713
|
-
await tool_ctx.error("Parameter 'pattern' cannot be empty")
|
|
714
|
-
return "Error: Parameter 'pattern' cannot be empty"
|
|
715
|
-
|
|
716
|
-
path_validation = validate_path_parameter(path)
|
|
717
|
-
if path_validation.is_error:
|
|
718
|
-
await tool_ctx.error(path_validation.error_message)
|
|
719
|
-
return f"Error: {path_validation.error_message}"
|
|
720
|
-
|
|
721
|
-
# file_pattern can be None safely as it has a default value
|
|
722
|
-
|
|
723
|
-
await tool_ctx.info(
|
|
724
|
-
f"Searching for pattern '{pattern}' in files matching '{file_pattern}' in {path}"
|
|
725
|
-
)
|
|
726
|
-
|
|
727
|
-
# Check if path is allowed
|
|
728
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
729
|
-
await tool_ctx.error(
|
|
730
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
731
|
-
)
|
|
732
|
-
return (
|
|
733
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
try:
|
|
737
|
-
input_path = Path(path)
|
|
738
|
-
|
|
739
|
-
if not input_path.exists():
|
|
740
|
-
await tool_ctx.error(f"Path does not exist: {path}")
|
|
741
|
-
return f"Error: Path does not exist: {path}"
|
|
742
|
-
|
|
743
|
-
# Find matching files
|
|
744
|
-
matching_files: list[Path] = []
|
|
745
|
-
|
|
746
|
-
# Process based on whether path is a file or directory
|
|
747
|
-
if input_path.is_file():
|
|
748
|
-
# Single file search
|
|
749
|
-
if file_pattern == "*" or input_path.match(file_pattern):
|
|
750
|
-
matching_files.append(input_path)
|
|
751
|
-
await tool_ctx.info(f"Searching single file: {path}")
|
|
752
|
-
else:
|
|
753
|
-
await tool_ctx.info(
|
|
754
|
-
f"File does not match pattern '{file_pattern}': {path}"
|
|
755
|
-
)
|
|
756
|
-
return f"File does not match pattern '{file_pattern}': {path}"
|
|
757
|
-
elif input_path.is_dir():
|
|
758
|
-
# Directory search - recursive function to find files
|
|
759
|
-
async def find_files(current_path: Path) -> None:
|
|
760
|
-
# Skip if not allowed
|
|
761
|
-
if not self.permission_manager.is_path_allowed(
|
|
762
|
-
str(current_path)
|
|
763
|
-
):
|
|
764
|
-
return
|
|
765
|
-
|
|
766
|
-
try:
|
|
767
|
-
for entry in current_path.iterdir():
|
|
768
|
-
# Skip if not allowed
|
|
769
|
-
if not self.permission_manager.is_path_allowed(
|
|
770
|
-
str(entry)
|
|
771
|
-
):
|
|
772
|
-
continue
|
|
773
|
-
|
|
774
|
-
if entry.is_file():
|
|
775
|
-
# Check if file matches pattern
|
|
776
|
-
if file_pattern == "*" or entry.match(file_pattern):
|
|
777
|
-
matching_files.append(entry)
|
|
778
|
-
elif entry.is_dir():
|
|
779
|
-
# Recurse into directory
|
|
780
|
-
await find_files(entry)
|
|
781
|
-
except Exception as e:
|
|
782
|
-
await tool_ctx.warning(
|
|
783
|
-
f"Error accessing {current_path}: {str(e)}"
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
# Find all matching files in directory
|
|
787
|
-
await tool_ctx.info(f"Searching directory: {path}")
|
|
788
|
-
await find_files(input_path)
|
|
789
|
-
else:
|
|
790
|
-
# This shouldn't happen since we already checked for existence
|
|
791
|
-
await tool_ctx.error(
|
|
792
|
-
f"Path is neither a file nor a directory: {path}"
|
|
793
|
-
)
|
|
794
|
-
return f"Error: Path is neither a file nor a directory: {path}"
|
|
795
|
-
|
|
796
|
-
# Report progress
|
|
797
|
-
total_files = len(matching_files)
|
|
798
|
-
if input_path.is_file():
|
|
799
|
-
await tool_ctx.info(f"Searching file: {path}")
|
|
800
|
-
else:
|
|
801
|
-
await tool_ctx.info(
|
|
802
|
-
f"Searching through {total_files} files in directory"
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
# Search through files
|
|
806
|
-
results: list[str] = []
|
|
807
|
-
files_processed = 0
|
|
808
|
-
matches_found = 0
|
|
809
|
-
|
|
810
|
-
for i, file_path in enumerate(matching_files):
|
|
811
|
-
# Report progress every 10 files
|
|
812
|
-
if i % 10 == 0:
|
|
813
|
-
await tool_ctx.report_progress(i, total_files)
|
|
814
|
-
|
|
815
|
-
try:
|
|
816
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
817
|
-
for line_num, line in enumerate(f, 1):
|
|
818
|
-
if pattern in line:
|
|
819
|
-
results.append(
|
|
820
|
-
f"{file_path}:{line_num}: {line.rstrip()}"
|
|
821
|
-
)
|
|
822
|
-
matches_found += 1
|
|
823
|
-
files_processed += 1
|
|
824
|
-
except UnicodeDecodeError:
|
|
825
|
-
# Skip binary files
|
|
826
|
-
continue
|
|
827
|
-
except Exception as e:
|
|
828
|
-
await tool_ctx.warning(f"Error reading {file_path}: {str(e)}")
|
|
829
|
-
|
|
830
|
-
# Final progress report
|
|
831
|
-
await tool_ctx.report_progress(total_files, total_files)
|
|
832
|
-
|
|
833
|
-
if not results:
|
|
834
|
-
if input_path.is_file():
|
|
835
|
-
return (
|
|
836
|
-
f"No matches found for pattern '{pattern}' in file: {path}"
|
|
837
|
-
)
|
|
838
|
-
else:
|
|
839
|
-
return f"No matches found for pattern '{pattern}' in files matching '{file_pattern}' in directory: {path}"
|
|
840
|
-
|
|
841
|
-
await tool_ctx.info(
|
|
842
|
-
f"Found {matches_found} matches in {files_processed} file{'s' if files_processed != 1 else ''}"
|
|
843
|
-
)
|
|
844
|
-
return (
|
|
845
|
-
f"Found {matches_found} matches in {files_processed} files:\n\n"
|
|
846
|
-
+ "\n".join(results)
|
|
847
|
-
)
|
|
848
|
-
except Exception as e:
|
|
849
|
-
await tool_ctx.error(f"Error searching file contents: {str(e)}")
|
|
850
|
-
return f"Error searching file contents: {str(e)}"
|
|
851
|
-
|
|
852
|
-
# Content replace tool (search and replace across multiple files)
|
|
853
|
-
@mcp_server.tool()
|
|
854
|
-
async def content_replace(
|
|
855
|
-
ctx: MCPContext,
|
|
856
|
-
pattern: str,
|
|
857
|
-
replacement: str,
|
|
858
|
-
path: str,
|
|
859
|
-
file_pattern: str = "*",
|
|
860
|
-
dry_run: bool = False,
|
|
861
|
-
) -> str:
|
|
862
|
-
"""Replace a pattern in file contents across multiple files.
|
|
863
|
-
|
|
864
|
-
Searches for text patterns across all files in the specified directory
|
|
865
|
-
that match the file pattern and replaces them with the specified text.
|
|
866
|
-
Can be run in dry-run mode to preview changes without applying them.
|
|
867
|
-
Only works within allowed directories.
|
|
868
|
-
|
|
869
|
-
Args:
|
|
870
|
-
pattern: Text pattern to search for
|
|
871
|
-
replacement: Text to replace with
|
|
872
|
-
path: Absolute directory or file to search in
|
|
873
|
-
file_pattern: File pattern to match (e.g., "*.py" for Python files)
|
|
874
|
-
dry_run: Preview changes without applying them (default: False)
|
|
875
|
-
|
|
876
|
-
Returns:
|
|
877
|
-
Summary of replacements made or preview of changes
|
|
878
|
-
"""
|
|
879
|
-
tool_ctx = create_tool_context(ctx)
|
|
880
|
-
tool_ctx.set_tool_info("content_replace")
|
|
881
|
-
|
|
882
|
-
# Validate required parameters
|
|
883
|
-
if not pattern:
|
|
884
|
-
await tool_ctx.error("Parameter 'pattern' is required but was None")
|
|
885
|
-
return "Error: Parameter 'pattern' is required but was None"
|
|
886
|
-
|
|
887
|
-
if pattern.strip() == "":
|
|
888
|
-
await tool_ctx.error("Parameter 'pattern' cannot be empty")
|
|
889
|
-
return "Error: Parameter 'pattern' cannot be empty"
|
|
890
|
-
|
|
891
|
-
if not replacement:
|
|
892
|
-
await tool_ctx.error("Parameter 'replacement' is required but was None")
|
|
893
|
-
return "Error: Parameter 'replacement' is required but was None"
|
|
894
|
-
|
|
895
|
-
# Note: replacement can be an empty string as sometimes you want to delete the pattern
|
|
896
|
-
|
|
897
|
-
path_validation = validate_path_parameter(path)
|
|
898
|
-
if path_validation.is_error:
|
|
899
|
-
await tool_ctx.error(path_validation.error_message)
|
|
900
|
-
return f"Error: {path_validation.error_message}"
|
|
901
|
-
|
|
902
|
-
# file_pattern and dry_run can be None safely as they have default values
|
|
903
|
-
|
|
904
|
-
await tool_ctx.info(
|
|
905
|
-
f"Replacing pattern '{pattern}' with '{replacement}' in files matching '{file_pattern}' in {path}"
|
|
906
|
-
)
|
|
907
|
-
|
|
908
|
-
# Check if path is allowed
|
|
909
|
-
if not self.permission_manager.is_path_allowed(path):
|
|
910
|
-
await tool_ctx.error(
|
|
911
|
-
f"Access denied - path outside allowed directories: {path}"
|
|
912
|
-
)
|
|
913
|
-
return (
|
|
914
|
-
f"Error: Access denied - path outside allowed directories: {path}"
|
|
915
|
-
)
|
|
916
|
-
|
|
917
|
-
# Additional check already verified by is_path_allowed above
|
|
918
|
-
await tool_ctx.info(
|
|
919
|
-
f"Replacing pattern '{pattern}' with '{replacement}' in files matching '{file_pattern}' in {path}"
|
|
920
|
-
)
|
|
921
|
-
|
|
922
|
-
try:
|
|
923
|
-
input_path = Path(path)
|
|
924
|
-
|
|
925
|
-
if not input_path.exists():
|
|
926
|
-
await tool_ctx.error(f"Path does not exist: {path}")
|
|
927
|
-
return f"Error: Path does not exist: {path}"
|
|
928
|
-
|
|
929
|
-
# Find matching files
|
|
930
|
-
matching_files: list[Path] = []
|
|
931
|
-
|
|
932
|
-
# Process based on whether path is a file or directory
|
|
933
|
-
if input_path.is_file():
|
|
934
|
-
# Single file search
|
|
935
|
-
if file_pattern == "*" or input_path.match(file_pattern):
|
|
936
|
-
matching_files.append(input_path)
|
|
937
|
-
await tool_ctx.info(f"Searching single file: {path}")
|
|
938
|
-
else:
|
|
939
|
-
await tool_ctx.info(
|
|
940
|
-
f"File does not match pattern '{file_pattern}': {path}"
|
|
941
|
-
)
|
|
942
|
-
return f"File does not match pattern '{file_pattern}': {path}"
|
|
943
|
-
elif input_path.is_dir():
|
|
944
|
-
# Directory search - recursive function to find files
|
|
945
|
-
async def find_files(current_path: Path) -> None:
|
|
946
|
-
# Skip if not allowed
|
|
947
|
-
if not self.permission_manager.is_path_allowed(
|
|
948
|
-
str(current_path)
|
|
949
|
-
):
|
|
950
|
-
return
|
|
951
|
-
|
|
952
|
-
try:
|
|
953
|
-
for entry in current_path.iterdir():
|
|
954
|
-
# Skip if not allowed
|
|
955
|
-
if not self.permission_manager.is_path_allowed(
|
|
956
|
-
str(entry)
|
|
957
|
-
):
|
|
958
|
-
continue
|
|
959
|
-
|
|
960
|
-
if entry.is_file():
|
|
961
|
-
# Check if file matches pattern
|
|
962
|
-
if file_pattern == "*" or entry.match(file_pattern):
|
|
963
|
-
matching_files.append(entry)
|
|
964
|
-
elif entry.is_dir():
|
|
965
|
-
# Recurse into directory
|
|
966
|
-
await find_files(entry)
|
|
967
|
-
except Exception as e:
|
|
968
|
-
await tool_ctx.warning(
|
|
969
|
-
f"Error accessing {current_path}: {str(e)}"
|
|
970
|
-
)
|
|
971
|
-
|
|
972
|
-
# Find all matching files in directory
|
|
973
|
-
await tool_ctx.info(f"Searching directory: {path}")
|
|
974
|
-
await find_files(input_path)
|
|
975
|
-
else:
|
|
976
|
-
# This shouldn't happen since we already checked for existence
|
|
977
|
-
await tool_ctx.error(
|
|
978
|
-
f"Path is neither a file nor a directory: {path}"
|
|
979
|
-
)
|
|
980
|
-
return f"Error: Path is neither a file nor a directory: {path}"
|
|
981
|
-
|
|
982
|
-
# Report progress
|
|
983
|
-
total_files = len(matching_files)
|
|
984
|
-
await tool_ctx.info(f"Processing {total_files} files")
|
|
985
|
-
|
|
986
|
-
# Process files
|
|
987
|
-
results: list[str] = []
|
|
988
|
-
files_modified = 0
|
|
989
|
-
replacements_made = 0
|
|
990
|
-
|
|
991
|
-
for i, file_path in enumerate(matching_files):
|
|
992
|
-
# Report progress every 10 files
|
|
993
|
-
if i % 10 == 0:
|
|
994
|
-
await tool_ctx.report_progress(i, total_files)
|
|
995
|
-
|
|
996
|
-
try:
|
|
997
|
-
# Read file
|
|
998
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
999
|
-
content = f.read()
|
|
1000
|
-
|
|
1001
|
-
# Count occurrences
|
|
1002
|
-
count = content.count(pattern)
|
|
1003
|
-
|
|
1004
|
-
if count > 0:
|
|
1005
|
-
# Replace pattern
|
|
1006
|
-
new_content = content.replace(pattern, replacement)
|
|
1007
|
-
|
|
1008
|
-
# Add to results
|
|
1009
|
-
replacements_made += count
|
|
1010
|
-
files_modified += 1
|
|
1011
|
-
results.append(f"{file_path}: {count} replacements")
|
|
1012
|
-
|
|
1013
|
-
# Write file if not a dry run
|
|
1014
|
-
if not dry_run:
|
|
1015
|
-
with open(file_path, "w", encoding="utf-8") as f:
|
|
1016
|
-
f.write(new_content)
|
|
1017
|
-
|
|
1018
|
-
# Update document context
|
|
1019
|
-
self.document_context.update_document(
|
|
1020
|
-
str(file_path), new_content
|
|
1021
|
-
)
|
|
1022
|
-
except UnicodeDecodeError:
|
|
1023
|
-
# Skip binary files
|
|
1024
|
-
continue
|
|
1025
|
-
except Exception as e:
|
|
1026
|
-
await tool_ctx.warning(
|
|
1027
|
-
f"Error processing {file_path}: {str(e)}"
|
|
1028
|
-
)
|
|
1029
|
-
|
|
1030
|
-
# Final progress report
|
|
1031
|
-
await tool_ctx.report_progress(total_files, total_files)
|
|
1032
|
-
|
|
1033
|
-
if replacements_made == 0:
|
|
1034
|
-
return f"No occurrences of pattern '{pattern}' found in files matching '{file_pattern}' in {path}"
|
|
1035
|
-
|
|
1036
|
-
if dry_run:
|
|
1037
|
-
await tool_ctx.info(
|
|
1038
|
-
f"Dry run: {replacements_made} replacements would be made in {files_modified} files"
|
|
1039
|
-
)
|
|
1040
|
-
message = f"Dry run: {replacements_made} replacements of '{pattern}' with '{replacement}' would be made in {files_modified} files:"
|
|
1041
|
-
else:
|
|
1042
|
-
await tool_ctx.info(
|
|
1043
|
-
f"Made {replacements_made} replacements in {files_modified} files"
|
|
1044
|
-
)
|
|
1045
|
-
message = f"Made {replacements_made} replacements of '{pattern}' with '{replacement}' in {files_modified} files:"
|
|
1046
|
-
|
|
1047
|
-
return message + "\n\n" + "\n".join(results)
|
|
1048
|
-
except Exception as e:
|
|
1049
|
-
await tool_ctx.error(f"Error replacing content: {str(e)}")
|
|
1050
|
-
return f"Error replacing content: {str(e)}"
|