MCP File System Server
by kvas-it
import os
import ast
import re
import shutil
import subprocess
from typing import List, Tuple, Dict, Union
from pathlib import Path
from mcp.server.fastmcp import FastMCP
RUFF_PATH = Path(__file__).parent / ".venv" / "bin" / "ruff"
mcp = FastMCP("FileSystem")
@mcp.tool()
def ls(path: str) -> list[str]:
"""List files in a directory.
:param path: Path to the directory. Can be relative to current working
directory (see cd tool).
"""
return os.listdir(path)
@mcp.tool()
def cd(path: str) -> None:
"""Change the current working directory.
:param path: New working directory. Can be relative to current working
directory. Supports home directory expansion (~/path).
"""
expanded_path = os.path.expanduser(path)
os.chdir(expanded_path)
@mcp.tool()
def read_file(path: str) -> str:
"""Read a file from the filesystem.
:param path: Path to the file. Can be relative (see cd tool).
"""
with open(path, "rt", encoding="utf-8") as f:
return f.read()
@mcp.tool()
def write_file(path: str, content: str) -> None:
"""Write content to a file.
:param path: Path to the file. Can be relative (see cd tool).
:param content: Content to write to the file.
"""
with open(path, "wt", encoding="utf-8") as f:
f.write(content)
@mcp.tool()
def edit_file(path: str, changes: List[Tuple[str, str]]) -> None:
"""Apply sequence of search/replace operations to a file.
:param path: Path to the file to edit. Can be relative (see cd tool).
:param changes: List of (search_text, replace_text) tuples.
Ideally, try to replace entire lines to avoid partial matches. Including
a few lines of context in the search text helps to ensure the right match.
"""
with open(path, "rt", encoding="utf-8") as f:
content = f.read()
for search, replace in changes:
old_content = content
content = content.replace(search, replace)
if old_content == content:
raise ValueError(f"Search text not found in file:\n\n{search}")
with open(path, "wt", encoding="utf-8") as f:
f.write(content)
@mcp.tool()
def summary(path: str) -> List[str]:
"""Generate a summary of a Python or Markdown file.
:param path: Path to file to summarize. Supports .py and .md files.
:returns: List of important lines (headers for md, functions/classes for py)
"""
with open(path, "rt", encoding="utf-8") as f:
content = f.read()
if path.endswith(".md"):
return [
line.strip() for line in content.split("\n") if line.strip().startswith("#")
]
elif path.endswith(".py"):
tree = ast.parse(content)
return [
f"{type(node).__name__}: {node.name}"
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.ClassDef))
]
raise ValueError(f"Unsupported file type: {path}")
@mcp.tool()
def mkdir(path: str) -> None:
"""Create a directory.
:param path: Directory path to create. Can be relative.
"""
os.makedirs(path, exist_ok=True)
@mcp.tool()
def rm(path: str) -> None:
"""Remove a file or empty directory.
:param path: Path to remove. Can be relative.
"""
if os.path.isdir(path):
os.rmdir(path) # Will fail if not empty
else:
os.unlink(path)
@mcp.tool()
def rmdir(path: str) -> None:
"""Remove directory and all its contents.
:param path: Directory to remove. Can be relative.
"""
shutil.rmtree(path)
@mcp.tool()
def cp(src: str, dst: str) -> None:
"""Copy file or directory.
:param src: Source path. Can be relative.
:param dst: Destination path. Can be relative.
"""
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
@mcp.tool()
def mv(src: str, dst: str) -> None:
"""Move file or directory.
:param src: Source path. Can be relative.
:param dst: Destination path. Can be relative.
"""
shutil.move(src, dst)
@mcp.tool()
def grep(pattern: str, path: str) -> List[str]:
"""Search for pattern in file(s).
:param pattern: Regular expression to search for
:param path: Path to file or directory. If directory, searches recursively.
:returns: List of matches in format "filename:line_number:matched_line"
"""
results = []
pattern = re.compile(pattern)
def search_file(filepath):
try:
with open(filepath, "rt", encoding="utf-8") as f:
for i, line in enumerate(f, 1):
if pattern.search(line):
results.append(f"{filepath}:{i}:{line.rstrip()}")
except UnicodeDecodeError:
pass # Skip binary files
if os.path.isfile(path):
search_file(path)
else:
for root, _, files in os.walk(path):
for file in files:
search_file(os.path.join(root, file))
return results
@mcp.tool()
def read_files(paths: list[str]) -> Dict[str, str]:
"""Read multiple files.
:param paths: List of file paths
:returns: List of file contents in same order
"""
return {path: read_file(path) for path in paths}
@mcp.tool()
def summarize(paths: list[str]) -> Dict[str, list[str]]:
"""Generate summaries for multiple files.
:param paths: List of file paths
:returns: List of summaries in same order as input paths
"""
return {path: summary(path) for path in paths}
def _read_claude_md() -> str:
"""Read the CLAUDE.md file, or return empty string if not found"""
claude_md = Path.cwd() / "CLAUDE.md"
if claude_md.exists():
with open(claude_md, "r", encoding="utf-8") as f:
return f.read()
return ""
@mcp.tool()
def work_on(path: str) -> Dict[str, Union[List[str], str]]:
"""Change to directory, list its contents, get the notes from CLAUDE.md.
Useful for getting familiar with a project at the start of a chat.
:param path: Directory to work on. Supports home expansion (~/path).
:returns: Dict with 'files' and 'notes' keys
"""
cd(path)
return {"files": ls("."), "notes": _read_claude_md()}
@mcp.tool()
def ruff_check(paths: list[str]) -> Dict[str, Union[str, int]]:
"""Run ruff linter on specified files. Useful to check that nothing was broken.
:param paths: List of file paths to check
:returns: Dict with 'output' and 'exit_code' keys
"""
try:
result = subprocess.run(
[str(RUFF_PATH), "check"] + paths,
capture_output=True,
text=True,
check=False,
)
return {"output": result.stdout + result.stderr, "exit_code": result.returncode}
except FileNotFoundError:
return {
"output": "Error: ruff not found. Please install ruff package.",
"exit_code": -1,
}
@mcp.tool()
def ruff_format(paths: list[str]) -> Dict[str, Union[str, int]]:
"""Format files using ruff. Useful for fixing formatting issues after edits.
:param paths: List of file paths to format
:returns: Dict with 'output' and 'exit_code' keys
"""
try:
result = subprocess.run(
[str(RUFF_PATH), "format"] + paths,
capture_output=True,
text=True,
check=False,
)
return {"output": result.stdout + result.stderr, "exit_code": result.returncode}
except FileNotFoundError:
return {
"output": "Error: ruff not found. Please install ruff package.",
"exit_code": -1,
}
@mcp.tool()
def shell_command(
command: str, args: list[str] = None, cmdline: str = None, timeout: int = 30
) -> Dict[str, Union[str, int]]:
"""Run a shell command and return its output.
:param command: The command to run (ignored if cmdline is provided)
:param args: List of arguments for the command (ignored if cmdline is provided)
:param cmdline: Full command line string including all arguments (alternative to command+args)
:param timeout: Maximum execution time in seconds (default: 30)
:returns: Dict with 'stdout', 'stderr', and 'exit_code' keys
"""
try:
if cmdline is not None:
# Use shell=True for convenience with complex commands
result = subprocess.run(
cmdline,
shell=True,
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
else:
# Use args list for safer command execution
if args is None:
args = []
result = subprocess.run(
[command] + args,
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
except FileNotFoundError:
return {"stdout": "", "stderr": "Error: Command not found.", "exit_code": 127}
except subprocess.TimeoutExpired:
return {
"stdout": "",
"stderr": f"Error: Command timed out after {timeout} seconds.",
"exit_code": 124,
}
except Exception as e:
return {
"stdout": "",
"stderr": f"Error executing command: {str(e)}",
"exit_code": 1,
}