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.

Files changed (87) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +118 -170
  3. hanzo_mcp/cli_enhanced.py +438 -0
  4. hanzo_mcp/config/__init__.py +19 -0
  5. hanzo_mcp/config/settings.py +388 -0
  6. hanzo_mcp/config/tool_config.py +197 -0
  7. hanzo_mcp/prompts/__init__.py +117 -0
  8. hanzo_mcp/prompts/compact_conversation.py +77 -0
  9. hanzo_mcp/prompts/create_release.py +38 -0
  10. hanzo_mcp/prompts/project_system.py +120 -0
  11. hanzo_mcp/prompts/project_todo_reminder.py +111 -0
  12. hanzo_mcp/prompts/utils.py +286 -0
  13. hanzo_mcp/server.py +117 -99
  14. hanzo_mcp/tools/__init__.py +105 -32
  15. hanzo_mcp/tools/agent/__init__.py +8 -11
  16. hanzo_mcp/tools/agent/agent_tool.py +290 -224
  17. hanzo_mcp/tools/agent/prompt.py +16 -13
  18. hanzo_mcp/tools/agent/tool_adapter.py +9 -9
  19. hanzo_mcp/tools/common/__init__.py +17 -16
  20. hanzo_mcp/tools/common/base.py +79 -110
  21. hanzo_mcp/tools/common/batch_tool.py +330 -0
  22. hanzo_mcp/tools/common/context.py +26 -292
  23. hanzo_mcp/tools/common/permissions.py +12 -12
  24. hanzo_mcp/tools/common/thinking_tool.py +153 -0
  25. hanzo_mcp/tools/common/validation.py +1 -63
  26. hanzo_mcp/tools/filesystem/__init__.py +88 -57
  27. hanzo_mcp/tools/filesystem/base.py +32 -24
  28. hanzo_mcp/tools/filesystem/content_replace.py +114 -107
  29. hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
  30. hanzo_mcp/tools/filesystem/edit.py +279 -0
  31. hanzo_mcp/tools/filesystem/grep.py +458 -0
  32. hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
  33. hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
  34. hanzo_mcp/tools/filesystem/read.py +255 -0
  35. hanzo_mcp/tools/filesystem/write.py +156 -0
  36. hanzo_mcp/tools/jupyter/__init__.py +41 -29
  37. hanzo_mcp/tools/jupyter/base.py +66 -57
  38. hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
  39. hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
  40. hanzo_mcp/tools/shell/__init__.py +29 -20
  41. hanzo_mcp/tools/shell/base.py +87 -45
  42. hanzo_mcp/tools/shell/bash_session.py +731 -0
  43. hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
  44. hanzo_mcp/tools/shell/command_executor.py +435 -384
  45. hanzo_mcp/tools/shell/run_command.py +284 -131
  46. hanzo_mcp/tools/shell/run_command_windows.py +328 -0
  47. hanzo_mcp/tools/shell/session_manager.py +196 -0
  48. hanzo_mcp/tools/shell/session_storage.py +325 -0
  49. hanzo_mcp/tools/todo/__init__.py +66 -0
  50. hanzo_mcp/tools/todo/base.py +319 -0
  51. hanzo_mcp/tools/todo/todo_read.py +148 -0
  52. hanzo_mcp/tools/todo/todo_write.py +378 -0
  53. hanzo_mcp/tools/vector/__init__.py +95 -0
  54. hanzo_mcp/tools/vector/infinity_store.py +365 -0
  55. hanzo_mcp/tools/vector/project_manager.py +361 -0
  56. hanzo_mcp/tools/vector/vector_index.py +115 -0
  57. hanzo_mcp/tools/vector/vector_search.py +215 -0
  58. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +33 -1
  59. hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
  60. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
  61. hanzo_mcp/tools/agent/base_provider.py +0 -73
  62. hanzo_mcp/tools/agent/litellm_provider.py +0 -45
  63. hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
  64. hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
  65. hanzo_mcp/tools/agent/provider_registry.py +0 -120
  66. hanzo_mcp/tools/common/error_handling.py +0 -86
  67. hanzo_mcp/tools/common/logging_config.py +0 -115
  68. hanzo_mcp/tools/common/session.py +0 -91
  69. hanzo_mcp/tools/common/think_tool.py +0 -123
  70. hanzo_mcp/tools/common/version_tool.py +0 -120
  71. hanzo_mcp/tools/filesystem/edit_file.py +0 -287
  72. hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
  73. hanzo_mcp/tools/filesystem/read_files.py +0 -199
  74. hanzo_mcp/tools/filesystem/search_content.py +0 -275
  75. hanzo_mcp/tools/filesystem/write_file.py +0 -162
  76. hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
  77. hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
  78. hanzo_mcp/tools/project/__init__.py +0 -64
  79. hanzo_mcp/tools/project/analysis.py +0 -886
  80. hanzo_mcp/tools/project/base.py +0 -66
  81. hanzo_mcp/tools/project/project_analyze.py +0 -173
  82. hanzo_mcp/tools/shell/run_script.py +0 -215
  83. hanzo_mcp/tools/shell/script_tool.py +0 -244
  84. hanzo_mcp-0.3.8.dist-info/RECORD +0 -53
  85. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
  86. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
  87. {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 mcp.server.fastmcp import Context as MCPContext
10
- from mcp.server.fastmcp import FastMCP
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(self, ctx: MCPContext, **params: Any) -> str:
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.get("path")
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(f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})")
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", "node_modules", ".venv", "venv",
143
- "__pycache__", ".pytest_cache", ".idea",
144
- ".vs", ".vscode", "dist", "build", "target",
145
- ".ruff_cache",".llm-context"
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(f"Directory tree filtering: include_filtered={include_filtered}")
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 current_path.name in FILTERED_DIRECTORIES and not include_filtered
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(current_path: Path, current_depth: int = 0) -> list[dict[str, Any]]:
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(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
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(entry, current_depth + 1)
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
- "name": entry.name,
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(tree_data: list[dict[str, Any]], level: int = 0) -> list[str]:
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(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
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.mcp_description)
285
- async def directory_tree(ctx: MCPContext, path: str, depth: int = 3, include_filtered: bool = False) -> str:
286
- return await tool_self.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
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
+ )