hanzo-mcp 0.1.20__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.
- hanzo_mcp/__init__.py +3 -0
- hanzo_mcp/cli.py +213 -0
- hanzo_mcp/server.py +149 -0
- hanzo_mcp/tools/__init__.py +81 -0
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +18 -0
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +444 -0
- hanzo_mcp/tools/common/permissions.py +253 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/common/validation.py +124 -0
- hanzo_mcp/tools/filesystem/__init__.py +89 -0
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +71 -0
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +514 -0
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -0
- hanzo_mcp/tools/project/analysis.py +882 -0
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -0
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +740 -0
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- hanzo_mcp-0.1.20.dist-info/METADATA +111 -0
- hanzo_mcp-0.1.20.dist-info/RECORD +44 -0
- hanzo_mcp-0.1.20.dist-info/WHEEL +5 -0
- hanzo_mcp-0.1.20.dist-info/entry_points.txt +2 -0
- hanzo_mcp-0.1.20.dist-info/licenses/LICENSE +21 -0
- hanzo_mcp-0.1.20.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Edit file tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the EditFileTool for making line-based edits to text files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from difflib import unified_diff
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, final, override
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class EditFileTool(FilesystemBaseTool):
|
|
18
|
+
"""Tool for making line-based edits to files."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@override
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Get the tool name.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tool name
|
|
27
|
+
"""
|
|
28
|
+
return "edit_file"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@override
|
|
32
|
+
def description(self) -> str:
|
|
33
|
+
"""Get the tool description.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tool description
|
|
37
|
+
"""
|
|
38
|
+
return """Make line-based edits to a text file.
|
|
39
|
+
|
|
40
|
+
Each edit replaces exact line sequences with new content.
|
|
41
|
+
Returns a git-style diff showing the changes made.
|
|
42
|
+
Only works within allowed directories."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@override
|
|
46
|
+
def parameters(self) -> dict[str, Any]:
|
|
47
|
+
"""Get the parameter specifications for the tool.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Parameter specifications
|
|
51
|
+
"""
|
|
52
|
+
return {
|
|
53
|
+
"properties": {
|
|
54
|
+
"path": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "The absolute path to the file to modify (must be absolute, not relative)",
|
|
57
|
+
},
|
|
58
|
+
"edits": {
|
|
59
|
+
"items": {
|
|
60
|
+
"properties": {
|
|
61
|
+
"oldText": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)",
|
|
64
|
+
},
|
|
65
|
+
"newText": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "The edited text to replace the old_string",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"additionalProperties": {
|
|
71
|
+
"type": "string"
|
|
72
|
+
},
|
|
73
|
+
"type": "object"
|
|
74
|
+
},
|
|
75
|
+
"description":"List of edit operations [{\"oldText\": \"...\", \"newText\": \"...\"}]",
|
|
76
|
+
"type": "array"
|
|
77
|
+
},
|
|
78
|
+
"dry_run": {
|
|
79
|
+
"default": False,
|
|
80
|
+
"type": "boolean",
|
|
81
|
+
"description": "If true, do not write changes to the file"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["path", "edits"],
|
|
85
|
+
"title": "edit_fileArguments",
|
|
86
|
+
"type": "object"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
@override
|
|
91
|
+
def required(self) -> list[str]:
|
|
92
|
+
"""Get the list of required parameter names.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of required parameter names
|
|
96
|
+
"""
|
|
97
|
+
return ["path", "edits"]
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
101
|
+
"""Execute the tool with the given parameters.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
ctx: MCP context
|
|
105
|
+
**params: Tool parameters
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tool result
|
|
109
|
+
"""
|
|
110
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
111
|
+
self.set_tool_context_info(tool_ctx)
|
|
112
|
+
|
|
113
|
+
# Extract parameters
|
|
114
|
+
path = params.get("path")
|
|
115
|
+
edits = params.get("edits")
|
|
116
|
+
dry_run = params.get("dry_run", False) # Default to False if not provided
|
|
117
|
+
|
|
118
|
+
if not path:
|
|
119
|
+
await tool_ctx.error("Parameter 'path' is required but was None")
|
|
120
|
+
return "Error: Parameter 'path' is required but was None"
|
|
121
|
+
|
|
122
|
+
if path.strip() == "":
|
|
123
|
+
await tool_ctx.error("Parameter 'path' cannot be empty")
|
|
124
|
+
return "Error: Parameter 'path' cannot be empty"
|
|
125
|
+
|
|
126
|
+
# Validate parameters
|
|
127
|
+
path_validation = self.validate_path(path)
|
|
128
|
+
if path_validation.is_error:
|
|
129
|
+
await tool_ctx.error(path_validation.error_message)
|
|
130
|
+
return f"Error: {path_validation.error_message}"
|
|
131
|
+
|
|
132
|
+
if not edits:
|
|
133
|
+
await tool_ctx.error("Parameter 'edits' is required but was None")
|
|
134
|
+
return "Error: Parameter 'edits' is required but was None"
|
|
135
|
+
|
|
136
|
+
if not edits: # Check for empty list
|
|
137
|
+
await tool_ctx.warning("No edits specified")
|
|
138
|
+
return "Error: No edits specified"
|
|
139
|
+
|
|
140
|
+
# Validate each edit to ensure oldText is not empty
|
|
141
|
+
for i, edit in enumerate(edits):
|
|
142
|
+
old_text = edit.get("oldText", "")
|
|
143
|
+
if not old_text or old_text.strip() == "":
|
|
144
|
+
await tool_ctx.error(
|
|
145
|
+
f"Parameter 'oldText' in edit at index {i} is empty"
|
|
146
|
+
)
|
|
147
|
+
return f"Error: Parameter 'oldText' in edit at index {i} cannot be empty - must provide text to match"
|
|
148
|
+
|
|
149
|
+
# dry_run parameter can be None safely as it has a default value in the function signature
|
|
150
|
+
|
|
151
|
+
await tool_ctx.info(f"Editing file: {path}")
|
|
152
|
+
|
|
153
|
+
# Check if file is allowed to be edited
|
|
154
|
+
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
155
|
+
if not allowed:
|
|
156
|
+
return error_msg
|
|
157
|
+
|
|
158
|
+
# Additional check already verified by is_path_allowed above
|
|
159
|
+
await tool_ctx.info(f"Editing file: {path}")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
file_path = Path(path)
|
|
163
|
+
|
|
164
|
+
# Check file exists
|
|
165
|
+
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
166
|
+
if not exists:
|
|
167
|
+
return error_msg
|
|
168
|
+
|
|
169
|
+
# Check is a file
|
|
170
|
+
is_file, error_msg = await self.check_is_file(path, tool_ctx)
|
|
171
|
+
if not is_file:
|
|
172
|
+
return error_msg
|
|
173
|
+
|
|
174
|
+
# Read the file
|
|
175
|
+
try:
|
|
176
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
177
|
+
original_content = f.read()
|
|
178
|
+
|
|
179
|
+
# Apply edits
|
|
180
|
+
modified_content = original_content
|
|
181
|
+
edits_applied = 0
|
|
182
|
+
|
|
183
|
+
for edit in edits:
|
|
184
|
+
old_text = edit.get("oldText", "")
|
|
185
|
+
new_text = edit.get("newText", "")
|
|
186
|
+
|
|
187
|
+
if old_text in modified_content:
|
|
188
|
+
modified_content = modified_content.replace(
|
|
189
|
+
old_text, new_text
|
|
190
|
+
)
|
|
191
|
+
edits_applied += 1
|
|
192
|
+
else:
|
|
193
|
+
# Try line-by-line matching for whitespace flexibility
|
|
194
|
+
old_lines = old_text.splitlines()
|
|
195
|
+
content_lines = modified_content.splitlines()
|
|
196
|
+
|
|
197
|
+
for i in range(len(content_lines) - len(old_lines) + 1):
|
|
198
|
+
current_chunk = content_lines[i : i + len(old_lines)]
|
|
199
|
+
|
|
200
|
+
# Compare with whitespace normalization
|
|
201
|
+
matches = all(
|
|
202
|
+
old_line.strip() == content_line.strip()
|
|
203
|
+
for old_line, content_line in zip(
|
|
204
|
+
old_lines, current_chunk
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if matches:
|
|
209
|
+
# Replace the matching lines
|
|
210
|
+
new_lines = new_text.splitlines()
|
|
211
|
+
content_lines[i : i + len(old_lines)] = new_lines
|
|
212
|
+
modified_content = "\n".join(content_lines)
|
|
213
|
+
edits_applied += 1
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if edits_applied < len(edits):
|
|
217
|
+
await tool_ctx.warning(
|
|
218
|
+
f"Some edits could not be applied: {edits_applied}/{len(edits)}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Generate diff
|
|
222
|
+
original_lines = original_content.splitlines(keepends=True)
|
|
223
|
+
modified_lines = modified_content.splitlines(keepends=True)
|
|
224
|
+
|
|
225
|
+
diff_lines = list(
|
|
226
|
+
unified_diff(
|
|
227
|
+
original_lines,
|
|
228
|
+
modified_lines,
|
|
229
|
+
fromfile=f"{path} (original)",
|
|
230
|
+
tofile=f"{path} (modified)",
|
|
231
|
+
n=3,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
diff_text = "".join(diff_lines)
|
|
236
|
+
|
|
237
|
+
# Determine the number of backticks needed
|
|
238
|
+
num_backticks = 3
|
|
239
|
+
while f"```{num_backticks}" in diff_text:
|
|
240
|
+
num_backticks += 1
|
|
241
|
+
|
|
242
|
+
# Format diff with appropriate number of backticks
|
|
243
|
+
formatted_diff = (
|
|
244
|
+
f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Write the file if not a dry run
|
|
248
|
+
if not dry_run and diff_text: # Only write if there are changes
|
|
249
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
250
|
+
f.write(modified_content)
|
|
251
|
+
|
|
252
|
+
# Update document context
|
|
253
|
+
self.document_context.update_document(path, modified_content)
|
|
254
|
+
|
|
255
|
+
await tool_ctx.info(
|
|
256
|
+
f"Successfully edited file: {path} ({edits_applied} edits applied)"
|
|
257
|
+
)
|
|
258
|
+
return f"Successfully edited file: {path} ({edits_applied} edits applied)\n\n{formatted_diff}"
|
|
259
|
+
elif not diff_text:
|
|
260
|
+
return f"No changes made to file: {path}"
|
|
261
|
+
else:
|
|
262
|
+
await tool_ctx.info(
|
|
263
|
+
f"Dry run: {edits_applied} edits would be applied"
|
|
264
|
+
)
|
|
265
|
+
return f"Dry run: {edits_applied} edits would be applied\n\n{formatted_diff}"
|
|
266
|
+
except UnicodeDecodeError:
|
|
267
|
+
await tool_ctx.error(f"Cannot edit binary file: {path}")
|
|
268
|
+
return f"Error: Cannot edit binary file: {path}"
|
|
269
|
+
except Exception as e:
|
|
270
|
+
await tool_ctx.error(f"Error editing file: {str(e)}")
|
|
271
|
+
return f"Error editing file: {str(e)}"
|
|
272
|
+
|
|
273
|
+
@override
|
|
274
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
275
|
+
"""Register this edit file tool with the MCP server.
|
|
276
|
+
|
|
277
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
278
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
mcp_server: The FastMCP server instance
|
|
282
|
+
"""
|
|
283
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
284
|
+
|
|
285
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
286
|
+
async def edit_file(ctx: MCPContext, path: str, edits: list[dict[str, str]], dry_run: bool = False) -> str:
|
|
287
|
+
return await tool_self.call(ctx, path=path, edits=edits, dry_run=dry_run)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Get file info tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the GetFileInfoTool for retrieving metadata about files and directories.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, final, override
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class GetFileInfoTool(FilesystemBaseTool):
|
|
18
|
+
"""Tool for retrieving metadata about files and directories."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@override
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Get the tool name.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tool name
|
|
27
|
+
"""
|
|
28
|
+
return "get_file_info"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@override
|
|
32
|
+
def description(self) -> str:
|
|
33
|
+
"""Get the tool description.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tool description
|
|
37
|
+
"""
|
|
38
|
+
return """Retrieve detailed metadata about a file or directory.
|
|
39
|
+
|
|
40
|
+
Returns comprehensive information including size, creation time,
|
|
41
|
+
last modified time, permissions, and type. This tool is perfect for
|
|
42
|
+
understanding file characteristics without reading the actual content.
|
|
43
|
+
Only works within allowed directories."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@override
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
"""Get the parameter specifications for the tool.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Parameter specifications
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
"properties": {
|
|
55
|
+
"path": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "path to the file or directory to inspect"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["path"],
|
|
61
|
+
"type": "object"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
@override
|
|
66
|
+
def required(self) -> list[str]:
|
|
67
|
+
"""Get the list of required parameter names.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List of required parameter names
|
|
71
|
+
"""
|
|
72
|
+
return ["path"]
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
76
|
+
"""Execute the tool with the given parameters.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
ctx: MCP context
|
|
80
|
+
**params: Tool parameters
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tool result
|
|
84
|
+
"""
|
|
85
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
86
|
+
|
|
87
|
+
# Extract parameters
|
|
88
|
+
path = params.get("path")
|
|
89
|
+
|
|
90
|
+
if not path:
|
|
91
|
+
await tool_ctx.error("Parameter 'path' is required but was None")
|
|
92
|
+
return "Error: Parameter 'path' is required but was None"
|
|
93
|
+
|
|
94
|
+
if path.strip() == "":
|
|
95
|
+
await tool_ctx.error("Parameter 'path' cannot be empty")
|
|
96
|
+
return "Error: Parameter 'path' cannot be empty"
|
|
97
|
+
|
|
98
|
+
# Validate path parameter
|
|
99
|
+
path_validation = self.validate_path(path)
|
|
100
|
+
if path_validation.is_error:
|
|
101
|
+
await tool_ctx.error(path_validation.error_message)
|
|
102
|
+
return f"Error: {path_validation.error_message}"
|
|
103
|
+
|
|
104
|
+
await tool_ctx.info(f"Getting file info: {path}")
|
|
105
|
+
|
|
106
|
+
# Check if path is allowed
|
|
107
|
+
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
108
|
+
if not allowed:
|
|
109
|
+
return error_msg
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
file_path = Path(path)
|
|
113
|
+
|
|
114
|
+
# Check if path exists
|
|
115
|
+
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
116
|
+
if not exists:
|
|
117
|
+
return error_msg
|
|
118
|
+
|
|
119
|
+
# Get file stats
|
|
120
|
+
stats = file_path.stat()
|
|
121
|
+
|
|
122
|
+
# Format timestamps
|
|
123
|
+
created_time = time.strftime(
|
|
124
|
+
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_ctime)
|
|
125
|
+
)
|
|
126
|
+
modified_time = time.strftime(
|
|
127
|
+
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_mtime)
|
|
128
|
+
)
|
|
129
|
+
accessed_time = time.strftime(
|
|
130
|
+
"%Y-%m-%d %H:%M:%S", time.localtime(stats.st_atime)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Format permissions in octal
|
|
134
|
+
permissions = oct(stats.st_mode)[-3:]
|
|
135
|
+
|
|
136
|
+
# Build info dictionary
|
|
137
|
+
file_info: dict[str, Any] = {
|
|
138
|
+
"name": file_path.name,
|
|
139
|
+
"type": "directory" if file_path.is_dir() else "file",
|
|
140
|
+
"size": stats.st_size,
|
|
141
|
+
"created": created_time,
|
|
142
|
+
"modified": modified_time,
|
|
143
|
+
"accessed": accessed_time,
|
|
144
|
+
"permissions": permissions,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Format the output
|
|
148
|
+
result = [f"{key}: {value}" for key, value in file_info.items()]
|
|
149
|
+
|
|
150
|
+
await tool_ctx.info(f"Retrieved info for {path}")
|
|
151
|
+
return "\n".join(result)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
await tool_ctx.error(f"Error getting file info: {str(e)}")
|
|
154
|
+
return f"Error getting file info: {str(e)}"
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
158
|
+
"""Register this get file info tool with the MCP server.
|
|
159
|
+
|
|
160
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
161
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
mcp_server: The FastMCP server instance
|
|
165
|
+
"""
|
|
166
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
167
|
+
|
|
168
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
169
|
+
async def get_file_info(path: str, ctx: MCPContext) -> str:
|
|
170
|
+
return await tool_self.call(ctx, path=path)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Read files tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ReadFilesTool for reading the contents of 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 ReadFilesTool(FilesystemBaseTool):
|
|
17
|
+
"""Tool for reading 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 "read_files"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@override
|
|
31
|
+
def description(self) -> str:
|
|
32
|
+
"""Get the tool description.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tool description
|
|
36
|
+
"""
|
|
37
|
+
return """Read the contents of one or multiple files.
|
|
38
|
+
|
|
39
|
+
Can read a single file or multiple files simultaneously. When reading multiple files,
|
|
40
|
+
each file's content is returned with its path as a reference. Failed reads for
|
|
41
|
+
individual files won't stop the entire operation. Only works within allowed directories."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
@override
|
|
45
|
+
def parameters(self) -> dict[str, Any]:
|
|
46
|
+
"""Get the parameter specifications for the tool.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Parameter specifications
|
|
50
|
+
"""
|
|
51
|
+
return {
|
|
52
|
+
"properties": {
|
|
53
|
+
"paths": {
|
|
54
|
+
"anyOf": [
|
|
55
|
+
{"items": {"type": "string"}, "type": "array"},
|
|
56
|
+
{"type": "string"}
|
|
57
|
+
],
|
|
58
|
+
"description": "absolute path to the file or files to read"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": ["paths"],
|
|
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 ["paths"]
|
|
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
|
+
paths = params.get("paths")
|
|
91
|
+
|
|
92
|
+
# Validate the 'paths' parameter
|
|
93
|
+
if not paths:
|
|
94
|
+
await tool_ctx.error("Parameter 'paths' is required but was None")
|
|
95
|
+
return "Error: Parameter 'paths' is required but was None"
|
|
96
|
+
|
|
97
|
+
# Convert single path to list if necessary
|
|
98
|
+
path_list: list[str] = [paths] if isinstance(paths, str) else paths
|
|
99
|
+
|
|
100
|
+
# Handle empty list case
|
|
101
|
+
if not path_list:
|
|
102
|
+
await tool_ctx.warning("No files specified to read")
|
|
103
|
+
return "Error: No files specified to read"
|
|
104
|
+
|
|
105
|
+
# For a single file with direct string return
|
|
106
|
+
single_file_mode = isinstance(paths, str)
|
|
107
|
+
|
|
108
|
+
await tool_ctx.info(f"Reading {len(path_list)} file(s)")
|
|
109
|
+
|
|
110
|
+
results: list[str] = []
|
|
111
|
+
|
|
112
|
+
# Read each file
|
|
113
|
+
for i, path in enumerate(path_list):
|
|
114
|
+
# Report progress
|
|
115
|
+
await tool_ctx.report_progress(i, len(path_list))
|
|
116
|
+
|
|
117
|
+
# Check if path is allowed
|
|
118
|
+
if not self.is_path_allowed(path):
|
|
119
|
+
await tool_ctx.error(
|
|
120
|
+
f"Access denied - path outside allowed directories: {path}"
|
|
121
|
+
)
|
|
122
|
+
results.append(
|
|
123
|
+
f"{path}: Error - Access denied - path outside allowed directories"
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
file_path = Path(path)
|
|
129
|
+
|
|
130
|
+
if not file_path.exists():
|
|
131
|
+
await tool_ctx.error(f"File does not exist: {path}")
|
|
132
|
+
results.append(f"{path}: Error - File does not exist")
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if not file_path.is_file():
|
|
136
|
+
await tool_ctx.error(f"Path is not a file: {path}")
|
|
137
|
+
results.append(f"{path}: Error - Path is not a file")
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Read the file
|
|
141
|
+
try:
|
|
142
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
143
|
+
content = f.read()
|
|
144
|
+
|
|
145
|
+
# Add to document context
|
|
146
|
+
self.document_context.add_document(path, content)
|
|
147
|
+
|
|
148
|
+
results.append(f"{path}:\n{content}")
|
|
149
|
+
except UnicodeDecodeError:
|
|
150
|
+
try:
|
|
151
|
+
with open(file_path, "r", encoding="latin-1") as f:
|
|
152
|
+
content = f.read()
|
|
153
|
+
await tool_ctx.warning(
|
|
154
|
+
f"File read with latin-1 encoding: {path}"
|
|
155
|
+
)
|
|
156
|
+
results.append(f"{path} (latin-1 encoding):\n{content}")
|
|
157
|
+
except Exception:
|
|
158
|
+
await tool_ctx.error(f"Cannot read binary file: {path}")
|
|
159
|
+
results.append(f"{path}: Error - Cannot read binary file")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
await tool_ctx.error(f"Error reading file: {str(e)}")
|
|
162
|
+
results.append(f"{path}: Error - {str(e)}")
|
|
163
|
+
|
|
164
|
+
# Final progress report
|
|
165
|
+
await tool_ctx.report_progress(len(path_list), len(path_list))
|
|
166
|
+
|
|
167
|
+
await tool_ctx.info(f"Read {len(path_list)} file(s)")
|
|
168
|
+
|
|
169
|
+
# For single file mode with direct string input, return just the content
|
|
170
|
+
# if successful, otherwise return the error
|
|
171
|
+
if single_file_mode and len(results) == 1:
|
|
172
|
+
result_text = results[0]
|
|
173
|
+
# If it's a successful read (doesn't contain "Error - ")
|
|
174
|
+
if not result_text.split(":", 1)[1].strip().startswith("Error - "):
|
|
175
|
+
# Just return the content part (after the first colon and newline)
|
|
176
|
+
return result_text.split(":", 1)[1].strip()
|
|
177
|
+
else:
|
|
178
|
+
# Return just the error message
|
|
179
|
+
return "Error: " + result_text.split("Error - ", 1)[1]
|
|
180
|
+
|
|
181
|
+
# For multiple files or failed single file read, return all results
|
|
182
|
+
return "\n\n---\n\n".join(results)
|
|
183
|
+
|
|
184
|
+
@override
|
|
185
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
186
|
+
"""Register this tool with the MCP server.
|
|
187
|
+
|
|
188
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
189
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
mcp_server: The FastMCP server instance
|
|
193
|
+
"""
|
|
194
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
195
|
+
|
|
196
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
197
|
+
async def read_files(ctx: MCPContext, paths: list[str] | str) -> str:
|
|
198
|
+
return await tool_self.call(ctx, paths=paths)
|