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:
2026-03-04 23:00:13 -05:00
parent c5418acbfe
commit cbe58936f5
4 changed files with 71 additions and 2 deletions

View File

@@ -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 |

View File

@@ -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) |

View File

@@ -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) |

View File

@@ -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":
@@ -1051,6 +1090,32 @@ MCP_TOOL_SPECS: list[dict[str, Any]] = [
"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"]
}
},
{ {
"name": "py_get_definition", "name": "py_get_definition",
"description": ( "description": (