arcade-mcp/examples/mcp_servers/local_filesystem_server/tools.py
Eric Gustin d7107c107d
Update examples (#601)
* Reorganize the examples folder
* Add two mcp server examples. A local filesystem server and a simple
'starter' server.
2025-10-03 17:37:22 -07:00

449 lines
16 KiB
Python

import base64
import fnmatch
import os
import re
import shutil
import stat
from datetime import datetime
from pathlib import Path
from typing import Annotated
from arcade_mcp_server import tool
def _split_globs(globs: str) -> list[str]:
patterns = [p.strip() for p in globs.split(",") if p.strip()]
return patterns
def _is_hidden(path: Path) -> bool:
return path.name.startswith(".")
def _match_any(name: str, patterns: list[str]) -> bool:
if not patterns:
return True
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def _match_none(name: str, patterns: list[str]) -> bool:
return all(not fnmatch.fnmatch(name, pattern) for pattern in patterns)
def _path_type(path: Path, follow_symlinks: bool) -> str:
try:
st = path.stat() if follow_symlinks else path.lstat()
mode = st.st_mode
if stat.S_ISDIR(mode):
return "dir"
if stat.S_ISLNK(mode):
return "symlink"
return "file" # noqa: TRY300
except FileNotFoundError:
return "unknown"
@tool
def list_directory(
path: Annotated[str, "Directory to list"],
recursive: Annotated[bool, "Recurse into subdirectories"] = False,
max_depth: Annotated[int, "Maximum recursion depth (1 = just this dir)"] = 1,
include_globs: Annotated[str, "Comma-separated include patterns (e.g. *.py,*.md)"] = "",
exclude_globs: Annotated[str, "Comma-separated exclude patterns"] = "",
show_hidden: Annotated[bool, "Include dotfiles"] = False,
follow_symlinks: Annotated[bool, "Follow symlinks during traversal"] = False,
max_entries: Annotated[int, "Maximum number of entries to return"] = 1000,
) -> list[dict]:
"""Enumerate files and folders with metadata."""
root = Path(path).expanduser().resolve()
includes = _split_globs(include_globs)
excludes = _split_globs(exclude_globs)
results: list[dict] = []
entries_count = 0
def add_entry(p: Path) -> None:
nonlocal entries_count
try:
st = p.stat() if follow_symlinks else p.lstat()
except FileNotFoundError:
return
entry_type = _path_type(p, follow_symlinks)
if not show_hidden and _is_hidden(p):
return
name = p.name
if not _match_any(name, includes) or not _match_none(name, excludes):
return
results.append(
{
"path": str(p),
"name": name,
"type": entry_type,
"size": int(st.st_size),
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
"mode": int(st.st_mode),
"is_symlink": p.is_symlink(),
}
)
entries_count += 1
if not root.exists() or not root.is_dir():
return []
if not recursive:
for child in root.iterdir():
if entries_count >= max_entries:
break
add_entry(child)
return results
# Recursive traversal with depth control
start_depth = len(root.parts)
for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
current = Path(dirpath)
depth = len(current.parts) - start_depth + 1
if depth > max_depth:
# Prevent descent by clearing dirnames
dirnames[:] = []
continue
# Optionally filter hidden directories early
if not show_hidden:
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in dirnames + filenames:
if entries_count >= max_entries:
break
add_entry(current / name)
if entries_count >= max_entries:
break
return results
@tool
def read_file(
path: Annotated[str, "Path to file"],
binary: Annotated[bool, "Return base64 when true"] = False,
encoding: Annotated[str, "Text encoding when binary is false"] = "utf-8",
start: Annotated[int, "Start byte offset (0-based)"] = 0,
end: Annotated[int, "End byte offset (0 for EOF)"] = 0,
max_bytes: Annotated[int, "Maximum bytes to read (0 for unlimited)"] = 1048576,
) -> dict:
"""Read file contents safely with optional slicing."""
p = Path(path).expanduser().resolve()
if not p.exists() or not p.is_file():
return {"content": "", "bytes_read": 0, "encoding": encoding, "truncated": False}
total_size = p.stat().st_size
start_offset = max(0, start)
end_offset = total_size if end <= 0 else min(end, total_size)
if end_offset < start_offset:
end_offset = start_offset
to_read = end_offset - start_offset
if max_bytes > 0:
to_read = min(to_read, max_bytes)
with open(p, "rb") as f:
f.seek(start_offset)
data = f.read(to_read)
truncated = False
if (end <= 0 and max_bytes > 0 and (start_offset + len(data)) < total_size) or (
end > 0 and len(data) < (end_offset - start_offset)
):
truncated = True
if binary:
return {
"content": base64.b64encode(data).decode("ascii"),
"bytes_read": len(data),
"encoding": "base64",
"truncated": truncated,
}
else:
text = data.decode(encoding, errors="replace")
return {
"content": text,
"bytes_read": len(data),
"encoding": encoding,
"truncated": truncated,
}
@tool
def write_file(
path: Annotated[str, "Path to write"],
content: Annotated[str, "Text or base64 when binary=true"],
binary: Annotated[bool, "Interpret content as base64 when true"] = False,
encoding: Annotated[str, "Text encoding when binary is false"] = "utf-8",
mode: Annotated[str, "overwrite | append | create_new"] = "overwrite",
make_parents: Annotated[bool, "Create parent directories if needed"] = True,
) -> dict:
"""Create/overwrite/append text or binary file."""
p = Path(path).expanduser().resolve()
if make_parents:
p.parent.mkdir(parents=True, exist_ok=True)
data = base64.b64decode(content) if binary else content.encode(encoding)
created = False
appended = False
if mode == "create_new":
if p.exists():
raise FileExistsError(f"Path already exists: {p}")
with open(p, "wb") as f:
f.write(data)
created = True
elif mode == "append":
with open(p, "ab") as f:
f.write(data)
appended = True
created = not (p.stat().st_size > len(data)) # heuristic
elif mode == "overwrite":
with open(p, "wb") as f:
f.write(data)
created = True
else:
raise ValueError("Invalid mode. Use overwrite | append | create_new")
return {"bytes_written": len(data), "created": created, "appended": appended}
@tool
def tail_file(
path: Annotated[str, "Path to file"],
lines: Annotated[int, "Number of lines from end (ignored if bytes>0)"] = 100,
bytes: Annotated[int, "Number of bytes from end (0 to disable)"] = 0,
encoding: Annotated[str, "Text encoding for output"] = "utf-8",
) -> dict:
"""Return last N lines or bytes from a file."""
p = Path(path).expanduser().resolve()
if not p.exists() or not p.is_file():
return {"content": "", "truncated": False}
size = p.stat().st_size
if bytes > 0:
start = max(0, size - bytes)
with open(p, "rb") as f:
f.seek(start)
data = f.read(bytes)
text = data.decode(encoding, errors="replace")
return {"content": text, "truncated": start > 0}
# Tail by lines
block_size = 8192
data = bytearray()
newline_count = 0
with open(p, "rb") as f:
pos = size
while pos > 0 and newline_count <= lines:
read_size = block_size if pos >= block_size else pos
pos -= read_size
f.seek(pos)
chunk = f.read(read_size)
data[:0] = chunk
newline_count = data.count(b"\n")
if len(data) > 8 * 1024 * 1024: # 8MB safety cap
break
decoded = data.decode(encoding, errors="replace")
split_lines = decoded.splitlines()[-lines:]
return {"content": "\n".join(split_lines), "truncated": len(split_lines) < lines}
@tool
def stat_path(
path: Annotated[str, "Path to stat"],
follow_symlinks: Annotated[bool, "Follow symlinks"] = False,
) -> dict:
"""Return metadata for file/dir/symlink."""
p = Path(path).expanduser().resolve()
exists = p.exists() or p.is_symlink()
try:
st = p.stat() if follow_symlinks else p.lstat()
except FileNotFoundError:
return {"exists": False}
info = {
"exists": exists,
"path": str(p),
"type": _path_type(p, follow_symlinks),
"size": int(st.st_size),
"mode": int(st.st_mode),
"uid": int(getattr(st, "st_uid", 0)),
"gid": int(getattr(st, "st_gid", 0)),
"atime": datetime.fromtimestamp(st.st_atime).isoformat(),
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
"ctime": datetime.fromtimestamp(st.st_ctime).isoformat(),
"is_symlink": p.is_symlink(),
}
if p.is_symlink():
try:
info["target"] = str(p.resolve(strict=False))
except Exception:
info["target"] = None
return info
@tool
def create_directory(
path: Annotated[str, "Directory path to create"],
exist_ok: Annotated[bool, "Do not error if directory exists"] = True,
parents: Annotated[bool, "Create missing parents"] = True,
mode: Annotated[int, "POSIX permissions like 0o755"] = 0o755,
) -> dict:
"""Create a directory path."""
p = Path(path).expanduser().resolve()
if parents:
p.mkdir(mode=mode, parents=True, exist_ok=exist_ok)
else:
if p.exists() and not exist_ok:
raise FileExistsError(f"Directory exists: {p}")
p.mkdir(mode=mode)
return {"created": True}
@tool
def move_path(
src: Annotated[str, "Source path"],
dst: Annotated[str, "Destination path"],
overwrite: Annotated[bool, "Replace destination if exists"] = False,
make_parents: Annotated[bool, "Create destination parents"] = True,
) -> dict:
"""Move/rename files or directories."""
src_p = Path(src).expanduser().resolve()
dst_p = Path(dst).expanduser().resolve()
if make_parents:
dst_p.parent.mkdir(parents=True, exist_ok=True)
if dst_p.exists():
if overwrite:
# Atomic replace for files; for dirs, move into or replace
if src_p.is_file():
os.replace(src_p, dst_p)
else:
# Move into destination if it's an existing directory
if dst_p.is_dir():
shutil.move(str(src_p), str(dst_p))
else:
os.replace(src_p, dst_p)
else:
raise FileExistsError(f"Destination exists: {dst_p}")
else:
shutil.move(str(src_p), str(dst_p))
return {"moved": True}
@tool
def copy_path(
src: Annotated[str, "Source path"],
dst: Annotated[str, "Destination path"],
overwrite: Annotated[bool, "Replace destination if exists"] = False,
preserve_metadata: Annotated[bool, "Preserve file metadata"] = True,
follow_symlinks: Annotated[bool, "Follow symlinks when copying files"] = False,
make_parents: Annotated[bool, "Create destination parents"] = True,
) -> dict:
"""Copy file or directory tree."""
src_p = Path(src).expanduser().resolve()
dst_p = Path(dst).expanduser().resolve()
if make_parents:
dst_p.parent.mkdir(parents=True, exist_ok=True)
if src_p.is_dir():
shutil.copytree(
src=str(src_p),
dst=str(dst_p),
dirs_exist_ok=overwrite,
copy_function=shutil.copy2 if preserve_metadata else shutil.copy,
)
else:
if dst_p.exists() and not overwrite:
raise FileExistsError(f"Destination exists: {dst_p}")
copy_fn = shutil.copy2 if preserve_metadata else shutil.copy
copy_fn(str(src_p), str(dst_p), follow_symlinks=follow_symlinks)
return {"copied": True}
@tool
def search_files(
root: Annotated[str, "Root directory to search"],
name_globs: Annotated[str, "Comma-separated include name patterns"] = "",
exclude_globs: Annotated[str, "Comma-separated exclude name patterns"] = "",
regex: Annotated[str, "Content regex (ignored when fixed_string=true)"] = "",
fixed_string: Annotated[bool, "Search content by plain substring"] = False,
case_insensitive: Annotated[bool, "Regex or substring is case-insensitive"] = False,
max_results: Annotated[int, "Maximum number of results"] = 100,
follow_symlinks: Annotated[bool, "Follow symlinks"] = False,
binary: Annotated[bool, "Search binary files too"] = False,
include_hidden: Annotated[bool, "Include hidden files"] = False,
) -> list[dict]:
"""Find files by name and optionally by content."""
base = Path(root).expanduser().resolve()
if not base.exists() or not base.is_dir():
return []
includes = _split_globs(name_globs)
excludes = _split_globs(exclude_globs)
flags = re.IGNORECASE if case_insensitive else 0
pattern = None
if regex and not fixed_string:
try:
pattern = re.compile(regex, flags)
except re.error as e:
raise ValueError(f"Invalid regex: {e}")
results: list[dict] = []
for dirpath, dirnames, filenames in os.walk(base, followlinks=follow_symlinks):
current = Path(dirpath)
if not include_hidden:
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in filenames:
if len(results) >= max_results:
return results
if not include_hidden and name.startswith("."):
continue
if includes and not _match_any(name, includes):
continue
if not _match_none(name, excludes):
continue
file_path = current / name
if not binary:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except Exception:
continue
matched = False
match_lines: list[dict] = []
if fixed_string and regex:
hay = content if not case_insensitive else content.lower()
needle = regex if not case_insensitive else regex.lower()
if needle in hay:
matched = True
for idx, line in enumerate(content.splitlines(), start=1):
if needle in (line if not case_insensitive else line.lower()):
match_lines.append({"line": idx, "text": line})
elif pattern is not None:
for idx, line in enumerate(content.splitlines(), start=1):
if pattern.search(line):
matched = True
match_lines.append({"line": idx, "text": line})
else:
matched = True # name matched and no content filter
if matched:
results.append(
{
"path": str(file_path),
"match_lines": match_lines,
"match_count": len(match_lines),
}
)
else:
# Binary search only supports name filtering (no content scan)
results.append({"path": str(file_path), "match_lines": [], "match_count": 0})
return results