hanzo-mcp 0.3.8__py3-none-any.whl → 0.5.0__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 +388 -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 +105 -32
- 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/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 +88 -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/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 +95 -0
- hanzo_mcp/tools/vector/infinity_store.py +365 -0
- hanzo_mcp/tools/vector/project_manager.py +361 -0
- hanzo_mcp/tools/vector/vector_index.py +115 -0
- hanzo_mcp/tools/vector/vector_search.py +215 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +33 -1
- hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.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/RECORD +0 -53
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -4,33 +4,75 @@ This module provides the DirectoryTreeTool for viewing file and directory struct
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any, final, override
|
|
7
|
+
from typing import Annotated, Any, TypedDict, Unpack, final, override
|
|
8
8
|
|
|
9
|
-
from
|
|
10
|
-
from
|
|
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
|
|
11
13
|
|
|
12
14
|
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
13
15
|
|
|
16
|
+
DirectoryPath = Annotated[
|
|
17
|
+
str,
|
|
18
|
+
Field(
|
|
19
|
+
description="The path to the directory to view",
|
|
20
|
+
title="Path",
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
Depth = Annotated[
|
|
25
|
+
int,
|
|
26
|
+
Field(
|
|
27
|
+
default=3,
|
|
28
|
+
description="The maximum depth to traverse (0 for unlimited)",
|
|
29
|
+
title="Depth",
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
IncludeFiltered = Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
Field(
|
|
36
|
+
default=False,
|
|
37
|
+
description="Include directories that are normally filtered",
|
|
38
|
+
title="Include Filtered",
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DirectoryTreeToolParams(TypedDict):
|
|
44
|
+
"""Parameters for the DirectoryTreeTool.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
path: The path to the directory to view
|
|
48
|
+
depth: The maximum depth to traverse (0 for unlimited)
|
|
49
|
+
include_filtered: Include directories that are normally filtered
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
path: DirectoryPath
|
|
53
|
+
depth: Depth
|
|
54
|
+
include_filtered: IncludeFiltered
|
|
55
|
+
|
|
14
56
|
|
|
15
57
|
@final
|
|
16
58
|
class DirectoryTreeTool(FilesystemBaseTool):
|
|
17
59
|
"""Tool for viewing directory structure as a tree."""
|
|
18
|
-
|
|
60
|
+
|
|
19
61
|
@property
|
|
20
62
|
@override
|
|
21
63
|
def name(self) -> str:
|
|
22
64
|
"""Get the tool name.
|
|
23
|
-
|
|
65
|
+
|
|
24
66
|
Returns:
|
|
25
67
|
Tool name
|
|
26
68
|
"""
|
|
27
69
|
return "directory_tree"
|
|
28
|
-
|
|
70
|
+
|
|
29
71
|
@property
|
|
30
72
|
@override
|
|
31
73
|
def description(self) -> str:
|
|
32
74
|
"""Get the tool description.
|
|
33
|
-
|
|
75
|
+
|
|
34
76
|
Returns:
|
|
35
77
|
Tool description
|
|
36
78
|
"""
|
|
@@ -41,75 +83,28 @@ Directories are marked with trailing slashes. The output is formatted as an
|
|
|
41
83
|
indented list for readability. By default, common development directories like
|
|
42
84
|
.git, node_modules, and venv are noted but not traversed unless explicitly
|
|
43
85
|
requested. 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
|
-
"title": "Path",
|
|
57
|
-
"type": "string",
|
|
58
|
-
"description":"The path to the directory to view"
|
|
59
|
-
},
|
|
60
|
-
"depth": {
|
|
61
|
-
"default": 3,
|
|
62
|
-
"title": "Depth",
|
|
63
|
-
"type": "integer",
|
|
64
|
-
"description": "The maximum depth to traverse (0 for unlimited)"
|
|
65
|
-
},
|
|
66
|
-
"include_filtered": {
|
|
67
|
-
"default": False,
|
|
68
|
-
"title": "Include Filtered",
|
|
69
|
-
"type": "boolean",
|
|
70
|
-
"description": "Include directories that are normally filtered"
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
"required": ["path"],
|
|
74
|
-
"title": "directory_treeArguments",
|
|
75
|
-
"type": "object"
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
@property
|
|
79
|
-
@override
|
|
80
|
-
def required(self) -> list[str]:
|
|
81
|
-
"""Get the list of required parameter names.
|
|
82
|
-
|
|
83
|
-
Returns:
|
|
84
|
-
List of required parameter names
|
|
85
|
-
"""
|
|
86
|
-
return ["path"]
|
|
87
|
-
|
|
86
|
+
|
|
88
87
|
@override
|
|
89
|
-
async def call(
|
|
88
|
+
async def call(
|
|
89
|
+
self,
|
|
90
|
+
ctx: MCPContext,
|
|
91
|
+
**params: Unpack[DirectoryTreeToolParams],
|
|
92
|
+
) -> str:
|
|
90
93
|
"""Execute the tool with the given parameters.
|
|
91
|
-
|
|
94
|
+
|
|
92
95
|
Args:
|
|
93
96
|
ctx: MCP context
|
|
94
97
|
**params: Tool parameters
|
|
95
|
-
|
|
98
|
+
|
|
96
99
|
Returns:
|
|
97
100
|
Tool result
|
|
98
101
|
"""
|
|
99
102
|
tool_ctx = self.create_tool_context(ctx)
|
|
100
|
-
|
|
103
|
+
|
|
101
104
|
# Extract parameters
|
|
102
|
-
path = params
|
|
105
|
+
path: DirectoryPath = params["path"]
|
|
103
106
|
depth = params.get("depth", 3) # Default depth is 3
|
|
104
107
|
include_filtered = params.get("include_filtered", False) # Default to False
|
|
105
|
-
|
|
106
|
-
if not path:
|
|
107
|
-
await tool_ctx.error("Parameter 'path' is required but was None")
|
|
108
|
-
return "Error: Parameter 'path' is required but was None"
|
|
109
|
-
|
|
110
|
-
if path.strip() == "":
|
|
111
|
-
await tool_ctx.error("Parameter 'path' cannot be empty")
|
|
112
|
-
return "Error: Parameter 'path' cannot be empty"
|
|
113
108
|
|
|
114
109
|
# Validate path parameter
|
|
115
110
|
path_validation = self.validate_path(path)
|
|
@@ -117,7 +112,9 @@ requested. Only works within allowed directories."""
|
|
|
117
112
|
await tool_ctx.error(path_validation.error_message)
|
|
118
113
|
return f"Error: {path_validation.error_message}"
|
|
119
114
|
|
|
120
|
-
await tool_ctx.info(
|
|
115
|
+
await tool_ctx.info(
|
|
116
|
+
f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})"
|
|
117
|
+
)
|
|
121
118
|
|
|
122
119
|
# Check if path is allowed
|
|
123
120
|
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
@@ -131,7 +128,7 @@ requested. Only works within allowed directories."""
|
|
|
131
128
|
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
132
129
|
if not exists:
|
|
133
130
|
return error_msg
|
|
134
|
-
|
|
131
|
+
|
|
135
132
|
# Check if path is a directory
|
|
136
133
|
is_dir, error_msg = await self.check_is_directory(path, tool_ctx)
|
|
137
134
|
if not is_dir:
|
|
@@ -139,35 +136,51 @@ requested. Only works within allowed directories."""
|
|
|
139
136
|
|
|
140
137
|
# Define filtered directories
|
|
141
138
|
FILTERED_DIRECTORIES = {
|
|
142
|
-
".git",
|
|
143
|
-
"
|
|
144
|
-
".
|
|
145
|
-
"
|
|
139
|
+
".git",
|
|
140
|
+
"node_modules",
|
|
141
|
+
".venv",
|
|
142
|
+
"venv",
|
|
143
|
+
"__pycache__",
|
|
144
|
+
".pytest_cache",
|
|
145
|
+
".idea",
|
|
146
|
+
".vs",
|
|
147
|
+
".vscode",
|
|
148
|
+
"dist",
|
|
149
|
+
"build",
|
|
150
|
+
"target",
|
|
151
|
+
".ruff_cache",
|
|
152
|
+
".llm-context",
|
|
146
153
|
}
|
|
147
|
-
|
|
154
|
+
|
|
148
155
|
# Log filtering settings
|
|
149
|
-
await tool_ctx.info(
|
|
150
|
-
|
|
156
|
+
await tool_ctx.info(
|
|
157
|
+
f"Directory tree filtering: include_filtered={include_filtered}"
|
|
158
|
+
)
|
|
159
|
+
|
|
151
160
|
# Check if a directory should be filtered
|
|
152
161
|
def should_filter(current_path: Path) -> bool:
|
|
153
162
|
# Don't filter if it's the explicitly requested path
|
|
154
163
|
if str(current_path.absolute()) == str(dir_path.absolute()):
|
|
155
164
|
# Don't filter explicitly requested paths
|
|
156
165
|
return False
|
|
157
|
-
|
|
166
|
+
|
|
158
167
|
# Filter based on directory name if filtering is enabled
|
|
159
|
-
return
|
|
160
|
-
|
|
168
|
+
return (
|
|
169
|
+
current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
|
170
|
+
)
|
|
171
|
+
|
|
161
172
|
# Track stats for summary
|
|
162
173
|
stats = {
|
|
163
174
|
"directories": 0,
|
|
164
175
|
"files": 0,
|
|
165
176
|
"skipped_depth": 0,
|
|
166
|
-
"skipped_filtered": 0
|
|
177
|
+
"skipped_filtered": 0,
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
# Build the tree recursively
|
|
170
|
-
async def build_tree(
|
|
181
|
+
async def build_tree(
|
|
182
|
+
current_path: Path, current_depth: int = 0
|
|
183
|
+
) -> list[dict[str, Any]]:
|
|
171
184
|
result: list[dict[str, Any]] = []
|
|
172
185
|
|
|
173
186
|
# Skip processing if path isn't allowed
|
|
@@ -176,8 +189,10 @@ requested. Only works within allowed directories."""
|
|
|
176
189
|
|
|
177
190
|
try:
|
|
178
191
|
# Sort entries: directories first, then files alphabetically
|
|
179
|
-
entries = sorted(
|
|
180
|
-
|
|
192
|
+
entries = sorted(
|
|
193
|
+
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
|
|
194
|
+
)
|
|
195
|
+
|
|
181
196
|
for entry in entries:
|
|
182
197
|
# Skip entries that aren't allowed
|
|
183
198
|
if not self.is_path_allowed(str(entry)):
|
|
@@ -205,37 +220,38 @@ requested. Only works within allowed directories."""
|
|
|
205
220
|
continue
|
|
206
221
|
|
|
207
222
|
# Process children recursively with depth increment
|
|
208
|
-
entry_data["children"] = await build_tree(
|
|
223
|
+
entry_data["children"] = await build_tree(
|
|
224
|
+
entry, current_depth + 1
|
|
225
|
+
)
|
|
209
226
|
result.append(entry_data)
|
|
210
227
|
else:
|
|
211
228
|
# Files should be at the same level check as directories
|
|
212
229
|
if depth <= 0 or current_depth < depth:
|
|
213
230
|
stats["files"] += 1
|
|
214
231
|
# Add file entry
|
|
215
|
-
result.append({
|
|
216
|
-
|
|
217
|
-
"type": "file"
|
|
218
|
-
})
|
|
219
|
-
|
|
232
|
+
result.append({"name": entry.name, "type": "file"})
|
|
233
|
+
|
|
220
234
|
except Exception as e:
|
|
221
|
-
await tool_ctx.warning(
|
|
222
|
-
f"Error processing {current_path}: {str(e)}"
|
|
223
|
-
)
|
|
235
|
+
await tool_ctx.warning(f"Error processing {current_path}: {str(e)}")
|
|
224
236
|
|
|
225
237
|
return result
|
|
226
238
|
|
|
227
239
|
# Format the tree as a simple indented structure
|
|
228
|
-
def format_tree(
|
|
240
|
+
def format_tree(
|
|
241
|
+
tree_data: list[dict[str, Any]], level: int = 0
|
|
242
|
+
) -> list[str]:
|
|
229
243
|
lines = []
|
|
230
|
-
|
|
244
|
+
|
|
231
245
|
for item in tree_data:
|
|
232
246
|
# Indentation based on level
|
|
233
247
|
indent = " " * level
|
|
234
|
-
|
|
248
|
+
|
|
235
249
|
# Format based on type
|
|
236
250
|
if item["type"] == "directory":
|
|
237
251
|
if "skipped" in item:
|
|
238
|
-
lines.append(
|
|
252
|
+
lines.append(
|
|
253
|
+
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
|
|
254
|
+
)
|
|
239
255
|
else:
|
|
240
256
|
lines.append(f"{indent}{item['name']}/")
|
|
241
257
|
# Add children with increased indentation if present
|
|
@@ -244,43 +260,51 @@ requested. Only works within allowed directories."""
|
|
|
244
260
|
else:
|
|
245
261
|
# File
|
|
246
262
|
lines.append(f"{indent}{item['name']}")
|
|
247
|
-
|
|
263
|
+
|
|
248
264
|
return lines
|
|
249
265
|
|
|
250
266
|
# Build tree starting from the requested directory
|
|
251
267
|
tree_data = await build_tree(dir_path)
|
|
252
|
-
|
|
268
|
+
|
|
253
269
|
# Format as simple text
|
|
254
270
|
formatted_output = "\n".join(format_tree(tree_data))
|
|
255
|
-
|
|
271
|
+
|
|
256
272
|
# Add stats summary
|
|
257
273
|
summary = (
|
|
258
274
|
f"\nDirectory Stats: {stats['directories']} directories, {stats['files']} files "
|
|
259
275
|
f"({stats['skipped_depth']} skipped due to depth limit, "
|
|
260
276
|
f"{stats['skipped_filtered']} filtered directories skipped)"
|
|
261
277
|
)
|
|
262
|
-
|
|
278
|
+
|
|
263
279
|
await tool_ctx.info(
|
|
264
280
|
f"Generated directory tree for {path} (depth: {depth}, include_filtered: {include_filtered})"
|
|
265
281
|
)
|
|
266
|
-
|
|
282
|
+
|
|
267
283
|
return formatted_output + summary
|
|
268
284
|
except Exception as e:
|
|
269
285
|
await tool_ctx.error(f"Error generating directory tree: {str(e)}")
|
|
270
286
|
return f"Error generating directory tree: {str(e)}"
|
|
271
|
-
|
|
287
|
+
|
|
272
288
|
@override
|
|
273
289
|
def register(self, mcp_server: FastMCP) -> None:
|
|
274
290
|
"""Register this directory tree tool with the MCP server.
|
|
275
|
-
|
|
291
|
+
|
|
276
292
|
Creates a wrapper function with explicitly defined parameters that match
|
|
277
293
|
the tool's parameter schema and registers it with the MCP server.
|
|
278
|
-
|
|
294
|
+
|
|
279
295
|
Args:
|
|
280
296
|
mcp_server: The FastMCP server instance
|
|
281
297
|
"""
|
|
282
298
|
tool_self = self # Create a reference to self for use in the closure
|
|
283
|
-
|
|
284
|
-
@mcp_server.tool(name=self.name, description=self.
|
|
285
|
-
async def directory_tree(
|
|
286
|
-
|
|
299
|
+
|
|
300
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
301
|
+
async def directory_tree(
|
|
302
|
+
ctx: MCPContext,
|
|
303
|
+
path: DirectoryPath,
|
|
304
|
+
depth: Depth,
|
|
305
|
+
include_filtered: IncludeFiltered,
|
|
306
|
+
) -> str:
|
|
307
|
+
ctx = get_context()
|
|
308
|
+
return await tool_self.call(
|
|
309
|
+
ctx, path=path, depth=depth, include_filtered=include_filtered
|
|
310
|
+
)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Edit tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the Edit tool for making 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
|
+
OldString = Annotated[
|
|
25
|
+
str,
|
|
26
|
+
Field(
|
|
27
|
+
description="The text to replace (must match the file contents exactly, including all whitespace and indentation)",
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
NewString = Annotated[
|
|
32
|
+
str,
|
|
33
|
+
Field(
|
|
34
|
+
description="The edited text to replace the old_string",
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ExpectedReplacements = Annotated[
|
|
39
|
+
int,
|
|
40
|
+
Field(
|
|
41
|
+
default=1,
|
|
42
|
+
description="The expected number of replacements to perform. Defaults to 1 if not specified.",
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EditToolParams(TypedDict):
|
|
48
|
+
"""Parameters for the Edit tool.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
52
|
+
old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
|
53
|
+
new_string: The edited text to replace the old_string
|
|
54
|
+
expected_replacements: The expected number of replacements to perform. Defaults to 1 if not specified.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
file_path: FilePath
|
|
58
|
+
old_string: OldString
|
|
59
|
+
new_string: NewString
|
|
60
|
+
expected_replacements: ExpectedReplacements
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@final
|
|
64
|
+
class Edit(FilesystemBaseTool):
|
|
65
|
+
"""Tool for making precise text replacements in files."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
@override
|
|
69
|
+
def name(self) -> str:
|
|
70
|
+
"""Get the tool name.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tool name
|
|
74
|
+
"""
|
|
75
|
+
return "edit"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
@override
|
|
79
|
+
def description(self) -> str:
|
|
80
|
+
"""Get the tool description.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tool description
|
|
84
|
+
"""
|
|
85
|
+
return """Performs exact string replacements in files with strict occurrence count validation.
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
|
89
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required."""
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
async def call(
|
|
93
|
+
self,
|
|
94
|
+
ctx: MCPContext,
|
|
95
|
+
**params: Unpack[EditToolParams],
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Execute the tool with the given parameters.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
ctx: MCP context
|
|
101
|
+
**params: Tool parameters
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tool result
|
|
105
|
+
"""
|
|
106
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
107
|
+
self.set_tool_context_info(tool_ctx)
|
|
108
|
+
|
|
109
|
+
# Extract parameters
|
|
110
|
+
file_path: FilePath = params["file_path"]
|
|
111
|
+
old_string: OldString = params["old_string"]
|
|
112
|
+
new_string: NewString = params["new_string"]
|
|
113
|
+
expected_replacements = params.get("expected_replacements", 1)
|
|
114
|
+
|
|
115
|
+
# Validate parameters
|
|
116
|
+
path_validation = self.validate_path(file_path)
|
|
117
|
+
if path_validation.is_error:
|
|
118
|
+
await tool_ctx.error(path_validation.error_message)
|
|
119
|
+
return f"Error: {path_validation.error_message}"
|
|
120
|
+
|
|
121
|
+
# Only validate old_string for non-empty if we're not creating a new file
|
|
122
|
+
# Empty old_string is valid when creating a new file
|
|
123
|
+
file_exists = Path(file_path).exists()
|
|
124
|
+
if file_exists and old_string.strip() == "":
|
|
125
|
+
await tool_ctx.error(
|
|
126
|
+
"Parameter 'old_string' cannot be empty for existing files"
|
|
127
|
+
)
|
|
128
|
+
return "Error: Parameter 'old_string' cannot be empty for existing files"
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
expected_replacements is None
|
|
132
|
+
or not isinstance(expected_replacements, (int, float))
|
|
133
|
+
or expected_replacements < 0
|
|
134
|
+
):
|
|
135
|
+
await tool_ctx.error(
|
|
136
|
+
"Parameter 'expected_replacements' must be a non-negative number"
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
"Error: Parameter 'expected_replacements' must be a non-negative number"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await tool_ctx.info(f"Editing file: {file_path}")
|
|
143
|
+
|
|
144
|
+
# Check if file is allowed to be edited
|
|
145
|
+
allowed, error_msg = await self.check_path_allowed(file_path, tool_ctx)
|
|
146
|
+
if not allowed:
|
|
147
|
+
return error_msg
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
file_path_obj = Path(file_path)
|
|
151
|
+
|
|
152
|
+
# If the file doesn't exist and old_string is empty, create a new file
|
|
153
|
+
if not file_path_obj.exists() and old_string == "":
|
|
154
|
+
# Check if parent directory is allowed
|
|
155
|
+
parent_dir = str(file_path_obj.parent)
|
|
156
|
+
if not self.is_path_allowed(parent_dir):
|
|
157
|
+
await tool_ctx.error(f"Parent directory not allowed: {parent_dir}")
|
|
158
|
+
return f"Error: Parent directory not allowed: {parent_dir}"
|
|
159
|
+
|
|
160
|
+
# Create parent directories if they don't exist
|
|
161
|
+
file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
# Create the new file with the new_string content
|
|
164
|
+
with open(file_path_obj, "w", encoding="utf-8") as f:
|
|
165
|
+
f.write(new_string)
|
|
166
|
+
|
|
167
|
+
await tool_ctx.info(f"Successfully created file: {file_path}")
|
|
168
|
+
return (
|
|
169
|
+
f"Successfully created file: {file_path} ({len(new_string)} bytes)"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Check file exists for non-creation operations
|
|
173
|
+
exists, error_msg = await self.check_path_exists(file_path, tool_ctx)
|
|
174
|
+
if not exists:
|
|
175
|
+
return error_msg
|
|
176
|
+
|
|
177
|
+
# Check is a file
|
|
178
|
+
is_file, error_msg = await self.check_is_file(file_path, tool_ctx)
|
|
179
|
+
if not is_file:
|
|
180
|
+
return error_msg
|
|
181
|
+
|
|
182
|
+
# Read the file
|
|
183
|
+
try:
|
|
184
|
+
with open(file_path_obj, "r", encoding="utf-8") as f:
|
|
185
|
+
original_content = f.read()
|
|
186
|
+
|
|
187
|
+
# Apply edit
|
|
188
|
+
if old_string in original_content:
|
|
189
|
+
# Count occurrences of the old_string in the content
|
|
190
|
+
occurrences = original_content.count(old_string)
|
|
191
|
+
|
|
192
|
+
# Check if the number of occurrences matches expected_replacements
|
|
193
|
+
if occurrences != expected_replacements:
|
|
194
|
+
await tool_ctx.error(
|
|
195
|
+
f"Found {occurrences} occurrences of the specified old_string, but expected {expected_replacements}"
|
|
196
|
+
)
|
|
197
|
+
return f"Error: 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."
|
|
198
|
+
|
|
199
|
+
# Replace all occurrences since the count matches expectations
|
|
200
|
+
modified_content = original_content.replace(old_string, new_string)
|
|
201
|
+
else:
|
|
202
|
+
# If we can't find the exact string, report an error
|
|
203
|
+
await tool_ctx.error(
|
|
204
|
+
"The specified old_string was not found in the file content"
|
|
205
|
+
)
|
|
206
|
+
return "Error: The specified old_string was not found in the file content. Please check that it matches exactly, including all whitespace and indentation."
|
|
207
|
+
|
|
208
|
+
# Generate diff
|
|
209
|
+
original_lines = original_content.splitlines(keepends=True)
|
|
210
|
+
modified_lines = modified_content.splitlines(keepends=True)
|
|
211
|
+
|
|
212
|
+
diff_lines = list(
|
|
213
|
+
unified_diff(
|
|
214
|
+
original_lines,
|
|
215
|
+
modified_lines,
|
|
216
|
+
fromfile=f"{file_path} (original)",
|
|
217
|
+
tofile=f"{file_path} (modified)",
|
|
218
|
+
n=3,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
diff_text = "".join(diff_lines)
|
|
223
|
+
|
|
224
|
+
# Determine the number of backticks needed
|
|
225
|
+
num_backticks = 3
|
|
226
|
+
while f"```{num_backticks}" in diff_text:
|
|
227
|
+
num_backticks += 1
|
|
228
|
+
|
|
229
|
+
# Format diff with appropriate number of backticks
|
|
230
|
+
formatted_diff = (
|
|
231
|
+
f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Write the file if there are changes
|
|
235
|
+
if diff_text:
|
|
236
|
+
with open(file_path_obj, "w", encoding="utf-8") as f:
|
|
237
|
+
f.write(modified_content)
|
|
238
|
+
|
|
239
|
+
await tool_ctx.info(
|
|
240
|
+
f"Successfully edited file: {file_path} ({expected_replacements} replacements applied)"
|
|
241
|
+
)
|
|
242
|
+
return f"Successfully edited file: {file_path} ({expected_replacements} replacements applied)\n\n{formatted_diff}"
|
|
243
|
+
else:
|
|
244
|
+
return f"No changes made to file: {file_path}"
|
|
245
|
+
except UnicodeDecodeError:
|
|
246
|
+
await tool_ctx.error(f"Cannot edit binary file: {file_path}")
|
|
247
|
+
return f"Error: Cannot edit binary file: {file_path}"
|
|
248
|
+
except Exception as e:
|
|
249
|
+
await tool_ctx.error(f"Error editing file: {str(e)}")
|
|
250
|
+
return f"Error editing file: {str(e)}"
|
|
251
|
+
|
|
252
|
+
@override
|
|
253
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
254
|
+
"""Register this edit tool with the MCP server.
|
|
255
|
+
|
|
256
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
257
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
mcp_server: The FastMCP server instance
|
|
261
|
+
"""
|
|
262
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
263
|
+
|
|
264
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
265
|
+
async def edit(
|
|
266
|
+
ctx: MCPContext,
|
|
267
|
+
file_path: FilePath,
|
|
268
|
+
old_string: OldString,
|
|
269
|
+
new_string: NewString,
|
|
270
|
+
expected_replacements: ExpectedReplacements,
|
|
271
|
+
) -> str:
|
|
272
|
+
ctx = get_context()
|
|
273
|
+
return await tool_self.call(
|
|
274
|
+
ctx,
|
|
275
|
+
file_path=file_path,
|
|
276
|
+
old_string=old_string,
|
|
277
|
+
new_string=new_string,
|
|
278
|
+
expected_replacements=expected_replacements,
|
|
279
|
+
)
|