feat(mcp): Add edit_file tool - native edit replacement that preserves indentation
- New edit_file(path, old_string, new_string, replace_all) function - Reads/writes with newline='' to preserve CRLF and 1-space indentation - Returns error if old_string not found or multiple matches without replace_all - Added to MUTATING_TOOLS for HITL approval routing - Added to TOOL_NAMES and dispatch function - Added MCP_TOOL_SPECS entry for AI tool declaration - Updated agent configs (tier2, tier3, general) with edit_file mapping Note: tier1, tier4, explore agents don't need this (edit: deny - read-only)
This commit is contained in:
@@ -28,9 +28,11 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable.
|
|||||||
### Edit MCP Tools (USE THESE)
|
### Edit MCP Tools (USE THESE)
|
||||||
| Native Tool | MCP Tool |
|
| Native Tool | MCP Tool |
|
||||||
|-------------|----------|
|
|-------------|----------|
|
||||||
|
| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) |
|
||||||
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
||||||
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
||||||
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
||||||
|
| `edit` | `manual-slop_py_set_var_declaration` (replace variable) |
|
||||||
|
|
||||||
### Shell Commands
|
### Shell Commands
|
||||||
| Native Tool | MCP Tool |
|
| Native Tool | MCP Tool |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable.
|
|||||||
### Edit MCP Tools (USE THESE)
|
### Edit MCP Tools (USE THESE)
|
||||||
| Native Tool | MCP Tool |
|
| Native Tool | MCP Tool |
|
||||||
|-------------|----------|
|
|-------------|----------|
|
||||||
|
| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) |
|
||||||
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
||||||
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
||||||
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable.
|
|||||||
### Edit MCP Tools (USE THESE - BAN NATIVE EDIT)
|
### Edit MCP Tools (USE THESE - BAN NATIVE EDIT)
|
||||||
| Native Tool | MCP Tool |
|
| Native Tool | MCP Tool |
|
||||||
|-------------|----------|
|
|-------------|----------|
|
||||||
|
| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) |
|
||||||
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
| `edit` | `manual-slop_py_update_definition` (replace function/class) |
|
||||||
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
| `edit` | `manual-slop_set_file_slice` (replace line range) |
|
||||||
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
| `edit` | `manual-slop_py_set_signature` (replace signature only) |
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ MUTATING_TOOLS: frozenset[str] = frozenset({
|
|||||||
"py_update_definition",
|
"py_update_definition",
|
||||||
"py_set_signature",
|
"py_set_signature",
|
||||||
"py_set_var_declaration",
|
"py_set_var_declaration",
|
||||||
|
"edit_file",
|
||||||
})
|
})
|
||||||
|
|
||||||
# ------------------------------------------------------------------ state
|
# ------------------------------------------------------------------ state
|
||||||
@@ -315,6 +316,37 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str)
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR updating slice in '{path}': {e}"
|
return f"ERROR updating slice in '{path}': {e}"
|
||||||
|
|
||||||
|
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Replace exact string match in a file. Preserves indentation and line endings.
|
||||||
|
Drop-in replacement for native edit tool that destroys 1-space indentation.
|
||||||
|
"""
|
||||||
|
p, err = _resolve_and_check(path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert p is not None
|
||||||
|
if not p.exists():
|
||||||
|
return f"ERROR: file not found: {path}"
|
||||||
|
if not old_string:
|
||||||
|
return "ERROR: old_string cannot be empty"
|
||||||
|
try:
|
||||||
|
content = p.read_text(encoding="utf-8", newline="")
|
||||||
|
if old_string not in content:
|
||||||
|
return f"ERROR: old_string not found in '{path}'"
|
||||||
|
count = content.count(old_string)
|
||||||
|
if count > 1 and not replace_all:
|
||||||
|
return f"ERROR: Found {count} matches for old_string in '{path}'. Use replace_all=true or provide more context to make it unique."
|
||||||
|
if replace_all:
|
||||||
|
new_content = content.replace(old_string, new_string)
|
||||||
|
p.write_text(new_content, encoding="utf-8", newline="")
|
||||||
|
return f"Successfully replaced {count} occurrences in '{path}'"
|
||||||
|
else:
|
||||||
|
new_content = content.replace(old_string, new_string, 1)
|
||||||
|
p.write_text(new_content, encoding="utf-8", newline="")
|
||||||
|
return f"Successfully replaced 1 occurrence in '{path}'"
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR editing '{path}': {e}"
|
||||||
|
|
||||||
def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]:
|
def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]:
|
||||||
"""Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation."""
|
"""Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation."""
|
||||||
parts = name.split(".")
|
parts = name.split(".")
|
||||||
@@ -824,7 +856,7 @@ def get_ui_performance() -> str:
|
|||||||
return f"ERROR: Failed to retrieve UI performance: {str(e)}"
|
return f"ERROR: Failed to retrieve UI performance: {str(e)}"
|
||||||
# ------------------------------------------------------------------ tool dispatch
|
# ------------------------------------------------------------------ tool dispatch
|
||||||
|
|
||||||
TOOL_NAMES: set[str] = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"}
|
TOOL_NAMES: set[str] = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "edit_file", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"}
|
||||||
|
|
||||||
def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -868,6 +900,13 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
|
|||||||
str(tool_input.get("base_rev", "HEAD")),
|
str(tool_input.get("base_rev", "HEAD")),
|
||||||
str(tool_input.get("head_rev", ""))
|
str(tool_input.get("head_rev", ""))
|
||||||
)
|
)
|
||||||
|
if tool_name == "edit_file":
|
||||||
|
return edit_file(
|
||||||
|
path,
|
||||||
|
str(tool_input.get("old_string", "")),
|
||||||
|
str(tool_input.get("new_string", "")),
|
||||||
|
bool(tool_input.get("replace_all", False))
|
||||||
|
)
|
||||||
if tool_name == "web_search":
|
if tool_name == "web_search":
|
||||||
return web_search(str(tool_input.get("query", "")))
|
return web_search(str(tool_input.get("query", "")))
|
||||||
if tool_name == "fetch_url":
|
if tool_name == "fetch_url":
|
||||||
@@ -1048,7 +1087,33 @@ MCP_TOOL_SPECS: list[dict[str, Any]] = [
|
|||||||
"description": "New content to insert."
|
"description": "New content to insert."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["path", "start_line", "end_line", "new_content"]
|
"required": ["path", "start_line", "end_line", "new_content"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "edit_file",
|
||||||
|
"description": "Replace exact string match in a file. Preserves indentation and line endings. Drop-in replacement for native edit tool.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file."
|
||||||
|
},
|
||||||
|
"old_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The text to replace."
|
||||||
|
},
|
||||||
|
"new_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The replacement text."
|
||||||
|
},
|
||||||
|
"replace_all": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Replace all occurrences. Default false."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path", "old_string", "new_string"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user