add file_edit, file_move, file_glob, file_grep tools and improve existing file tools
- file_read: numbered output (cat -n), offset/limit for chunked reading - file_write: updated description to prefer file_edit for partial changes - file_edit: partial string replacement (old_string → new_string), fails on ambiguous matches - file_move: rename/move files and directories within /workspace - file_glob: file discovery via glob patterns (e.g. **/*.py) - file_grep: regex search in file contents with context lines, glob filter, ignore_case - exec: new cwd parameter to run commands in subdirectories of /workspace
This commit is contained in:
+145
-7
@@ -4,6 +4,7 @@ Implements MCP Streamable HTTP transport (JSON-RPC 2.0)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import fnmatch
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -143,11 +144,12 @@ _jobs: dict[str, BackgroundJob] = {}
|
|||||||
TOOLS = [
|
TOOLS = [
|
||||||
{
|
{
|
||||||
"name": "exec",
|
"name": "exec",
|
||||||
"description": "Run a shell command and return stdout+stderr synchronously (max 30 s).",
|
"description": "Run a shell command inside /workspace and return stdout+stderr synchronously (max 30 s). Use cwd to run in a subdirectory.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"cmd": {"type": "string", "description": "Shell command to execute"},
|
"cmd": {"type": "string", "description": "Shell command to execute"},
|
||||||
|
"cwd": {"type": "string", "description": "Working directory relative to /workspace (default: /workspace)"},
|
||||||
"timeout": {"type": "integer", "description": "Timeout in seconds (default 30)", "default": 30},
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default 30)", "default": 30},
|
||||||
},
|
},
|
||||||
"required": ["cmd"],
|
"required": ["cmd"],
|
||||||
@@ -250,27 +252,43 @@ TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_read",
|
"name": "file_read",
|
||||||
"description": "Read the content of a file inside /workspace.",
|
"description": "Read a file inside /workspace. Returns content with 1-based line numbers (cat -n format). Use offset and limit to read large files in chunks.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Path relative to /workspace"},
|
"path": {"type": "string", "description": "Path relative to /workspace"},
|
||||||
|
"offset": {"type": "integer", "description": "Line number to start reading from (1-based, default: 1)"},
|
||||||
|
"limit": {"type": "integer", "description": "Maximum number of lines to return"},
|
||||||
},
|
},
|
||||||
"required": ["path"],
|
"required": ["path"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_write",
|
"name": "file_write",
|
||||||
"description": "Write (create or overwrite) a file inside /workspace.",
|
"description": "Write (create or overwrite) a file inside /workspace with the given content. Prefer file_edit for modifying existing files.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string", "description": "Path relative to /workspace"},
|
"path": {"type": "string", "description": "Path relative to /workspace"},
|
||||||
"content": {"type": "string", "description": "File content to write"},
|
"content": {"type": "string", "description": "Full file content to write"},
|
||||||
},
|
},
|
||||||
"required": ["path", "content"],
|
"required": ["path", "content"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "file_edit",
|
||||||
|
"description": "Perform an exact string replacement in a file inside /workspace. old_string must appear exactly once unless replace_all is true. Fails if old_string is not found or is ambiguous.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Path relative to /workspace"},
|
||||||
|
"old_string": {"type": "string", "description": "Exact text to find and replace"},
|
||||||
|
"new_string": {"type": "string", "description": "Replacement text"},
|
||||||
|
"replace_all": {"type": "boolean", "description": "Replace all occurrences (default: false)", "default": False},
|
||||||
|
},
|
||||||
|
"required": ["path", "old_string", "new_string"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "file_list",
|
"name": "file_list",
|
||||||
"description": "List files and directories inside /workspace (optionally a subdirectory).",
|
"description": "List files and directories inside /workspace (optionally a subdirectory).",
|
||||||
@@ -292,6 +310,46 @@ TOOLS = [
|
|||||||
"required": ["path"],
|
"required": ["path"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "file_move",
|
||||||
|
"description": "Move or rename a file or directory inside /workspace.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"src": {"type": "string", "description": "Source path relative to /workspace"},
|
||||||
|
"dst": {"type": "string", "description": "Destination path relative to /workspace"},
|
||||||
|
},
|
||||||
|
"required": ["src", "dst"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "file_glob",
|
||||||
|
"description": "Find files inside /workspace matching a glob pattern (e.g. '**/*.py'). Returns matching paths relative to /workspace.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string", "description": "Glob pattern (e.g. '**/*.py', 'src/**/*.ts')"},
|
||||||
|
"path": {"type": "string", "description": "Subdirectory to search in (default: workspace root)", "default": ""},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "file_grep",
|
||||||
|
"description": "Search file contents inside /workspace using a regex pattern. Returns matching lines with file path and line number.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string", "description": "Regular expression to search for"},
|
||||||
|
"path": {"type": "string", "description": "File or directory to search in (default: workspace root)", "default": ""},
|
||||||
|
"glob": {"type": "string", "description": "Only search files matching this glob (e.g. '*.py')"},
|
||||||
|
"context": {"type": "integer", "description": "Lines of context before and after each match (default: 0)", "default": 0},
|
||||||
|
"ignore_case": {"type": "boolean", "description": "Case-insensitive search (default: false)", "default": False},
|
||||||
|
"max_matches": {"type": "integer", "description": "Maximum number of matches to return (default: 100)", "default": 100},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# ─── Tool dispatch ────────────────────────────────────────────────────────────
|
# ─── Tool dispatch ────────────────────────────────────────────────────────────
|
||||||
@@ -300,12 +358,13 @@ async def _dispatch(name: str, args: dict) -> dict:
|
|||||||
if name == "exec":
|
if name == "exec":
|
||||||
cmd = args.get("cmd", "")
|
cmd = args.get("cmd", "")
|
||||||
timeout = int(args.get("timeout", 30))
|
timeout = int(args.get("timeout", 30))
|
||||||
log.info("exec: %s", cmd)
|
cwd = _workspace_path(args["cwd"]) if args.get("cwd") else pathlib.Path(WORKSPACE)
|
||||||
|
log.info("exec (cwd=%s): %s", cwd, cmd)
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
cmd, shell=True, capture_output=True, text=True,
|
cmd, shell=True, capture_output=True, text=True,
|
||||||
timeout=timeout, executable="/bin/bash",
|
timeout=timeout, executable="/bin/bash",
|
||||||
cwd=WORKSPACE,
|
cwd=str(cwd),
|
||||||
)
|
)
|
||||||
return {"stdout": r.stdout, "stderr": r.stderr, "exit_code": r.returncode}
|
return {"stdout": r.stdout, "stderr": r.stderr, "exit_code": r.returncode}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -385,7 +444,12 @@ async def _dispatch(name: str, args: dict) -> dict:
|
|||||||
elif name == "file_read":
|
elif name == "file_read":
|
||||||
p = _workspace_path(args["path"])
|
p = _workspace_path(args["path"])
|
||||||
log.info("file_read: %s", p)
|
log.info("file_read: %s", p)
|
||||||
return {"path": args["path"], "content": p.read_text(encoding="utf-8")}
|
all_lines = p.read_text(encoding="utf-8").splitlines()
|
||||||
|
offset = max(1, int(args.get("offset", 1))) - 1 # convert to 0-based
|
||||||
|
limit = args.get("limit")
|
||||||
|
sliced = all_lines[offset:offset + int(limit)] if limit is not None else all_lines[offset:]
|
||||||
|
numbered = "\n".join(f"{offset + i + 1}\t{line}" for i, line in enumerate(sliced))
|
||||||
|
return {"path": args["path"], "content": numbered, "total_lines": len(all_lines)}
|
||||||
|
|
||||||
elif name == "file_write":
|
elif name == "file_write":
|
||||||
p = _workspace_path(args["path"])
|
p = _workspace_path(args["path"])
|
||||||
@@ -394,6 +458,28 @@ async def _dispatch(name: str, args: dict) -> dict:
|
|||||||
p.write_text(args.get("content", ""), encoding="utf-8")
|
p.write_text(args.get("content", ""), encoding="utf-8")
|
||||||
return {"path": args["path"], "written": len(args.get("content", ""))}
|
return {"path": args["path"], "written": len(args.get("content", ""))}
|
||||||
|
|
||||||
|
elif name == "file_edit":
|
||||||
|
p = _workspace_path(args["path"])
|
||||||
|
old_string = args["old_string"]
|
||||||
|
new_string = args["new_string"]
|
||||||
|
replace_all = bool(args.get("replace_all", False))
|
||||||
|
log.info("file_edit: %s replace_all=%s", p, replace_all)
|
||||||
|
content = p.read_text(encoding="utf-8")
|
||||||
|
count = content.count(old_string)
|
||||||
|
if count == 0:
|
||||||
|
raise ValueError(f"old_string not found in {args['path']}")
|
||||||
|
if not replace_all and count > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"old_string is not unique in {args['path']} ({count} occurrences). "
|
||||||
|
"Use replace_all=true to replace all, or provide more context to make it unique."
|
||||||
|
)
|
||||||
|
if replace_all:
|
||||||
|
new_content = content.replace(old_string, new_string)
|
||||||
|
else:
|
||||||
|
new_content = content.replace(old_string, new_string, 1)
|
||||||
|
p.write_text(new_content, encoding="utf-8")
|
||||||
|
return {"path": args["path"], "replacements": count if replace_all else 1}
|
||||||
|
|
||||||
elif name == "file_list":
|
elif name == "file_list":
|
||||||
p = _workspace_path(args.get("path", ""))
|
p = _workspace_path(args.get("path", ""))
|
||||||
log.info("file_list: %s", p)
|
log.info("file_list: %s", p)
|
||||||
@@ -409,6 +495,58 @@ async def _dispatch(name: str, args: dict) -> dict:
|
|||||||
p.unlink()
|
p.unlink()
|
||||||
return {"deleted": args["path"]}
|
return {"deleted": args["path"]}
|
||||||
|
|
||||||
|
elif name == "file_move":
|
||||||
|
src = _workspace_path(args["src"])
|
||||||
|
dst = _workspace_path(args["dst"])
|
||||||
|
log.info("file_move: %s -> %s", src, dst)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
src.rename(dst)
|
||||||
|
return {"src": args["src"], "dst": args["dst"]}
|
||||||
|
|
||||||
|
elif name == "file_glob":
|
||||||
|
base = _workspace_path(args.get("path", ""))
|
||||||
|
pattern = args["pattern"]
|
||||||
|
log.info("file_glob: %s in %s", pattern, base)
|
||||||
|
matches = sorted(
|
||||||
|
str(p.relative_to(pathlib.Path(WORKSPACE)))
|
||||||
|
for p in base.glob(pattern)
|
||||||
|
if p.is_file()
|
||||||
|
)
|
||||||
|
return {"pattern": pattern, "matches": matches, "count": len(matches)}
|
||||||
|
|
||||||
|
elif name == "file_grep":
|
||||||
|
base = _workspace_path(args.get("path", ""))
|
||||||
|
pattern = args["pattern"]
|
||||||
|
glob_filter = args.get("glob")
|
||||||
|
context_lines = int(args.get("context", 0))
|
||||||
|
ignore_case = bool(args.get("ignore_case", False))
|
||||||
|
max_matches = int(args.get("max_matches", 100))
|
||||||
|
log.info("file_grep: %r in %s", pattern, base)
|
||||||
|
flags = re.IGNORECASE if ignore_case else 0
|
||||||
|
regex = re.compile(pattern, flags)
|
||||||
|
results = []
|
||||||
|
files = base.rglob("*") if base.is_dir() else [base]
|
||||||
|
for filepath in sorted(files):
|
||||||
|
if not filepath.is_file():
|
||||||
|
continue
|
||||||
|
if glob_filter and not fnmatch.fnmatch(filepath.name, glob_filter):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if regex.search(line):
|
||||||
|
rel = str(filepath.relative_to(pathlib.Path(WORKSPACE)))
|
||||||
|
entry = {"file": rel, "line": i + 1, "match": line}
|
||||||
|
if context_lines:
|
||||||
|
entry["before"] = lines[max(0, i - context_lines):i]
|
||||||
|
entry["after"] = lines[i + 1:i + 1 + context_lines]
|
||||||
|
results.append(entry)
|
||||||
|
if len(results) >= max_matches:
|
||||||
|
return {"pattern": pattern, "matches": results, "truncated": True}
|
||||||
|
return {"pattern": pattern, "matches": results, "truncated": False}
|
||||||
|
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user