* Reorganize the examples folder * Add two mcp server examples. A local filesystem server and a simple 'starter' server.
449 lines
16 KiB
Python
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
|