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.

Files changed (49) hide show
  1. hanzo_mcp/__init__.py +2 -2
  2. hanzo_mcp/cli.py +80 -9
  3. hanzo_mcp/server.py +41 -10
  4. hanzo_mcp/tools/__init__.py +54 -32
  5. hanzo_mcp/tools/agent/__init__.py +59 -0
  6. hanzo_mcp/tools/agent/agent_tool.py +474 -0
  7. hanzo_mcp/tools/agent/prompt.py +137 -0
  8. hanzo_mcp/tools/agent/tool_adapter.py +75 -0
  9. hanzo_mcp/tools/common/__init__.py +29 -0
  10. hanzo_mcp/tools/common/base.py +216 -0
  11. hanzo_mcp/tools/common/context.py +7 -3
  12. hanzo_mcp/tools/common/permissions.py +63 -119
  13. hanzo_mcp/tools/common/session.py +91 -0
  14. hanzo_mcp/tools/common/thinking_tool.py +123 -0
  15. hanzo_mcp/tools/common/version_tool.py +62 -0
  16. hanzo_mcp/tools/filesystem/__init__.py +85 -5
  17. hanzo_mcp/tools/filesystem/base.py +113 -0
  18. hanzo_mcp/tools/filesystem/content_replace.py +287 -0
  19. hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
  20. hanzo_mcp/tools/filesystem/edit_file.py +287 -0
  21. hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
  22. hanzo_mcp/tools/filesystem/read_files.py +198 -0
  23. hanzo_mcp/tools/filesystem/search_content.py +275 -0
  24. hanzo_mcp/tools/filesystem/write_file.py +162 -0
  25. hanzo_mcp/tools/jupyter/__init__.py +67 -4
  26. hanzo_mcp/tools/jupyter/base.py +284 -0
  27. hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
  28. hanzo_mcp/tools/jupyter/notebook_operations.py +72 -112
  29. hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
  30. hanzo_mcp/tools/project/__init__.py +64 -1
  31. hanzo_mcp/tools/project/analysis.py +9 -6
  32. hanzo_mcp/tools/project/base.py +66 -0
  33. hanzo_mcp/tools/project/project_analyze.py +173 -0
  34. hanzo_mcp/tools/shell/__init__.py +58 -1
  35. hanzo_mcp/tools/shell/base.py +148 -0
  36. hanzo_mcp/tools/shell/command_executor.py +203 -322
  37. hanzo_mcp/tools/shell/run_command.py +204 -0
  38. hanzo_mcp/tools/shell/run_script.py +215 -0
  39. hanzo_mcp/tools/shell/script_tool.py +244 -0
  40. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/METADATA +83 -77
  41. hanzo_mcp-0.1.32.dist-info/RECORD +46 -0
  42. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/licenses/LICENSE +2 -2
  43. hanzo_mcp/tools/common/thinking.py +0 -65
  44. hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
  45. hanzo_mcp-0.1.25.dist-info/RECORD +0 -24
  46. hanzo_mcp-0.1.25.dist-info/zip-safe +0 -1
  47. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/WHEEL +0 -0
  48. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/entry_points.txt +0 -0
  49. {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)}"