hanzo-mcp 0.3.8__py3-none-any.whl → 0.5.1__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 +1 -1
- hanzo_mcp/cli.py +118 -170
- hanzo_mcp/cli_enhanced.py +438 -0
- hanzo_mcp/config/__init__.py +19 -0
- hanzo_mcp/config/settings.py +449 -0
- hanzo_mcp/config/tool_config.py +197 -0
- hanzo_mcp/prompts/__init__.py +117 -0
- hanzo_mcp/prompts/compact_conversation.py +77 -0
- hanzo_mcp/prompts/create_release.py +38 -0
- hanzo_mcp/prompts/project_system.py +120 -0
- hanzo_mcp/prompts/project_todo_reminder.py +111 -0
- hanzo_mcp/prompts/utils.py +286 -0
- hanzo_mcp/server.py +117 -99
- hanzo_mcp/tools/__init__.py +121 -33
- hanzo_mcp/tools/agent/__init__.py +8 -11
- hanzo_mcp/tools/agent/agent_tool.py +290 -224
- hanzo_mcp/tools/agent/prompt.py +16 -13
- hanzo_mcp/tools/agent/tool_adapter.py +9 -9
- hanzo_mcp/tools/common/__init__.py +17 -16
- hanzo_mcp/tools/common/base.py +79 -110
- hanzo_mcp/tools/common/batch_tool.py +330 -0
- hanzo_mcp/tools/common/config_tool.py +396 -0
- hanzo_mcp/tools/common/context.py +26 -292
- hanzo_mcp/tools/common/permissions.py +12 -12
- hanzo_mcp/tools/common/thinking_tool.py +153 -0
- hanzo_mcp/tools/common/validation.py +1 -63
- hanzo_mcp/tools/filesystem/__init__.py +97 -57
- hanzo_mcp/tools/filesystem/base.py +32 -24
- hanzo_mcp/tools/filesystem/content_replace.py +114 -107
- hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
- hanzo_mcp/tools/filesystem/edit.py +279 -0
- hanzo_mcp/tools/filesystem/grep.py +458 -0
- hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
- hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
- hanzo_mcp/tools/filesystem/read.py +255 -0
- hanzo_mcp/tools/filesystem/unified_search.py +689 -0
- hanzo_mcp/tools/filesystem/write.py +156 -0
- hanzo_mcp/tools/jupyter/__init__.py +41 -29
- hanzo_mcp/tools/jupyter/base.py +66 -57
- hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
- hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
- hanzo_mcp/tools/shell/__init__.py +29 -20
- hanzo_mcp/tools/shell/base.py +87 -45
- hanzo_mcp/tools/shell/bash_session.py +731 -0
- hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
- hanzo_mcp/tools/shell/command_executor.py +435 -384
- hanzo_mcp/tools/shell/run_command.py +284 -131
- hanzo_mcp/tools/shell/run_command_windows.py +328 -0
- hanzo_mcp/tools/shell/session_manager.py +196 -0
- hanzo_mcp/tools/shell/session_storage.py +325 -0
- hanzo_mcp/tools/todo/__init__.py +66 -0
- hanzo_mcp/tools/todo/base.py +319 -0
- hanzo_mcp/tools/todo/todo_read.py +148 -0
- hanzo_mcp/tools/todo/todo_write.py +378 -0
- hanzo_mcp/tools/vector/__init__.py +99 -0
- hanzo_mcp/tools/vector/ast_analyzer.py +459 -0
- hanzo_mcp/tools/vector/git_ingester.py +482 -0
- hanzo_mcp/tools/vector/infinity_store.py +731 -0
- hanzo_mcp/tools/vector/mock_infinity.py +162 -0
- hanzo_mcp/tools/vector/project_manager.py +361 -0
- hanzo_mcp/tools/vector/vector_index.py +116 -0
- hanzo_mcp/tools/vector/vector_search.py +225 -0
- hanzo_mcp-0.5.1.dist-info/METADATA +276 -0
- hanzo_mcp-0.5.1.dist-info/RECORD +68 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/WHEEL +1 -1
- hanzo_mcp/tools/agent/base_provider.py +0 -73
- hanzo_mcp/tools/agent/litellm_provider.py +0 -45
- hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
- hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
- hanzo_mcp/tools/agent/provider_registry.py +0 -120
- hanzo_mcp/tools/common/error_handling.py +0 -86
- hanzo_mcp/tools/common/logging_config.py +0 -115
- hanzo_mcp/tools/common/session.py +0 -91
- hanzo_mcp/tools/common/think_tool.py +0 -123
- hanzo_mcp/tools/common/version_tool.py +0 -120
- hanzo_mcp/tools/filesystem/edit_file.py +0 -287
- hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
- hanzo_mcp/tools/filesystem/read_files.py +0 -199
- hanzo_mcp/tools/filesystem/search_content.py +0 -275
- hanzo_mcp/tools/filesystem/write_file.py +0 -162
- hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
- hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
- hanzo_mcp/tools/project/__init__.py +0 -64
- hanzo_mcp/tools/project/analysis.py +0 -886
- hanzo_mcp/tools/project/base.py +0 -66
- hanzo_mcp/tools/project/project_analyze.py +0 -173
- hanzo_mcp/tools/shell/run_script.py +0 -215
- hanzo_mcp/tools/shell/script_tool.py +0 -244
- hanzo_mcp-0.3.8.dist-info/METADATA +0 -196
- hanzo_mcp-0.3.8.dist-info/RECORD +0 -53
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Multi-edit tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the MultiEdit tool for making multiple precise text replacements in files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from difflib import unified_diff
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, TypedDict, Unpack, final, override
|
|
9
|
+
|
|
10
|
+
from fastmcp import Context as MCPContext
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from fastmcp.server.dependencies import get_context
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
16
|
+
|
|
17
|
+
FilePath = Annotated[
|
|
18
|
+
str,
|
|
19
|
+
Field(
|
|
20
|
+
description="The absolute path to the file to modify (must be absolute, not relative)",
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EditItem(TypedDict):
|
|
26
|
+
"""A single edit operation."""
|
|
27
|
+
|
|
28
|
+
old_string: Annotated[
|
|
29
|
+
str,
|
|
30
|
+
Field(
|
|
31
|
+
description="The text to replace (must match the file contents exactly, including all whitespace and indentation)",
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
new_string: Annotated[
|
|
35
|
+
str,
|
|
36
|
+
Field(
|
|
37
|
+
description="The edited text to replace the old_string",
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
expected_replacements: Annotated[
|
|
41
|
+
int,
|
|
42
|
+
Field(
|
|
43
|
+
default=1,
|
|
44
|
+
description="The expected number of replacements to perform. Defaults to 1 if not specified.",
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Edits = Annotated[
|
|
50
|
+
list[EditItem],
|
|
51
|
+
Field(
|
|
52
|
+
description="Array of edit operations to perform sequentially on the file",
|
|
53
|
+
min_length=1,
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MultiEditToolParams(TypedDict):
|
|
59
|
+
"""Parameters for the MultiEdit tool.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
63
|
+
edits: Array of edit operations to perform sequentially on the file
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
file_path: FilePath
|
|
67
|
+
edits: Edits
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@final
|
|
71
|
+
class MultiEdit(FilesystemBaseTool):
|
|
72
|
+
"""Tool for making multiple precise text replacements in files."""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
@override
|
|
76
|
+
def name(self) -> str:
|
|
77
|
+
"""Get the tool name.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Tool name
|
|
81
|
+
"""
|
|
82
|
+
return "multi_edit"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
@override
|
|
86
|
+
def description(self) -> str:
|
|
87
|
+
"""Get the tool description.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tool description
|
|
91
|
+
"""
|
|
92
|
+
return """This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
|
|
93
|
+
|
|
94
|
+
Before using this tool:
|
|
95
|
+
|
|
96
|
+
1. Use the Read tool to understand the file's contents and context
|
|
97
|
+
2. Verify the directory path is correct
|
|
98
|
+
|
|
99
|
+
To make multiple file edits, provide the following:
|
|
100
|
+
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
101
|
+
2. edits: An array of edit operations to perform, where each edit contains:
|
|
102
|
+
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
|
103
|
+
- new_string: The edited text to replace the old_string
|
|
104
|
+
- expected_replacements: The number of replacements you expect to make. Defaults to 1 if not specified.
|
|
105
|
+
|
|
106
|
+
IMPORTANT:
|
|
107
|
+
- All edits are applied in sequence, in the order they are provided
|
|
108
|
+
- Each edit operates on the result of the previous edit
|
|
109
|
+
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
|
110
|
+
- This tool is ideal when you need to make several changes to different parts of the same file
|
|
111
|
+
- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead
|
|
112
|
+
|
|
113
|
+
CRITICAL REQUIREMENTS:
|
|
114
|
+
1. All edits follow the same requirements as the single Edit tool
|
|
115
|
+
2. The edits are atomic - either all succeed or none are applied
|
|
116
|
+
3. Plan your edits carefully to avoid conflicts between sequential operations
|
|
117
|
+
|
|
118
|
+
WARNING:
|
|
119
|
+
- The tool will fail if edits.old_string matches multiple locations and edits.expected_replacements isn't specified
|
|
120
|
+
- The tool will fail if the number of matches doesn't equal edits.expected_replacements when it's specified
|
|
121
|
+
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
|
122
|
+
- The tool will fail if edits.old_string and edits.new_string are the same
|
|
123
|
+
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
|
|
124
|
+
|
|
125
|
+
When making edits:
|
|
126
|
+
- Ensure all edits result in idiomatic, correct code
|
|
127
|
+
- Do not leave the code in a broken state
|
|
128
|
+
- Always use absolute file paths (starting with /)
|
|
129
|
+
|
|
130
|
+
If you want to create a new file, use:
|
|
131
|
+
- A new file path, including dir name if needed
|
|
132
|
+
- First edit: empty old_string and the new file's contents as new_string
|
|
133
|
+
- Subsequent edits: normal edit operations on the created content"""
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
async def call(
|
|
137
|
+
self,
|
|
138
|
+
ctx: MCPContext,
|
|
139
|
+
**params: Unpack[MultiEditToolParams],
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Execute the tool with the given parameters.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
ctx: MCP context
|
|
145
|
+
**params: Tool parameters
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Tool result
|
|
149
|
+
"""
|
|
150
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
151
|
+
self.set_tool_context_info(tool_ctx)
|
|
152
|
+
|
|
153
|
+
# Extract parameters
|
|
154
|
+
file_path: FilePath = params["file_path"]
|
|
155
|
+
edits: Edits = params["edits"]
|
|
156
|
+
|
|
157
|
+
# Validate parameters
|
|
158
|
+
path_validation = self.validate_path(file_path)
|
|
159
|
+
if path_validation.is_error:
|
|
160
|
+
await tool_ctx.error(path_validation.error_message)
|
|
161
|
+
return f"Error: {path_validation.error_message}"
|
|
162
|
+
|
|
163
|
+
# Validate each edit
|
|
164
|
+
for i, edit in enumerate(edits):
|
|
165
|
+
if not isinstance(edit, dict):
|
|
166
|
+
await tool_ctx.error(f"Edit at index {i} must be an object")
|
|
167
|
+
return f"Error: Edit at index {i} must be an object"
|
|
168
|
+
|
|
169
|
+
old_string = edit.get("old_string")
|
|
170
|
+
new_string = edit.get("new_string")
|
|
171
|
+
expected_replacements = edit.get("expected_replacements", 1)
|
|
172
|
+
|
|
173
|
+
if old_string is None:
|
|
174
|
+
await tool_ctx.error(
|
|
175
|
+
f"Parameter 'old_string' in edit at index {i} is required but was None"
|
|
176
|
+
)
|
|
177
|
+
return f"Error: Parameter 'old_string' in edit at index {i} is required but was None"
|
|
178
|
+
|
|
179
|
+
if new_string is None:
|
|
180
|
+
await tool_ctx.error(
|
|
181
|
+
f"Parameter 'new_string' in edit at index {i} is required but was None"
|
|
182
|
+
)
|
|
183
|
+
return f"Error: Parameter 'new_string' in edit at index {i} is required but was None"
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
expected_replacements is None
|
|
187
|
+
or not isinstance(expected_replacements, (int, float))
|
|
188
|
+
or expected_replacements < 0
|
|
189
|
+
):
|
|
190
|
+
await tool_ctx.error(
|
|
191
|
+
f"Parameter 'expected_replacements' in edit at index {i} must be a non-negative number"
|
|
192
|
+
)
|
|
193
|
+
return f"Error: Parameter 'expected_replacements' in edit at index {i} must be a non-negative number"
|
|
194
|
+
|
|
195
|
+
if old_string == new_string:
|
|
196
|
+
await tool_ctx.error(
|
|
197
|
+
f"Edit at index {i}: old_string and new_string are identical"
|
|
198
|
+
)
|
|
199
|
+
return (
|
|
200
|
+
f"Error: Edit at index {i}: old_string and new_string are identical"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await tool_ctx.info(f"Applying {len(edits)} edits to file: {file_path}")
|
|
204
|
+
|
|
205
|
+
# Check if file is allowed to be edited
|
|
206
|
+
allowed, error_msg = await self.check_path_allowed(file_path, tool_ctx)
|
|
207
|
+
if not allowed:
|
|
208
|
+
return error_msg
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
file_path_obj = Path(file_path)
|
|
212
|
+
|
|
213
|
+
# Handle file creation case (when first edit has empty old_string)
|
|
214
|
+
first_edit = edits[0]
|
|
215
|
+
if not file_path_obj.exists() and first_edit.get("old_string") == "":
|
|
216
|
+
# Check if parent directory is allowed
|
|
217
|
+
parent_dir = str(file_path_obj.parent)
|
|
218
|
+
if not self.is_path_allowed(parent_dir):
|
|
219
|
+
await tool_ctx.error(f"Parent directory not allowed: {parent_dir}")
|
|
220
|
+
return f"Error: Parent directory not allowed: {parent_dir}"
|
|
221
|
+
|
|
222
|
+
# Create parent directories if they don't exist
|
|
223
|
+
file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
# Start with the content from the first edit
|
|
226
|
+
current_content = first_edit.get("new_string", "")
|
|
227
|
+
|
|
228
|
+
# Apply remaining edits to this content
|
|
229
|
+
edits_to_apply = edits[1:]
|
|
230
|
+
creation_mode = True
|
|
231
|
+
else:
|
|
232
|
+
# Normal edit mode - file must exist
|
|
233
|
+
exists, error_msg = await self.check_path_exists(file_path, tool_ctx)
|
|
234
|
+
if not exists:
|
|
235
|
+
return error_msg
|
|
236
|
+
|
|
237
|
+
# Check is a file
|
|
238
|
+
is_file, error_msg = await self.check_is_file(file_path, tool_ctx)
|
|
239
|
+
if not is_file:
|
|
240
|
+
return error_msg
|
|
241
|
+
|
|
242
|
+
# Read the file
|
|
243
|
+
try:
|
|
244
|
+
with open(file_path_obj, "r", encoding="utf-8") as f:
|
|
245
|
+
current_content = f.read()
|
|
246
|
+
except UnicodeDecodeError:
|
|
247
|
+
await tool_ctx.error(f"Cannot edit binary file: {file_path}")
|
|
248
|
+
return f"Error: Cannot edit binary file: {file_path}"
|
|
249
|
+
|
|
250
|
+
edits_to_apply = edits
|
|
251
|
+
creation_mode = False
|
|
252
|
+
|
|
253
|
+
# Store original content for diff generation
|
|
254
|
+
original_content = "" if creation_mode else current_content
|
|
255
|
+
|
|
256
|
+
# Apply all edits sequentially
|
|
257
|
+
total_replacements = 0
|
|
258
|
+
for i, edit in enumerate(edits_to_apply):
|
|
259
|
+
old_string = edit.get("old_string")
|
|
260
|
+
new_string = edit.get("new_string")
|
|
261
|
+
expected_replacements = edit.get("expected_replacements", 1)
|
|
262
|
+
|
|
263
|
+
# Check if old_string exists in current content
|
|
264
|
+
if old_string not in current_content:
|
|
265
|
+
edit_index = (
|
|
266
|
+
i + 1 if not creation_mode else i + 2
|
|
267
|
+
) # Adjust for display
|
|
268
|
+
await tool_ctx.error(
|
|
269
|
+
f"Edit {edit_index}: The specified old_string was not found in the file content"
|
|
270
|
+
)
|
|
271
|
+
return f"Error: Edit {edit_index}: The specified old_string was not found in the file content. Please check that it matches exactly, including all whitespace and indentation."
|
|
272
|
+
|
|
273
|
+
# Count occurrences
|
|
274
|
+
occurrences = current_content.count(old_string)
|
|
275
|
+
|
|
276
|
+
# Check if the number of occurrences matches expected_replacements
|
|
277
|
+
if occurrences != expected_replacements:
|
|
278
|
+
edit_index = (
|
|
279
|
+
i + 1 if not creation_mode else i + 2
|
|
280
|
+
) # Adjust for display
|
|
281
|
+
await tool_ctx.error(
|
|
282
|
+
f"Edit {edit_index}: Found {occurrences} occurrences of the specified old_string, but expected {expected_replacements}"
|
|
283
|
+
)
|
|
284
|
+
return f"Error: Edit {edit_index}: Found {occurrences} occurrences of the specified old_string, but expected {expected_replacements}. Change your old_string to uniquely identify the target text, or set expected_replacements={occurrences} to replace all occurrences."
|
|
285
|
+
|
|
286
|
+
# Apply the replacement
|
|
287
|
+
current_content = current_content.replace(old_string, new_string)
|
|
288
|
+
total_replacements += expected_replacements
|
|
289
|
+
|
|
290
|
+
# Generate diff
|
|
291
|
+
original_lines = original_content.splitlines(keepends=True)
|
|
292
|
+
modified_lines = current_content.splitlines(keepends=True)
|
|
293
|
+
|
|
294
|
+
diff_lines = list(
|
|
295
|
+
unified_diff(
|
|
296
|
+
original_lines,
|
|
297
|
+
modified_lines,
|
|
298
|
+
fromfile=f"{file_path} (original)",
|
|
299
|
+
tofile=f"{file_path} (modified)",
|
|
300
|
+
n=3,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
diff_text = "".join(diff_lines)
|
|
305
|
+
|
|
306
|
+
# Determine the number of backticks needed
|
|
307
|
+
num_backticks = 3
|
|
308
|
+
while f"```{num_backticks}" in diff_text:
|
|
309
|
+
num_backticks += 1
|
|
310
|
+
|
|
311
|
+
# Format diff with appropriate number of backticks
|
|
312
|
+
formatted_diff = f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
|
313
|
+
|
|
314
|
+
# Write the file
|
|
315
|
+
if diff_text or creation_mode:
|
|
316
|
+
with open(file_path_obj, "w", encoding="utf-8") as f:
|
|
317
|
+
f.write(current_content)
|
|
318
|
+
|
|
319
|
+
if creation_mode:
|
|
320
|
+
pass
|
|
321
|
+
else:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
if creation_mode:
|
|
325
|
+
await tool_ctx.info(f"Successfully created file: {file_path}")
|
|
326
|
+
return f"Successfully created file: {file_path} ({len(current_content)} bytes)\n\n{formatted_diff}"
|
|
327
|
+
else:
|
|
328
|
+
await tool_ctx.info(
|
|
329
|
+
f"Successfully applied {len(edits)} edits to file: {file_path} ({total_replacements} total replacements)"
|
|
330
|
+
)
|
|
331
|
+
return f"Successfully applied {len(edits)} edits to file: {file_path} ({total_replacements} total replacements)\n\n{formatted_diff}"
|
|
332
|
+
else:
|
|
333
|
+
return f"No changes made to file: {file_path}"
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
await tool_ctx.error(f"Error applying edits to file: {str(e)}")
|
|
337
|
+
return f"Error applying edits to file: {str(e)}"
|
|
338
|
+
|
|
339
|
+
@override
|
|
340
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
341
|
+
"""Register this multi-edit tool with the MCP server.
|
|
342
|
+
|
|
343
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
344
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
mcp_server: The FastMCP server instance
|
|
348
|
+
"""
|
|
349
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
350
|
+
|
|
351
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
352
|
+
async def multi_edit(
|
|
353
|
+
ctx: MCPContext,
|
|
354
|
+
file_path: FilePath,
|
|
355
|
+
edits: Edits,
|
|
356
|
+
) -> str:
|
|
357
|
+
ctx = get_context()
|
|
358
|
+
return await tool_self.call(
|
|
359
|
+
ctx,
|
|
360
|
+
file_path=file_path,
|
|
361
|
+
edits=edits,
|
|
362
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Read tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ReadTool for reading the contents of files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, TypedDict, Unpack, final, override
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context as MCPContext
|
|
10
|
+
from fastmcp import FastMCP
|
|
11
|
+
from fastmcp.server.dependencies import get_context
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
15
|
+
|
|
16
|
+
FilePath = Annotated[
|
|
17
|
+
str,
|
|
18
|
+
Field(
|
|
19
|
+
description="The absolute path to the file to read",
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
Offset = Annotated[
|
|
24
|
+
int,
|
|
25
|
+
Field(
|
|
26
|
+
description="The line number to start reading from. Only provide if the file is too large to read at once",
|
|
27
|
+
default=0,
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
Limit = Annotated[
|
|
32
|
+
int,
|
|
33
|
+
Field(
|
|
34
|
+
description="The number of lines to read. Only provide if the file is too large to read at once",
|
|
35
|
+
default=2000,
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReadToolParams(TypedDict):
|
|
41
|
+
"""Parameters for the ReadTool.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
file_path: The absolute path to the file to read
|
|
45
|
+
offset: The line number to start reading from. Only provide if the file is too large to read at once
|
|
46
|
+
limit: The number of lines to read. Only provide if the file is too large to read at once
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
file_path: FilePath
|
|
50
|
+
offset: Offset
|
|
51
|
+
limit: Limit
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@final
|
|
55
|
+
class ReadTool(FilesystemBaseTool):
|
|
56
|
+
"""Tool for reading file contents."""
|
|
57
|
+
|
|
58
|
+
# Default values for truncation
|
|
59
|
+
DEFAULT_LINE_LIMIT = 2000
|
|
60
|
+
MAX_LINE_LENGTH = 2000
|
|
61
|
+
LINE_TRUNCATION_INDICATOR = "... [line truncated]"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
@override
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
"""Get the tool name.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Tool name
|
|
70
|
+
"""
|
|
71
|
+
return "read"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@override
|
|
75
|
+
def description(self) -> str:
|
|
76
|
+
"""Get the tool description.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Tool description
|
|
80
|
+
"""
|
|
81
|
+
return """Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
82
|
+
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
83
|
+
|
|
84
|
+
Usage:
|
|
85
|
+
- The file_path parameter must be an absolute path, not a relative path
|
|
86
|
+
- By default, it reads up to 2000 lines starting from the beginning of the file
|
|
87
|
+
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
|
88
|
+
- Any lines longer than 2000 characters will be truncated
|
|
89
|
+
- Results are returned using cat -n format, with line numbers starting at 1
|
|
90
|
+
- For Jupyter notebooks (.ipynb files), use the notebook_read instead
|
|
91
|
+
- When reading multiple files, you MUST use the batch tool to read them all at once"""
|
|
92
|
+
|
|
93
|
+
@override
|
|
94
|
+
async def call(
|
|
95
|
+
self,
|
|
96
|
+
ctx: MCPContext,
|
|
97
|
+
**params: Unpack[ReadToolParams],
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Execute the tool with the given parameters.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
ctx: MCP context
|
|
103
|
+
**params: Tool parameters
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Tool result
|
|
107
|
+
"""
|
|
108
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
109
|
+
self.set_tool_context_info(tool_ctx)
|
|
110
|
+
|
|
111
|
+
# Extract parameters
|
|
112
|
+
file_path = params.get("file_path")
|
|
113
|
+
offset = params.get("offset", 0)
|
|
114
|
+
limit = params.get("limit", self.DEFAULT_LINE_LIMIT)
|
|
115
|
+
|
|
116
|
+
# Validate required parameters for direct calls (not through MCP framework)
|
|
117
|
+
if not file_path:
|
|
118
|
+
await tool_ctx.error("Parameter 'file_path' is required but was None")
|
|
119
|
+
return "Error: Parameter 'file_path' is required but was None"
|
|
120
|
+
|
|
121
|
+
await tool_ctx.info(
|
|
122
|
+
f"Reading file: {file_path} (offset: {offset}, limit: {limit})"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Check if path is allowed
|
|
126
|
+
if not self.is_path_allowed(file_path):
|
|
127
|
+
await tool_ctx.error(
|
|
128
|
+
f"Access denied - path outside allowed directories: {file_path}"
|
|
129
|
+
)
|
|
130
|
+
return (
|
|
131
|
+
f"Error: Access denied - path outside allowed directories: {file_path}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
file_path_obj = Path(file_path)
|
|
136
|
+
|
|
137
|
+
if not file_path_obj.exists():
|
|
138
|
+
await tool_ctx.error(f"File does not exist: {file_path}")
|
|
139
|
+
return f"Error: File does not exist: {file_path}"
|
|
140
|
+
|
|
141
|
+
if not file_path_obj.is_file():
|
|
142
|
+
await tool_ctx.error(f"Path is not a file: {file_path}")
|
|
143
|
+
return f"Error: Path is not a file: {file_path}"
|
|
144
|
+
|
|
145
|
+
# Read the file
|
|
146
|
+
try:
|
|
147
|
+
# Read and process the file with line numbers and truncation
|
|
148
|
+
lines = []
|
|
149
|
+
current_line = 0
|
|
150
|
+
truncated_lines = 0
|
|
151
|
+
|
|
152
|
+
# Try with utf-8 encoding first
|
|
153
|
+
try:
|
|
154
|
+
with open(file_path_obj, "r", encoding="utf-8") as f:
|
|
155
|
+
for i, line in enumerate(f):
|
|
156
|
+
# Skip lines before offset
|
|
157
|
+
if i < offset:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Stop after reading 'limit' lines
|
|
161
|
+
if current_line >= limit:
|
|
162
|
+
truncated_lines = True
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
current_line += 1
|
|
166
|
+
|
|
167
|
+
# Truncate long lines
|
|
168
|
+
if len(line) > self.MAX_LINE_LENGTH:
|
|
169
|
+
line = (
|
|
170
|
+
line[: self.MAX_LINE_LENGTH]
|
|
171
|
+
+ self.LINE_TRUNCATION_INDICATOR
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Add line with line number (1-based)
|
|
175
|
+
lines.append(f"{i + 1:6d} {line.rstrip()}")
|
|
176
|
+
|
|
177
|
+
except UnicodeDecodeError:
|
|
178
|
+
# Try with latin-1 encoding
|
|
179
|
+
try:
|
|
180
|
+
lines = []
|
|
181
|
+
current_line = 0
|
|
182
|
+
truncated_lines = 0
|
|
183
|
+
|
|
184
|
+
with open(file_path_obj, "r", encoding="latin-1") as f:
|
|
185
|
+
for i, line in enumerate(f):
|
|
186
|
+
# Skip lines before offset
|
|
187
|
+
if i < offset:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Stop after reading 'limit' lines
|
|
191
|
+
if current_line >= limit:
|
|
192
|
+
truncated_lines = True
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
current_line += 1
|
|
196
|
+
|
|
197
|
+
# Truncate long lines
|
|
198
|
+
if len(line) > self.MAX_LINE_LENGTH:
|
|
199
|
+
line = (
|
|
200
|
+
line[: self.MAX_LINE_LENGTH]
|
|
201
|
+
+ self.LINE_TRUNCATION_INDICATOR
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Add line with line number (1-based)
|
|
205
|
+
lines.append(f"{i + 1:6d} {line.rstrip()}")
|
|
206
|
+
|
|
207
|
+
await tool_ctx.warning(
|
|
208
|
+
f"File read with latin-1 encoding: {file_path}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
except Exception:
|
|
212
|
+
await tool_ctx.error(f"Cannot read binary file: {file_path}")
|
|
213
|
+
return f"Error: Cannot read binary file: {file_path}"
|
|
214
|
+
|
|
215
|
+
# Format the result
|
|
216
|
+
result = "\n".join(lines)
|
|
217
|
+
|
|
218
|
+
# Add truncation message if necessary
|
|
219
|
+
if truncated_lines:
|
|
220
|
+
result += f"\n... (output truncated, showing {limit} of {limit + truncated_lines}+ lines)"
|
|
221
|
+
|
|
222
|
+
await tool_ctx.info(f"Successfully read file: {file_path}")
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
await tool_ctx.error(f"Error reading file: {str(e)}")
|
|
227
|
+
return f"Error: {str(e)}"
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
await tool_ctx.error(f"Error reading file: {str(e)}")
|
|
231
|
+
return f"Error: {str(e)}"
|
|
232
|
+
|
|
233
|
+
@override
|
|
234
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
235
|
+
"""Register this tool with the MCP server.
|
|
236
|
+
|
|
237
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
238
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
mcp_server: The FastMCP server instance
|
|
242
|
+
"""
|
|
243
|
+
tool_self = self
|
|
244
|
+
|
|
245
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
246
|
+
async def read(
|
|
247
|
+
ctx: MCPContext,
|
|
248
|
+
file_path: FilePath,
|
|
249
|
+
offset: Offset,
|
|
250
|
+
limit: Limit,
|
|
251
|
+
) -> str:
|
|
252
|
+
ctx = get_context()
|
|
253
|
+
return await tool_self.call(
|
|
254
|
+
ctx, file_path=file_path, offset=offset, limit=limit
|
|
255
|
+
)
|