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
@@ -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)