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.
- hanzo_tools_fs-0.3.0/PKG-INFO +23 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/__init__.py +3 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/__init__.py +136 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/ast.py +291 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/edit.py +108 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/find.py +112 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/read.py +100 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/search.py +195 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/tree.py +118 -0
- hanzo_tools_fs-0.3.0/hanzo_tools/fs/write.py +75 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/PKG-INFO +23 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/SOURCES.txt +16 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/dependency_links.txt +1 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/entry_points.txt +2 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/requires.txt +11 -0
- hanzo_tools_fs-0.3.0/hanzo_tools_fs.egg-info/top_level.txt +1 -0
- hanzo_tools_fs-0.3.0/pyproject.toml +43 -0
- hanzo_tools_fs-0.3.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|