hanzo-tools-fs 0.3.0__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanzo-tools-fs
3
+ Version: 0.3.0
4
+ Summary: Filesystem tools for Hanzo AI - read, write, edit, search, find
5
+ Author-email: Hanzo Industries Inc <dev@hanzo.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hanzoai/python-sdk
8
+ Keywords: hanzo,tools,filesystem,mcp,ai
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: aiofiles>=24.1.0
15
+ Requires-Dist: grep-ast>=0.8.1
16
+ Requires-Dist: ffind>=1.3.0
17
+ Requires-Dist: watchdog>=6.0.0
18
+ Requires-Dist: mcp>=1.25.0
19
+ Requires-Dist: fastmcp>=2.14.1
20
+ Requires-Dist: pydantic>=2.12.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
23
+ Requires-Dist: ruff>=0.14.0; extra == "dev"
@@ -0,0 +1,3 @@
1
+ """Hanzo Tools namespace package."""
2
+
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,136 @@
1
+ """Filesystem tools for Hanzo AI.
2
+
3
+ Tools:
4
+ - read: Read file contents
5
+ - write: Write/create files
6
+ - edit: Edit files with find/replace
7
+ - multi_edit: Multiple edits in one operation
8
+ - tree: Directory tree view
9
+ - find: Find files by pattern
10
+ - search: Search file contents
11
+ - ast: AST-based code search
12
+ - rules: Read project rules/config
13
+
14
+ Install:
15
+ pip install hanzo-tools-fs
16
+
17
+ Usage:
18
+ from hanzo_tools.fs import register_tools, TOOLS
19
+
20
+ # Register with MCP server
21
+ register_tools(mcp_server, permission_manager)
22
+
23
+ # Or access individual tools
24
+ from hanzo_tools.fs import ReadTool, WriteTool
25
+ """
26
+
27
+ from hanzo_tools.fs.ast import ASTTool
28
+ from hanzo_tools.fs.edit import EditTool
29
+ from hanzo_tools.fs.find import FindTool
30
+ from hanzo_tools.fs.read import ReadTool
31
+ from hanzo_tools.fs.tree import TreeTool
32
+ from hanzo_tools.fs.write import WriteTool
33
+ from hanzo_tools.fs.search import SearchTool
34
+
35
+ # Backwards compatibility aliases
36
+ Edit = EditTool
37
+ Read = ReadTool
38
+ Write = WriteTool
39
+
40
+ # Read-only tools (for agent sandboxing)
41
+ READ_ONLY_TOOLS = [
42
+ ReadTool,
43
+ TreeTool,
44
+ FindTool,
45
+ SearchTool,
46
+ ASTTool,
47
+ ]
48
+
49
+ # Export list for tool discovery
50
+ TOOLS = [
51
+ ReadTool,
52
+ WriteTool,
53
+ EditTool,
54
+ TreeTool,
55
+ FindTool,
56
+ SearchTool,
57
+ ASTTool,
58
+ ]
59
+
60
+ __all__ = [
61
+ # Tool classes
62
+ "ReadTool",
63
+ "WriteTool",
64
+ "EditTool",
65
+ "TreeTool",
66
+ "FindTool",
67
+ "SearchTool",
68
+ "ASTTool",
69
+ # Aliases
70
+ "Edit",
71
+ "Read",
72
+ "Write",
73
+ # Registration
74
+ "register_tools",
75
+ "get_read_only_filesystem_tools",
76
+ "TOOLS",
77
+ "READ_ONLY_TOOLS",
78
+ ]
79
+
80
+
81
+ def get_read_only_filesystem_tools(permission_manager) -> list:
82
+ """Get read-only filesystem tools for sandboxed agents.
83
+
84
+ Returns tools that can only read files, not modify them:
85
+ - read: Read file contents
86
+ - tree: View directory structure
87
+ - find: Find files by pattern
88
+ - search: Search file contents
89
+ - ast: Code structure analysis
90
+
91
+ Args:
92
+ permission_manager: PermissionManager instance
93
+
94
+ Returns:
95
+ List of instantiated read-only tools
96
+ """
97
+ tools = []
98
+ for tool_class in READ_ONLY_TOOLS:
99
+ try:
100
+ tools.append(tool_class(permission_manager))
101
+ except TypeError:
102
+ tools.append(tool_class())
103
+ return tools
104
+
105
+
106
+ def register_tools(mcp_server, permission_manager, enabled_tools: dict[str, bool] | None = None):
107
+ """Register all filesystem tools with the MCP server.
108
+
109
+ Args:
110
+ mcp_server: FastMCP server instance
111
+ permission_manager: PermissionManager for access control
112
+ enabled_tools: Dict of tool_name -> enabled state
113
+
114
+ Returns:
115
+ List of registered tool instances
116
+ """
117
+ from hanzo_tools.core import ToolRegistry
118
+
119
+ enabled = enabled_tools or {}
120
+ registered = []
121
+
122
+ for tool_class in TOOLS:
123
+ tool_name = tool_class.name if hasattr(tool_class, "name") else tool_class.__name__.lower()
124
+
125
+ if enabled.get(tool_name, True): # Enabled by default
126
+ try:
127
+ tool = tool_class(permission_manager)
128
+ ToolRegistry.register_tool(mcp_server, tool)
129
+ registered.append(tool)
130
+ except TypeError:
131
+ # Tool doesn't need permission_manager
132
+ tool = tool_class()
133
+ ToolRegistry.register_tool(mcp_server, tool)
134
+ registered.append(tool)
135
+
136
+ return registered
@@ -0,0 +1,291 @@
1
+ """AST-based code structure search using tree-sitter.
2
+
3
+ This module provides the ASTTool for searching, indexing, and querying code symbols
4
+ using tree-sitter AST parsing. It can find function definitions, class declarations,
5
+ and other code structures with full context.
6
+ """
7
+
8
+ import os
9
+ from typing import Unpack, Annotated, TypedDict, final, override
10
+ from pathlib import Path
11
+
12
+ from pydantic import Field
13
+ from mcp.server import FastMCP
14
+ from mcp.server.fastmcp import Context as MCPContext
15
+
16
+ from hanzo_tools.core import BaseTool
17
+
18
+ # Lazy import for grep_ast
19
+ _tree_context_cls = None
20
+
21
+
22
+ def _get_tree_context():
23
+ """Lazy load TreeContext to avoid import-time overhead."""
24
+ global _tree_context_cls
25
+ if _tree_context_cls is None:
26
+ from grep_ast.grep_ast import TreeContext
27
+
28
+ _tree_context_cls = TreeContext
29
+ return _tree_context_cls
30
+
31
+
32
+ # Type annotations for parameters
33
+ Pattern = Annotated[
34
+ str,
35
+ Field(
36
+ description="The regex pattern to search for in source code files",
37
+ min_length=1,
38
+ ),
39
+ ]
40
+
41
+ SearchPath = Annotated[
42
+ str,
43
+ Field(
44
+ description="The path to search in (file or directory)",
45
+ min_length=1,
46
+ ),
47
+ ]
48
+
49
+ IgnoreCase = Annotated[
50
+ bool,
51
+ Field(
52
+ description="Whether to ignore case when matching",
53
+ default=False,
54
+ ),
55
+ ]
56
+
57
+ LineNumber = Annotated[
58
+ bool,
59
+ Field(
60
+ description="Whether to display line numbers",
61
+ default=False,
62
+ ),
63
+ ]
64
+
65
+
66
+ class ASTToolParams(TypedDict, total=False):
67
+ """Parameters for the ASTTool.
68
+
69
+ Attributes:
70
+ pattern: The regex pattern to search for in source code files
71
+ path: The path to search in (file or directory)
72
+ ignore_case: Whether to ignore case when matching
73
+ line_number: Whether to display line numbers
74
+ """
75
+
76
+ pattern: Pattern
77
+ path: SearchPath
78
+ ignore_case: IgnoreCase
79
+ line_number: LineNumber
80
+
81
+
82
+ # Extensions supported by tree-sitter (common programming languages)
83
+ SUPPORTED_EXTENSIONS = {
84
+ ".py",
85
+ ".pyw", # Python
86
+ ".js",
87
+ ".jsx",
88
+ ".mjs",
89
+ ".cjs", # JavaScript
90
+ ".ts",
91
+ ".tsx",
92
+ ".mts",
93
+ ".cts", # TypeScript
94
+ ".go", # Go
95
+ ".rs", # Rust
96
+ ".c",
97
+ ".h", # C
98
+ ".cpp",
99
+ ".cc",
100
+ ".cxx",
101
+ ".hpp",
102
+ ".hh",
103
+ ".hxx", # C++
104
+ ".java", # Java
105
+ ".rb", # Ruby
106
+ ".php", # PHP
107
+ ".cs", # C#
108
+ ".swift", # Swift
109
+ ".kt",
110
+ ".kts", # Kotlin
111
+ ".scala", # Scala
112
+ ".lua", # Lua
113
+ ".r",
114
+ ".R", # R
115
+ ".jl", # Julia
116
+ ".ex",
117
+ ".exs", # Elixir
118
+ ".erl",
119
+ ".hrl", # Erlang
120
+ ".ml",
121
+ ".mli", # OCaml
122
+ ".hs", # Haskell
123
+ ".elm", # Elm
124
+ ".vue", # Vue
125
+ ".svelte", # Svelte
126
+ }
127
+
128
+
129
+ @final
130
+ class ASTTool(BaseTool):
131
+ """Tool for searching and querying code structures using tree-sitter AST parsing."""
132
+
133
+ name = "ast"
134
+
135
+ @property
136
+ @override
137
+ def description(self) -> str:
138
+ """Get the tool description.
139
+
140
+ Returns:
141
+ Tool description
142
+ """
143
+ return """AST-based code structure search using tree-sitter. Find functions, classes, methods with full context.
144
+
145
+ Usage:
146
+ ast "function_name" ./src
147
+ ast "class.*Service" ./src
148
+ ast "def test_" ./tests
149
+
150
+ Searches code structure intelligently, understanding syntax and providing semantic context."""
151
+
152
+ def _is_supported_file(self, path: str) -> bool:
153
+ """Check if file has a supported extension for tree-sitter parsing."""
154
+ return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
155
+
156
+ @override
157
+ async def call(
158
+ self,
159
+ ctx: MCPContext,
160
+ **params: Unpack[ASTToolParams],
161
+ ) -> str:
162
+ """Execute the tool with the given parameters.
163
+
164
+ Args:
165
+ ctx: MCP context
166
+ **params: Tool parameters
167
+
168
+ Returns:
169
+ Tool result
170
+ """
171
+ # Extract parameters
172
+ pattern: str = params["pattern"]
173
+ path: str = params["path"]
174
+ ignore_case = params.get("ignore_case", False)
175
+ line_number = params.get("line_number", False)
176
+
177
+ # Expand ~ in path
178
+ path = os.path.expanduser(path)
179
+
180
+ # Check if path exists
181
+ path_obj = Path(path)
182
+ if not path_obj.exists():
183
+ return f"Error: Path does not exist: {path}"
184
+
185
+ # Get the files to process
186
+ files_to_process = []
187
+
188
+ if path_obj.is_file():
189
+ if self._is_supported_file(str(path_obj)):
190
+ files_to_process.append(str(path_obj))
191
+ else:
192
+ return f"Error: File type not supported for AST parsing: {path_obj.suffix}"
193
+ elif path_obj.is_dir():
194
+ for root, _, files in os.walk(path_obj):
195
+ # Skip hidden directories and common non-code directories
196
+ root_path = Path(root)
197
+ if any(part.startswith(".") for part in root_path.parts):
198
+ continue
199
+ if any(
200
+ part in ("node_modules", "__pycache__", "venv", ".venv", "dist", "build")
201
+ for part in root_path.parts
202
+ ):
203
+ continue
204
+
205
+ for file in files:
206
+ file_path = Path(root) / file
207
+ if self._is_supported_file(str(file_path)):
208
+ files_to_process.append(str(file_path))
209
+
210
+ if not files_to_process:
211
+ return f"No source code files found in {path}"
212
+
213
+ # Get TreeContext class
214
+ TreeContext = _get_tree_context()
215
+
216
+ # Process each file
217
+ results = []
218
+ errors = []
219
+
220
+ for file_path in files_to_process:
221
+ try:
222
+ # Read the file
223
+ with open(file_path, "r", encoding="utf-8") as f:
224
+ code = f.read()
225
+
226
+ # Process the file with grep-ast
227
+ try:
228
+ tc = TreeContext(
229
+ file_path,
230
+ code,
231
+ color=False,
232
+ verbose=False,
233
+ line_number=line_number,
234
+ )
235
+
236
+ # Find matches
237
+ loi = tc.grep(pattern, ignore_case)
238
+
239
+ if loi:
240
+ tc.add_lines_of_interest(loi)
241
+ tc.add_context()
242
+ output = tc.format()
243
+
244
+ # Add the result to our list
245
+ results.append(f"\n{file_path}:\n{output}\n")
246
+ except Exception as e:
247
+ # Skip files that can't be parsed by tree-sitter
248
+ errors.append(f"Could not parse {file_path}: {str(e)}")
249
+ except UnicodeDecodeError:
250
+ errors.append(f"Could not read {file_path} as text")
251
+ except Exception as e:
252
+ errors.append(f"Error processing {file_path}: {str(e)}")
253
+
254
+ if not results:
255
+ error_info = ""
256
+ if errors:
257
+ error_info = f"\n\nErrors encountered:\n" + "\n".join(errors[:5])
258
+ if len(errors) > 5:
259
+ error_info += f"\n... and {len(errors) - 5} more errors"
260
+ return f"No matches found for '{pattern}' in {path}{error_info}"
261
+
262
+ summary = f"Found matches in {len(results)} file(s) (searched {len(files_to_process)} files)"
263
+ return summary + "\n" + "".join(results)
264
+
265
+ @override
266
+ def register(self, mcp_server: FastMCP) -> None:
267
+ """Register this tool with the MCP server.
268
+
269
+ Creates a wrapper function with explicitly defined parameters that match
270
+ the tool's parameter schema and registers it with the MCP server.
271
+
272
+ Args:
273
+ mcp_server: The FastMCP server instance
274
+ """
275
+ tool_self = self # Create a reference to self for use in the closure
276
+
277
+ @mcp_server.tool(name=self.name, description=self.description)
278
+ async def ast(
279
+ ctx: MCPContext,
280
+ pattern: Pattern,
281
+ path: SearchPath,
282
+ ignore_case: IgnoreCase = False,
283
+ line_number: LineNumber = False,
284
+ ) -> str:
285
+ return await tool_self.call(
286
+ ctx,
287
+ pattern=pattern,
288
+ path=path,
289
+ ignore_case=ignore_case,
290
+ line_number=line_number,
291
+ )
@@ -0,0 +1,108 @@
1
+ """Edit tool - find and replace in files."""
2
+
3
+ from typing import Optional, Annotated
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from mcp.server import FastMCP
8
+ from mcp.server.fastmcp import Context as MCPContext
9
+
10
+ from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
11
+
12
+
13
+ class EditTool(FileSystemTool):
14
+ """Edit files with find and replace."""
15
+
16
+ name = "edit"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return """Edit a file by replacing text.
21
+
22
+ Args:
23
+ file_path: Absolute path to the file
24
+ old_string: Text to find (must be unique)
25
+ new_string: Text to replace with
26
+ expected_replacements: Expected number of replacements (default 1)
27
+
28
+ Returns:
29
+ Success message with diff or error
30
+ """
31
+
32
+ def __init__(self, permission_manager: Optional[PermissionManager] = None):
33
+ super().__init__(permission_manager)
34
+
35
+ @auto_timeout("edit")
36
+ async def call(
37
+ self,
38
+ ctx: MCPContext,
39
+ file_path: str,
40
+ old_string: str,
41
+ new_string: str,
42
+ expected_replacements: int = 1,
43
+ **kwargs,
44
+ ) -> str:
45
+ """Edit file with find/replace."""
46
+ validation = self.validate_path(file_path)
47
+ if not validation:
48
+ return validation.error_message
49
+
50
+ if not self.is_path_allowed(file_path):
51
+ return f"Error: Access denied to path: {file_path}"
52
+
53
+ path = Path(file_path)
54
+
55
+ if not path.exists():
56
+ return f"Error: File does not exist: {file_path}"
57
+
58
+ if old_string == new_string:
59
+ return "Error: old_string and new_string are identical"
60
+
61
+ try:
62
+ with open(path, "r", encoding="utf-8") as f:
63
+ content = f.read()
64
+
65
+ # Count occurrences
66
+ count = content.count(old_string)
67
+
68
+ if count == 0:
69
+ return f"Error: old_string not found in file"
70
+
71
+ if count != expected_replacements:
72
+ return (
73
+ f"Error: Found {count} occurrences of old_string, "
74
+ f"expected {expected_replacements}. "
75
+ f"Use expected_replacements={count} or make old_string more specific."
76
+ )
77
+
78
+ # Perform replacement
79
+ new_content = content.replace(old_string, new_string, expected_replacements)
80
+
81
+ with open(path, "w", encoding="utf-8") as f:
82
+ f.write(new_content)
83
+
84
+ return f"Successfully edited {file_path}\nReplaced {count} occurrence(s)"
85
+
86
+ except Exception as e:
87
+ return f"Error editing file: {e}"
88
+
89
+ def register(self, mcp_server: FastMCP) -> None:
90
+ """Register with MCP server."""
91
+ tool_instance = self
92
+
93
+ @mcp_server.tool()
94
+ async def edit(
95
+ file_path: Annotated[str, Field(description="Absolute path to the file")],
96
+ old_string: Annotated[str, Field(description="Text to find")],
97
+ new_string: Annotated[str, Field(description="Text to replace with")],
98
+ expected_replacements: Annotated[int, Field(description="Expected replacements")] = 1,
99
+ ctx: MCPContext = None,
100
+ ) -> str:
101
+ """Edit a file by replacing text."""
102
+ return await tool_instance.call(
103
+ ctx,
104
+ file_path=file_path,
105
+ old_string=old_string,
106
+ new_string=new_string,
107
+ expected_replacements=expected_replacements,
108
+ )
@@ -0,0 +1,112 @@
1
+ """Find tool - find files by pattern."""
2
+
3
+ import fnmatch
4
+ from typing import Optional, Annotated
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+ from mcp.server import FastMCP
9
+ from mcp.server.fastmcp import Context as MCPContext
10
+
11
+ from hanzo_tools.core import BaseTool, auto_timeout
12
+
13
+
14
+ class FindTool(BaseTool):
15
+ """Find files by name pattern."""
16
+
17
+ name = "find"
18
+
19
+ @property
20
+ def description(self) -> str:
21
+ return """Find files and directories by name pattern.
22
+
23
+ Args:
24
+ pattern: Glob pattern (e.g., "*.py", "test_*")
25
+ path: Directory to search in (default: current dir)
26
+ type: "file", "dir", or None for both
27
+ max_results: Maximum results to return (default 100)
28
+
29
+ Returns:
30
+ List of matching paths
31
+ """
32
+
33
+ IGNORED_DIRS = {
34
+ ".git",
35
+ "__pycache__",
36
+ "node_modules",
37
+ ".venv",
38
+ "venv",
39
+ ".idea",
40
+ ".vscode",
41
+ ".mypy_cache",
42
+ ".pytest_cache",
43
+ }
44
+
45
+ @auto_timeout("find")
46
+ async def call(
47
+ self,
48
+ ctx: MCPContext,
49
+ pattern: str,
50
+ path: str = ".",
51
+ type: Optional[str] = None,
52
+ max_results: int = 100,
53
+ **kwargs,
54
+ ) -> str:
55
+ """Find files matching pattern."""
56
+ root = Path(path).resolve()
57
+
58
+ if not root.exists():
59
+ return f"Error: Path does not exist: {path}"
60
+
61
+ matches = []
62
+
63
+ def should_skip(p: Path) -> bool:
64
+ return any(part in self.IGNORED_DIRS for part in p.parts)
65
+
66
+ for item in root.rglob("*"):
67
+ if len(matches) >= max_results:
68
+ break
69
+
70
+ if should_skip(item):
71
+ continue
72
+
73
+ # Check type filter
74
+ if type == "file" and not item.is_file():
75
+ continue
76
+ if type == "dir" and not item.is_dir():
77
+ continue
78
+
79
+ # Check pattern match
80
+ if fnmatch.fnmatch(item.name, pattern):
81
+ try:
82
+ rel_path = item.relative_to(root)
83
+ suffix = "/" if item.is_dir() else ""
84
+ matches.append(f"{rel_path}{suffix}")
85
+ except ValueError:
86
+ matches.append(str(item))
87
+
88
+ if not matches:
89
+ return f"No matches found for pattern: {pattern}"
90
+
91
+ result = f"Found {len(matches)} matches:\n\n"
92
+ result += "\n".join(matches)
93
+
94
+ if len(matches) >= max_results:
95
+ result += f"\n\n[Truncated at {max_results} results]"
96
+
97
+ return result
98
+
99
+ def register(self, mcp_server: FastMCP) -> None:
100
+ """Register with MCP server."""
101
+ tool_instance = self
102
+
103
+ @mcp_server.tool()
104
+ async def find(
105
+ pattern: Annotated[str, Field(description="Glob pattern")],
106
+ path: Annotated[str, Field(description="Directory to search")] = ".",
107
+ type: Annotated[Optional[str], Field(description="file, dir, or None")] = None,
108
+ max_results: Annotated[int, Field(description="Max results")] = 100,
109
+ ctx: MCPContext = None,
110
+ ) -> str:
111
+ """Find files and directories by pattern."""
112
+ return await tool_instance.call(ctx, pattern=pattern, path=path, type=type, max_results=max_results)
@@ -0,0 +1,100 @@
1
+ """Read tool - read file contents."""
2
+
3
+ from typing import Any, Optional, Annotated
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from mcp.server import FastMCP
8
+ from mcp.server.fastmcp import Context as MCPContext
9
+
10
+ from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
11
+
12
+
13
+ class ReadTool(FileSystemTool):
14
+ """Read file contents with line numbers."""
15
+
16
+ name = "read"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return """Read file contents with line numbers.
21
+
22
+ Args:
23
+ file_path: Absolute path to the file
24
+ offset: Starting line (0-based, optional)
25
+ limit: Max lines to read (optional, default 2000)
26
+
27
+ Returns:
28
+ File contents with line numbers
29
+ """
30
+
31
+ def __init__(self, permission_manager: Optional[PermissionManager] = None):
32
+ super().__init__(permission_manager)
33
+
34
+ @auto_timeout("read")
35
+ async def call(
36
+ self,
37
+ ctx: MCPContext,
38
+ file_path: str,
39
+ offset: int = 0,
40
+ limit: int = 2000,
41
+ **kwargs,
42
+ ) -> str:
43
+ """Read file contents."""
44
+ # Validate path
45
+ validation = self.validate_path(file_path)
46
+ if not validation:
47
+ return validation.error_message
48
+
49
+ if not self.is_path_allowed(file_path):
50
+ return f"Error: Access denied to path: {file_path}"
51
+
52
+ path = Path(file_path)
53
+
54
+ if not path.exists():
55
+ return f"Error: File does not exist: {file_path}"
56
+
57
+ if not path.is_file():
58
+ return f"Error: Not a file: {file_path}"
59
+
60
+ try:
61
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
62
+ lines = f.readlines()
63
+
64
+ # Apply offset and limit
65
+ total_lines = len(lines)
66
+ selected = lines[offset : offset + limit]
67
+
68
+ # Format with line numbers
69
+ output_lines = []
70
+ for i, line in enumerate(selected, start=offset + 1):
71
+ line = line.rstrip("\n\r")
72
+ # Truncate long lines
73
+ if len(line) > 2000:
74
+ line = line[:2000] + "..."
75
+ output_lines.append(f"{i:6}→{line}")
76
+
77
+ result = "\n".join(output_lines)
78
+
79
+ # Add info if truncated
80
+ if offset > 0 or offset + limit < total_lines:
81
+ result += f"\n\n[Showing lines {offset + 1}-{min(offset + limit, total_lines)} of {total_lines}]"
82
+
83
+ return result
84
+
85
+ except Exception as e:
86
+ return f"Error reading file: {e}"
87
+
88
+ def register(self, mcp_server: FastMCP) -> None:
89
+ """Register with MCP server."""
90
+ tool_instance = self
91
+
92
+ @mcp_server.tool()
93
+ async def read(
94
+ file_path: Annotated[str, Field(description="Absolute path to the file")],
95
+ offset: Annotated[int, Field(description="Starting line (0-based)")] = 0,
96
+ limit: Annotated[int, Field(description="Max lines to read")] = 2000,
97
+ ctx: MCPContext = None,
98
+ ) -> str:
99
+ """Read file contents with line numbers."""
100
+ return await tool_instance.call(ctx, file_path=file_path, offset=offset, limit=limit)
@@ -0,0 +1,195 @@
1
+ """Search tool - search file contents."""
2
+
3
+ import re
4
+ import asyncio
5
+ from typing import Optional, Annotated
6
+ from pathlib import Path
7
+
8
+ import aiofiles
9
+ from pydantic import Field
10
+ from mcp.server import FastMCP
11
+ from mcp.server.fastmcp import Context as MCPContext
12
+
13
+ from hanzo_tools.core import BaseTool, auto_timeout
14
+
15
+
16
+ class SearchTool(BaseTool):
17
+ """Search file contents using regex."""
18
+
19
+ name = "search"
20
+
21
+ @property
22
+ def description(self) -> str:
23
+ return """Search for patterns in file contents.
24
+
25
+ Uses ripgrep (rg) if available, falls back to Python regex.
26
+
27
+ Args:
28
+ pattern: Regex pattern to search for
29
+ path: Directory or file to search (default: current dir)
30
+ include: Glob pattern to filter files (e.g., "*.py")
31
+ context_lines: Lines of context around matches
32
+ max_results: Maximum results (default 50)
33
+
34
+ Returns:
35
+ Matching lines with file paths and line numbers
36
+ """
37
+
38
+ @auto_timeout("search")
39
+ async def call(
40
+ self,
41
+ ctx: MCPContext,
42
+ pattern: str,
43
+ path: str = ".",
44
+ include: Optional[str] = None,
45
+ context_lines: int = 2,
46
+ max_results: int = 50,
47
+ **kwargs,
48
+ ) -> str:
49
+ """Search for pattern in files."""
50
+ root = Path(path).resolve()
51
+
52
+ if not root.exists():
53
+ return f"Error: Path does not exist: {path}"
54
+
55
+ # Try ripgrep first (much faster)
56
+ try:
57
+ result = await self._search_with_rg(pattern, root, include, context_lines, max_results)
58
+ if result is not None:
59
+ return result
60
+ except Exception:
61
+ pass
62
+
63
+ # Fallback to Python
64
+ return await self._search_with_python(pattern, root, include, context_lines, max_results)
65
+
66
+ async def _search_with_rg(
67
+ self,
68
+ pattern: str,
69
+ root: Path,
70
+ include: Optional[str],
71
+ context_lines: int,
72
+ max_results: int,
73
+ ) -> Optional[str]:
74
+ """Search using ripgrep (async)."""
75
+ cmd = [
76
+ "rg",
77
+ "--line-number",
78
+ "--color=never",
79
+ f"--max-count={max_results}",
80
+ ]
81
+
82
+ if context_lines > 0:
83
+ cmd.append(f"-C{context_lines}")
84
+
85
+ if include:
86
+ cmd.extend(["--glob", include])
87
+
88
+ cmd.extend([pattern, str(root)])
89
+
90
+ try:
91
+ process = await asyncio.create_subprocess_exec(
92
+ *cmd,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ )
96
+
97
+ try:
98
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
99
+ except asyncio.TimeoutError:
100
+ process.kill()
101
+ await process.wait()
102
+ return None
103
+
104
+ if process.returncode == 0:
105
+ return stdout.decode("utf-8", errors="replace") or "No matches found"
106
+ elif process.returncode == 1:
107
+ return "No matches found"
108
+ else:
109
+ return None # Fall back to Python
110
+ except FileNotFoundError:
111
+ return None
112
+
113
+ async def _search_with_python(
114
+ self,
115
+ pattern: str,
116
+ root: Path,
117
+ include: Optional[str],
118
+ context_lines: int,
119
+ max_results: int,
120
+ ) -> str:
121
+ """Search using Python regex (async file I/O)."""
122
+ import fnmatch
123
+
124
+ try:
125
+ regex = re.compile(pattern)
126
+ except re.error as e:
127
+ return f"Invalid regex pattern: {e}"
128
+
129
+ matches = []
130
+
131
+ # Find files to search
132
+ if root.is_file():
133
+ files = [root]
134
+ else:
135
+ files = list(root.rglob("*"))
136
+
137
+ for file_path in files:
138
+ if not file_path.is_file():
139
+ continue
140
+
141
+ if include and not fnmatch.fnmatch(file_path.name, include):
142
+ continue
143
+
144
+ try:
145
+ async with aiofiles.open(file_path, "r", encoding="utf-8", errors="ignore") as f:
146
+ content = await f.read()
147
+ lines = content.splitlines()
148
+
149
+ for i, line in enumerate(lines, 1):
150
+ if regex.search(line):
151
+ rel_path = file_path.relative_to(root)
152
+ matches.append(f"{rel_path}:{i}:{line.rstrip()}")
153
+
154
+ if len(matches) >= max_results:
155
+ break
156
+
157
+ if len(matches) >= max_results:
158
+ break
159
+
160
+ except Exception:
161
+ continue
162
+
163
+ if not matches:
164
+ return "No matches found"
165
+
166
+ result = f"Found {len(matches)} matches:\n\n"
167
+ result += "\n".join(matches)
168
+
169
+ if len(matches) >= max_results:
170
+ result += f"\n\n[Truncated at {max_results} results]"
171
+
172
+ return result
173
+
174
+ def register(self, mcp_server: FastMCP) -> None:
175
+ """Register with MCP server."""
176
+ tool_instance = self
177
+
178
+ @mcp_server.tool()
179
+ async def search(
180
+ pattern: Annotated[str, Field(description="Regex pattern")],
181
+ path: Annotated[str, Field(description="Path to search")] = ".",
182
+ include: Annotated[Optional[str], Field(description="File pattern")] = None,
183
+ context_lines: Annotated[int, Field(description="Context lines")] = 2,
184
+ max_results: Annotated[int, Field(description="Max results")] = 50,
185
+ ctx: MCPContext = None,
186
+ ) -> str:
187
+ """Search for patterns in file contents."""
188
+ return await tool_instance.call(
189
+ ctx,
190
+ pattern=pattern,
191
+ path=path,
192
+ include=include,
193
+ context_lines=context_lines,
194
+ max_results=max_results,
195
+ )
@@ -0,0 +1,118 @@
1
+ """Tree tool - directory tree view."""
2
+
3
+ from typing import Annotated
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from mcp.server import FastMCP
8
+ from mcp.server.fastmcp import Context as MCPContext
9
+
10
+ from hanzo_tools.core import BaseTool, auto_timeout
11
+
12
+
13
+ class TreeTool(BaseTool):
14
+ """Display directory tree structure."""
15
+
16
+ name = "tree"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return """Display directory tree structure.
21
+
22
+ Args:
23
+ path: Directory path to display
24
+ depth: Maximum depth to traverse (default 3)
25
+ include_filtered: Include normally filtered dirs like .git
26
+
27
+ Returns:
28
+ Tree structure as text
29
+ """
30
+
31
+ # Directories to skip by default
32
+ FILTERED_DIRS = {
33
+ ".git",
34
+ "__pycache__",
35
+ "node_modules",
36
+ ".venv",
37
+ "venv",
38
+ ".idea",
39
+ ".vscode",
40
+ ".mypy_cache",
41
+ ".pytest_cache",
42
+ "dist",
43
+ "build",
44
+ "egg-info",
45
+ ".tox",
46
+ ".nox",
47
+ }
48
+
49
+ @auto_timeout("tree")
50
+ async def call(
51
+ self,
52
+ ctx: MCPContext,
53
+ path: str,
54
+ depth: int = 3,
55
+ include_filtered: bool = False,
56
+ **kwargs,
57
+ ) -> str:
58
+ """Generate directory tree."""
59
+ root = Path(path)
60
+
61
+ if not root.exists():
62
+ return f"Error: Path does not exist: {path}"
63
+
64
+ if not root.is_dir():
65
+ return f"Error: Not a directory: {path}"
66
+
67
+ lines = []
68
+ self._build_tree(root, lines, "", depth, include_filtered)
69
+
70
+ return "\n".join(lines)
71
+
72
+ def _build_tree(
73
+ self,
74
+ path: Path,
75
+ lines: list[str],
76
+ prefix: str,
77
+ depth: int,
78
+ include_filtered: bool,
79
+ ) -> None:
80
+ """Recursively build tree lines."""
81
+ if depth < 0:
82
+ return
83
+
84
+ try:
85
+ entries = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
86
+ except PermissionError:
87
+ lines.append(f"{prefix}[permission denied]")
88
+ return
89
+
90
+ # Filter entries
91
+ if not include_filtered:
92
+ entries = [e for e in entries if e.name not in self.FILTERED_DIRS]
93
+
94
+ for i, entry in enumerate(entries):
95
+ is_last = i == len(entries) - 1
96
+ connector = "└── " if is_last else "├── "
97
+
98
+ if entry.is_dir():
99
+ lines.append(f"{prefix}{connector}{entry.name}/")
100
+ if depth > 0:
101
+ extension = " " if is_last else "│ "
102
+ self._build_tree(entry, lines, prefix + extension, depth - 1, include_filtered)
103
+ else:
104
+ lines.append(f"{prefix}{connector}{entry.name}")
105
+
106
+ def register(self, mcp_server: FastMCP) -> None:
107
+ """Register with MCP server."""
108
+ tool_instance = self
109
+
110
+ @mcp_server.tool()
111
+ async def tree(
112
+ path: Annotated[str, Field(description="Directory path")],
113
+ depth: Annotated[int, Field(description="Max depth")] = 3,
114
+ include_filtered: Annotated[bool, Field(description="Include filtered dirs")] = False,
115
+ ctx: MCPContext = None,
116
+ ) -> str:
117
+ """Display directory tree structure."""
118
+ return await tool_instance.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
@@ -0,0 +1,75 @@
1
+ """Write tool - write/create files."""
2
+
3
+ from typing import Optional, Annotated
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from mcp.server import FastMCP
8
+ from mcp.server.fastmcp import Context as MCPContext
9
+
10
+ from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
11
+
12
+
13
+ class WriteTool(FileSystemTool):
14
+ """Write content to a file."""
15
+
16
+ name = "write"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return """Write content to a file (creates or overwrites).
21
+
22
+ Args:
23
+ file_path: Absolute path to the file
24
+ content: Content to write
25
+
26
+ Returns:
27
+ Success message or error
28
+ """
29
+
30
+ def __init__(self, permission_manager: Optional[PermissionManager] = None):
31
+ super().__init__(permission_manager)
32
+
33
+ @auto_timeout("write")
34
+ async def call(
35
+ self,
36
+ ctx: MCPContext,
37
+ file_path: str,
38
+ content: str,
39
+ **kwargs,
40
+ ) -> str:
41
+ """Write content to file."""
42
+ validation = self.validate_path(file_path)
43
+ if not validation:
44
+ return validation.error_message
45
+
46
+ if not self.is_path_allowed(file_path):
47
+ return f"Error: Access denied to path: {file_path}"
48
+
49
+ path = Path(file_path)
50
+
51
+ try:
52
+ # Create parent directories if needed
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ # Write content
56
+ with open(path, "w", encoding="utf-8") as f:
57
+ f.write(content)
58
+
59
+ return f"Successfully wrote {len(content)} bytes to {file_path}"
60
+
61
+ except Exception as e:
62
+ return f"Error writing file: {e}"
63
+
64
+ def register(self, mcp_server: FastMCP) -> None:
65
+ """Register with MCP server."""
66
+ tool_instance = self
67
+
68
+ @mcp_server.tool()
69
+ async def write(
70
+ file_path: Annotated[str, Field(description="Absolute path to the file")],
71
+ content: Annotated[str, Field(description="Content to write")],
72
+ ctx: MCPContext = None,
73
+ ) -> str:
74
+ """Write content to a file."""
75
+ return await tool_instance.call(ctx, file_path=file_path, content=content)
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanzo-tools-fs
3
+ Version: 0.3.0
4
+ Summary: Filesystem tools for Hanzo AI - read, write, edit, search, find
5
+ Author-email: Hanzo Industries Inc <dev@hanzo.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hanzoai/python-sdk
8
+ Keywords: hanzo,tools,filesystem,mcp,ai
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: aiofiles>=24.1.0
15
+ Requires-Dist: grep-ast>=0.8.1
16
+ Requires-Dist: ffind>=1.3.0
17
+ Requires-Dist: watchdog>=6.0.0
18
+ Requires-Dist: mcp>=1.25.0
19
+ Requires-Dist: fastmcp>=2.14.1
20
+ Requires-Dist: pydantic>=2.12.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
23
+ Requires-Dist: ruff>=0.14.0; extra == "dev"
@@ -0,0 +1,16 @@
1
+ pyproject.toml
2
+ hanzo_tools/__init__.py
3
+ hanzo_tools/fs/__init__.py
4
+ hanzo_tools/fs/ast.py
5
+ hanzo_tools/fs/edit.py
6
+ hanzo_tools/fs/find.py
7
+ hanzo_tools/fs/read.py
8
+ hanzo_tools/fs/search.py
9
+ hanzo_tools/fs/tree.py
10
+ hanzo_tools/fs/write.py
11
+ hanzo_tools_fs.egg-info/PKG-INFO
12
+ hanzo_tools_fs.egg-info/SOURCES.txt
13
+ hanzo_tools_fs.egg-info/dependency_links.txt
14
+ hanzo_tools_fs.egg-info/entry_points.txt
15
+ hanzo_tools_fs.egg-info/requires.txt
16
+ hanzo_tools_fs.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [hanzo.tools]
2
+ fs = hanzo_tools.fs:TOOLS
@@ -0,0 +1,11 @@
1
+ aiofiles>=24.1.0
2
+ grep-ast>=0.8.1
3
+ ffind>=1.3.0
4
+ watchdog>=6.0.0
5
+ mcp>=1.25.0
6
+ fastmcp>=2.14.1
7
+ pydantic>=2.12.5
8
+
9
+ [dev]
10
+ pytest>=7.0.0
11
+ ruff>=0.14.0
@@ -0,0 +1 @@
1
+ hanzo_tools
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hanzo-tools-fs"
7
+ version = "0.3.0"
8
+ description = "Filesystem tools for Hanzo AI - read, write, edit, search, find"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Hanzo Industries Inc", email = "dev@hanzo.ai" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ keywords = ["hanzo", "tools", "filesystem", "mcp", "ai"]
19
+ dependencies = [
20
+ "aiofiles>=24.1.0",
21
+ "grep-ast>=0.8.1",
22
+ "ffind>=1.3.0",
23
+ "watchdog>=6.0.0",
24
+ "mcp>=1.25.0",
25
+ "fastmcp>=2.14.1",
26
+ "pydantic>=2.12.5",
27
+ ]
28
+
29
+ [project.urls]
30
+ "Homepage" = "https://github.com/hanzoai/python-sdk"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7.0.0", "ruff>=0.14.0"]
34
+
35
+ [project.entry-points."hanzo.tools"]
36
+ fs = "hanzo_tools.fs:TOOLS"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["."]
40
+ include = ["hanzo_tools*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ hanzo_tools = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+