hanzo-mcp 0.1.25__py3-none-any.whl → 0.1.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (48) 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 +51 -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 +17 -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/filesystem/__init__.py +85 -5
  16. hanzo_mcp/tools/filesystem/base.py +113 -0
  17. hanzo_mcp/tools/filesystem/content_replace.py +287 -0
  18. hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
  19. hanzo_mcp/tools/filesystem/edit_file.py +287 -0
  20. hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
  21. hanzo_mcp/tools/filesystem/read_files.py +198 -0
  22. hanzo_mcp/tools/filesystem/search_content.py +275 -0
  23. hanzo_mcp/tools/filesystem/write_file.py +162 -0
  24. hanzo_mcp/tools/jupyter/__init__.py +67 -4
  25. hanzo_mcp/tools/jupyter/base.py +284 -0
  26. hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
  27. hanzo_mcp/tools/jupyter/notebook_operations.py +72 -112
  28. hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
  29. hanzo_mcp/tools/project/__init__.py +64 -1
  30. hanzo_mcp/tools/project/analysis.py +9 -6
  31. hanzo_mcp/tools/project/base.py +66 -0
  32. hanzo_mcp/tools/project/project_analyze.py +173 -0
  33. hanzo_mcp/tools/shell/__init__.py +58 -1
  34. hanzo_mcp/tools/shell/base.py +148 -0
  35. hanzo_mcp/tools/shell/command_executor.py +203 -322
  36. hanzo_mcp/tools/shell/run_command.py +204 -0
  37. hanzo_mcp/tools/shell/run_script.py +215 -0
  38. hanzo_mcp/tools/shell/script_tool.py +244 -0
  39. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/METADATA +72 -77
  40. hanzo_mcp-0.1.30.dist-info/RECORD +45 -0
  41. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/licenses/LICENSE +2 -2
  42. hanzo_mcp/tools/common/thinking.py +0 -65
  43. hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
  44. hanzo_mcp-0.1.25.dist-info/RECORD +0 -24
  45. hanzo_mcp-0.1.25.dist-info/zip-safe +0 -1
  46. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/WHEEL +0 -0
  47. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/entry_points.txt +0 -0
  48. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.30.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,275 @@
1
+ """Search content tool implementation.
2
+
3
+ This module provides the SearchContentTool for finding text patterns in files.
4
+ """
5
+
6
+ import asyncio
7
+ import fnmatch
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, final, override
11
+
12
+ from mcp.server.fastmcp import Context as MCPContext
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
16
+
17
+
18
+ @final
19
+ class SearchContentTool(FilesystemBaseTool):
20
+ """Tool for searching for text patterns in files."""
21
+
22
+ @property
23
+ @override
24
+ def name(self) -> str:
25
+ """Get the tool name.
26
+
27
+ Returns:
28
+ Tool name
29
+ """
30
+ return "search_content"
31
+
32
+ @property
33
+ @override
34
+ def description(self) -> str:
35
+ """Get the tool description.
36
+
37
+ Returns:
38
+ Tool description
39
+ """
40
+ return """Search for a pattern in file contents.
41
+
42
+ Similar to grep, this tool searches for text patterns within files.
43
+ Searches recursively through all files in the specified directory
44
+ that match the file pattern. Returns matching lines with file and
45
+ line number references. Only searches within allowed directories."""
46
+
47
+ @property
48
+ @override
49
+ def parameters(self) -> dict[str, Any]:
50
+ """Get the parameter specifications for the tool.
51
+
52
+ Returns:
53
+ Parameter specifications
54
+ """
55
+ return {
56
+ "properties": {
57
+ "pattern": {
58
+ "type": "string",
59
+ "description": "text pattern to search for"
60
+ },
61
+ "path": {
62
+ "type": "string",
63
+ "description": "path to the directory or file to search"
64
+ },
65
+ "file_pattern": {
66
+ "default": "*",
67
+ "type": "string"
68
+ }
69
+ },
70
+ "required": ["pattern", "path"],
71
+ "title": "search_contentArguments",
72
+ "type": "object"
73
+ }
74
+
75
+ @property
76
+ @override
77
+ def required(self) -> list[str]:
78
+ """Get the list of required parameter names.
79
+
80
+ Returns:
81
+ List of required parameter names
82
+ """
83
+ return ["pattern", "path"]
84
+
85
+ @override
86
+ async def call(self, ctx: MCPContext, **params: Any) -> str:
87
+ """Execute the tool with the given parameters.
88
+
89
+ Args:
90
+ ctx: MCP context
91
+ **params: Tool parameters
92
+
93
+ Returns:
94
+ Tool result
95
+ """
96
+ tool_ctx = self.create_tool_context(ctx)
97
+
98
+ # Extract parameters
99
+ pattern = params.get("pattern")
100
+ path = params.get("path")
101
+ file_pattern = params.get("file_pattern", "*") # Default to all files
102
+
103
+ # Validate required parameters
104
+ if not pattern:
105
+ await tool_ctx.error("Parameter 'pattern' is required but was None")
106
+ return "Error: Parameter 'pattern' is required but was None"
107
+
108
+ if pattern.strip() == "":
109
+ await tool_ctx.error("Parameter 'pattern' cannot be empty")
110
+ return "Error: Parameter 'pattern' cannot be empty"
111
+
112
+ if not path:
113
+ await tool_ctx.error("Parameter 'path' is required but was None")
114
+ return "Error: Parameter 'path' is required but was None"
115
+
116
+ if path.strip() == "":
117
+ await tool_ctx.error("Parameter 'path' cannot be empty")
118
+ return "Error: Parameter 'path' cannot be empty"
119
+
120
+
121
+ path_validation = self.validate_path(path)
122
+ if path_validation.is_error:
123
+ await tool_ctx.error(path_validation.error_message)
124
+ return f"Error: {path_validation.error_message}"
125
+
126
+ # file_pattern can be None safely as it has a default value
127
+
128
+ await tool_ctx.info(
129
+ f"Searching for pattern '{pattern}' in files matching '{file_pattern}' in {path}"
130
+ )
131
+
132
+ # Check if path is allowed
133
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
134
+ if not allowed:
135
+ return error_msg
136
+
137
+ try:
138
+ input_path = Path(path)
139
+
140
+ # Check if path exists
141
+ exists, error_msg = await self.check_path_exists(path, tool_ctx)
142
+ if not exists:
143
+ return error_msg
144
+
145
+ # Find matching files using optimized file finding
146
+ matching_files: list[Path] = []
147
+
148
+ # Process based on whether path is a file or directory
149
+ if input_path.is_file():
150
+ # Single file search
151
+ if file_pattern == "*" or fnmatch.fnmatch(input_path.name, file_pattern):
152
+ matching_files.append(input_path)
153
+ await tool_ctx.info(f"Searching single file: {path}")
154
+ else:
155
+ await tool_ctx.info(f"File does not match pattern '{file_pattern}': {path}")
156
+ return f"File does not match pattern '{file_pattern}': {path}"
157
+ elif input_path.is_dir():
158
+ # Directory search - optimized file finding
159
+ await tool_ctx.info(f"Finding files in directory: {path}")
160
+
161
+ # Keep track of allowed paths for filtering
162
+ allowed_paths: set[str] = set()
163
+
164
+ # Collect all allowed paths first for faster filtering
165
+ for entry in input_path.rglob("*"):
166
+ entry_path = str(entry)
167
+ if self.is_path_allowed(entry_path):
168
+ allowed_paths.add(entry_path)
169
+
170
+ # Find matching files efficiently
171
+ for entry in input_path.rglob("*"):
172
+ entry_path = str(entry)
173
+ if entry_path in allowed_paths and entry.is_file():
174
+ if file_pattern == "*" or fnmatch.fnmatch(entry.name, file_pattern):
175
+ matching_files.append(entry)
176
+
177
+ await tool_ctx.info(f"Found {len(matching_files)} matching files")
178
+ else:
179
+ # This shouldn't happen since we already checked for existence
180
+ await tool_ctx.error(f"Path is neither a file nor a directory: {path}")
181
+ return f"Error: Path is neither a file nor a directory: {path}"
182
+
183
+ # Report progress
184
+ total_files = len(matching_files)
185
+ if input_path.is_file():
186
+ await tool_ctx.info(f"Searching file: {path}")
187
+ else:
188
+ await tool_ctx.info(f"Searching through {total_files} files in directory")
189
+
190
+ # set up for parallel processing
191
+ results: list[str] = []
192
+ files_processed = 0
193
+ matches_found = 0
194
+ batch_size = 20 # Process files in batches to avoid overwhelming the system
195
+
196
+ # Use a semaphore to limit concurrent file operations
197
+ # Adjust the value based on system resources
198
+ semaphore = asyncio.Semaphore(10)
199
+
200
+ # Create an async function to search a single file
201
+ async def search_file(file_path: Path) -> list[str]:
202
+ nonlocal files_processed, matches_found
203
+ file_results: list[str] = []
204
+
205
+ try:
206
+ async with semaphore: # Limit concurrent operations
207
+ try:
208
+ with open(file_path, "r", encoding="utf-8") as f:
209
+ for line_num, line in enumerate(f, 1):
210
+ if re.search(pattern, line):
211
+ file_results.append(
212
+ f"{file_path}:{line_num}: {line.rstrip()}"
213
+ )
214
+ matches_found += 1
215
+ files_processed += 1
216
+ except UnicodeDecodeError:
217
+ # Skip binary files
218
+ files_processed += 1
219
+ except Exception as e:
220
+ await tool_ctx.warning(f"Error reading {file_path}: {str(e)}")
221
+ except Exception as e:
222
+ await tool_ctx.warning(f"Error processing {file_path}: {str(e)}")
223
+
224
+ return file_results
225
+
226
+ # Process files in parallel batches
227
+ for i in range(0, len(matching_files), batch_size):
228
+ batch = matching_files[i:i+batch_size]
229
+ batch_tasks = [search_file(file_path) for file_path in batch]
230
+
231
+ # Report progress
232
+ await tool_ctx.report_progress(i, total_files)
233
+
234
+ # Wait for the batch to complete
235
+ batch_results = await asyncio.gather(*batch_tasks)
236
+
237
+ # Flatten and collect results
238
+ for file_result in batch_results:
239
+ results.extend(file_result)
240
+
241
+ # Final progress report
242
+ await tool_ctx.report_progress(total_files, total_files)
243
+
244
+ if not results:
245
+ if input_path.is_file():
246
+ return f"No matches found for pattern '{pattern}' in file: {path}"
247
+ else:
248
+ return f"No matches found for pattern '{pattern}' in files matching '{file_pattern}' in directory: {path}"
249
+
250
+ await tool_ctx.info(
251
+ f"Found {matches_found} matches in {files_processed} file{'s' if files_processed > 1 else ''}"
252
+ )
253
+ return (
254
+ f"Found {matches_found} matches in {files_processed} file{'s' if files_processed > 1 else ''}:\n\n"
255
+ + "\n".join(results)
256
+ )
257
+ except Exception as e:
258
+ await tool_ctx.error(f"Error searching file contents: {str(e)}")
259
+ return f"Error searching file contents: {str(e)}"
260
+
261
+ @override
262
+ def register(self, mcp_server: FastMCP) -> None:
263
+ """Register this search content tool with the MCP server.
264
+
265
+ Creates a wrapper function with explicitly defined parameters that match
266
+ the tool's parameter schema and registers it with the MCP server.
267
+
268
+ Args:
269
+ mcp_server: The FastMCP server instance
270
+ """
271
+ tool_self = self # Create a reference to self for use in the closure
272
+
273
+ @mcp_server.tool(name=self.name, description=self.mcp_description)
274
+ async def search_content(ctx: MCPContext, pattern: str, path: str, file_pattern: str = "*") -> str:
275
+ return await tool_self.call(ctx, pattern=pattern, path=path, file_pattern=file_pattern)
@@ -0,0 +1,162 @@
1
+ """Write file tool implementation.
2
+
3
+ This module provides the WriteFileTool for creating or overwriting files.
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 WriteFileTool(FilesystemBaseTool):
17
+ """Tool for writing file contents."""
18
+
19
+ @property
20
+ @override
21
+ def name(self) -> str:
22
+ """Get the tool name.
23
+
24
+ Returns:
25
+ Tool name
26
+ """
27
+ return "write_file"
28
+
29
+ @property
30
+ @override
31
+ def description(self) -> str:
32
+ """Get the tool description.
33
+
34
+ Returns:
35
+ Tool description
36
+ """
37
+ return """Create a new file or completely overwrite an existing file with new content.
38
+
39
+ Use with caution as it will overwrite existing files without warning.
40
+ Handles text content with proper encoding. Only works within allowed directories."""
41
+
42
+ @property
43
+ @override
44
+ def parameters(self) -> dict[str, Any]:
45
+ """Get the parameter specifications for the tool.
46
+
47
+ Returns:
48
+ Parameter specifications
49
+ """
50
+ return {
51
+ "properties": {
52
+ "path": {
53
+ "type": "string",
54
+ "description": "path to the file to write"
55
+ },
56
+ "content": {
57
+ "type": "string",
58
+ "description": "content to write to the file"
59
+ }
60
+ },
61
+ "required": ["path", "content"],
62
+ "type": "object"
63
+ }
64
+
65
+ @property
66
+ @override
67
+ def required(self) -> list[str]:
68
+ """Get the list of required parameter names.
69
+
70
+ Returns:
71
+ List of required parameter names
72
+ """
73
+ return ["path", "content"]
74
+
75
+ @override
76
+ async def call(self, ctx: MCPContext, **params: Any) -> str:
77
+ """Execute the tool with the given parameters.
78
+
79
+ Args:
80
+ ctx: MCP context
81
+ **params: Tool parameters
82
+
83
+ Returns:
84
+ Tool result
85
+ """
86
+ tool_ctx = self.create_tool_context(ctx)
87
+ self.set_tool_context_info(tool_ctx)
88
+
89
+ # Extract parameters
90
+ path = params.get("path")
91
+ content = params.get("content")
92
+
93
+ if not path:
94
+ await tool_ctx.error("Parameter 'path' is required but was None")
95
+ return "Error: Parameter 'path' is required but was None"
96
+
97
+ if path.strip() == "":
98
+ await tool_ctx.error("Parameter 'path' cannot be empty")
99
+ return "Error: Parameter 'path' cannot be empty"
100
+
101
+ # Validate parameters
102
+ path_validation = self.validate_path(path)
103
+ if path_validation.is_error:
104
+ await tool_ctx.error(path_validation.error_message)
105
+ return f"Error: {path_validation.error_message}"
106
+
107
+ if not content:
108
+ await tool_ctx.error("Parameter 'content' is required but was None")
109
+ return "Error: Parameter 'content' is required but was None"
110
+
111
+ await tool_ctx.info(f"Writing file: {path}")
112
+
113
+ # Check if file is allowed to be written
114
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
115
+ if not allowed:
116
+ return error_msg
117
+
118
+ # Additional check already verified by is_path_allowed above
119
+ await tool_ctx.info(f"Writing file: {path}")
120
+
121
+ try:
122
+ file_path = Path(path)
123
+
124
+ # Check if parent directory is allowed
125
+ parent_dir = str(file_path.parent)
126
+ if not self.is_path_allowed(parent_dir):
127
+ await tool_ctx.error(f"Parent directory not allowed: {parent_dir}")
128
+ return f"Error: Parent directory not allowed: {parent_dir}"
129
+
130
+ # Create parent directories if they don't exist
131
+ file_path.parent.mkdir(parents=True, exist_ok=True)
132
+
133
+ # Write the file
134
+ with open(file_path, "w", encoding="utf-8") as f:
135
+ f.write(content)
136
+
137
+ # Add to document context
138
+ self.document_context.add_document(path, content)
139
+
140
+ await tool_ctx.info(
141
+ f"Successfully wrote file: {path} ({len(content)} bytes)"
142
+ )
143
+ return f"Successfully wrote file: {path} ({len(content)} bytes)"
144
+ except Exception as e:
145
+ await tool_ctx.error(f"Error writing file: {str(e)}")
146
+ return f"Error writing file: {str(e)}"
147
+
148
+ @override
149
+ def register(self, mcp_server: FastMCP) -> None:
150
+ """Register this tool with the MCP server.
151
+
152
+ Creates a wrapper function with explicitly defined parameters that match
153
+ the tool's parameter schema and registers it with the MCP server.
154
+
155
+ Args:
156
+ mcp_server: The FastMCP server instance
157
+ """
158
+ tool_self = self # Create a reference to self for use in the closure
159
+
160
+ @mcp_server.tool(name=self.name, description=self.mcp_description)
161
+ async def write_file(path: str, content: str, ctx: MCPContext) -> str:
162
+ return await tool_self.call(ctx, path=path, content=content)
@@ -1,8 +1,71 @@
1
- """Jupyter notebook tools for Hanzo MCP.
1
+ """Jupyter notebook tools package for Hanzo MCP.
2
2
 
3
- This module provides tools for reading and editing Jupyter notebooks (.ipynb files).
3
+ This package provides tools for working with Jupyter notebooks (.ipynb files),
4
+ including reading and editing notebook cells.
4
5
  """
5
6
 
6
- from hanzo_mcp.tools.jupyter.notebook_operations import JupyterNotebookTools
7
+ from mcp.server.fastmcp import FastMCP
7
8
 
8
- __all__ = ["JupyterNotebookTools"]
9
+ from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
10
+ from hanzo_mcp.tools.common.context import DocumentContext
11
+ from hanzo_mcp.tools.common.permissions import PermissionManager
12
+ from hanzo_mcp.tools.jupyter.edit_notebook import EditNotebookTool
13
+ from hanzo_mcp.tools.jupyter.read_notebook import ReadNotebookTool
14
+
15
+ # Export all tool classes
16
+ __all__ = [
17
+ "ReadNotebookTool",
18
+ "EditNotebookTool",
19
+ "get_jupyter_tools",
20
+ "register_jupyter_tools",
21
+ ]
22
+
23
+ def get_read_only_jupyter_tools(
24
+ document_context: DocumentContext, permission_manager: PermissionManager
25
+ ) -> list[BaseTool]:
26
+ """Create instances of read only Jupyter notebook tools.
27
+
28
+ Args:
29
+ document_context: Document context for tracking file contents
30
+ permission_manager: Permission manager for access control
31
+
32
+ Returns:
33
+ List of Jupyter notebook tool instances
34
+ """
35
+ return [
36
+ ReadNotebookTool(document_context, permission_manager),
37
+ ]
38
+
39
+
40
+ def get_jupyter_tools(
41
+ document_context: DocumentContext, permission_manager: PermissionManager
42
+ ) -> list[BaseTool]:
43
+ """Create instances of all Jupyter notebook tools.
44
+
45
+ Args:
46
+ document_context: Document context for tracking file contents
47
+ permission_manager: Permission manager for access control
48
+
49
+ Returns:
50
+ List of Jupyter notebook tool instances
51
+ """
52
+ return [
53
+ ReadNotebookTool(document_context, permission_manager),
54
+ EditNotebookTool(document_context, permission_manager),
55
+ ]
56
+
57
+
58
+ def register_jupyter_tools(
59
+ mcp_server: FastMCP,
60
+ document_context: DocumentContext,
61
+ permission_manager: PermissionManager,
62
+ ) -> None:
63
+ """Register all Jupyter notebook tools with the MCP server.
64
+
65
+ Args:
66
+ mcp_server: The FastMCP server instance
67
+ document_context: Document context for tracking file contents
68
+ permission_manager: Permission manager for access control
69
+ """
70
+ tools = get_jupyter_tools(document_context, permission_manager)
71
+ ToolRegistry.register_tools(mcp_server, tools)