218 lines
8.3 KiB
Python
218 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
A grep server using MCP (Model Context Protocol).
|
|
This server provides grep functionality to search for regular expression patterns within files.
|
|
|
|
Implementation logic inspired by Gemini CLI's grep.ts:
|
|
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/tools/grep.ts
|
|
Adapted from TypeScript to Python implementation with similar fallback strategy.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
from typing import Dict, Any, Optional
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
# Create the MCP server
|
|
mcp = FastMCP("Grep Server")
|
|
|
|
|
|
def is_git_repository(path: str) -> bool:
|
|
"""Check if the given path is inside a git repository."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--git-dir"],
|
|
cwd=path,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except (subprocess.SubprocessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
return False
|
|
|
|
|
|
@mcp.tool()
|
|
def search_text(
|
|
pattern: str, path: Optional[str] = None, include: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
|
|
|
Args:
|
|
pattern: The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').
|
|
path: Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.
|
|
include: Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).
|
|
|
|
Returns:
|
|
A dictionary containing search results with file paths, line numbers, and matching lines.
|
|
"""
|
|
# Use current working directory if no path specified
|
|
search_path = path if path else os.getcwd()
|
|
|
|
# Validate that the search path exists
|
|
if not os.path.exists(search_path):
|
|
return {"error": f"Path does not exist: {search_path}", "matches": []}
|
|
|
|
try:
|
|
# Check if we're in a git repository and try git grep first
|
|
if is_git_repository(search_path):
|
|
try:
|
|
# Build git grep command
|
|
git_cmd = ["git", "grep", "-n", "-E"]
|
|
|
|
# Add include pattern if specified (git grep uses different syntax)
|
|
if include:
|
|
git_cmd.extend(["--", include])
|
|
else:
|
|
git_cmd.append("--")
|
|
|
|
# Add pattern
|
|
git_cmd.insert(-1, pattern) # Insert pattern before the "--" separator
|
|
|
|
# Execute git grep command
|
|
result = subprocess.run(
|
|
git_cmd,
|
|
cwd=search_path,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="ignore",
|
|
timeout=30,
|
|
)
|
|
|
|
# If git grep succeeds, use its output
|
|
if result.returncode == 0:
|
|
# Parse git grep output and return results
|
|
matches = []
|
|
if result.stdout:
|
|
for line in result.stdout.strip().split("\n"):
|
|
if ":" in line:
|
|
# Parse git grep output format: filepath:line_number:content
|
|
parts = line.split(":", 2)
|
|
if len(parts) >= 3:
|
|
file_path = parts[0]
|
|
try:
|
|
line_number = int(parts[1])
|
|
line_content = parts[2]
|
|
matches.append(
|
|
{
|
|
"file": os.path.join(
|
|
search_path, file_path
|
|
)
|
|
if not os.path.isabs(file_path)
|
|
else file_path,
|
|
"line_number": line_number,
|
|
"line_content": line_content,
|
|
"match": pattern,
|
|
}
|
|
)
|
|
except ValueError:
|
|
continue
|
|
|
|
return {
|
|
"pattern": pattern,
|
|
"search_path": search_path,
|
|
"total_matches": len(matches),
|
|
"matches": matches,
|
|
"command": " ".join(git_cmd),
|
|
"method": "git grep",
|
|
}
|
|
|
|
except (
|
|
subprocess.SubprocessError,
|
|
subprocess.TimeoutExpired,
|
|
FileNotFoundError,
|
|
):
|
|
# Git grep failed, fall back to regular grep
|
|
pass
|
|
|
|
# Fallback: Build regular grep command
|
|
cmd = [
|
|
"grep",
|
|
"-n",
|
|
"-r",
|
|
"-E",
|
|
] # -n for line numbers, -r for recursive, -E for extended regex
|
|
|
|
# Add include pattern if specified
|
|
if include:
|
|
cmd.extend(["--include", include])
|
|
|
|
# Add common exclusions
|
|
cmd.extend(
|
|
[
|
|
"--exclude-dir=.git",
|
|
"--exclude-dir=node_modules",
|
|
"--exclude-dir=__pycache__",
|
|
"--exclude-dir=.svn",
|
|
"--exclude-dir=.hg",
|
|
"--exclude-dir=venv",
|
|
"--exclude-dir=env",
|
|
"--exclude=*.pyc",
|
|
"--exclude=*.pyo",
|
|
"--exclude=*.so",
|
|
"--exclude=*.dll",
|
|
"--exclude=*.exe",
|
|
"--exclude=*.jpg",
|
|
"--exclude=*.jpeg",
|
|
"--exclude=*.png",
|
|
"--exclude=*.gif",
|
|
"--exclude=*.zip",
|
|
"--exclude=*.tar",
|
|
"--exclude=*.gz",
|
|
"--exclude=*.pdf",
|
|
"--exclude=*.wasm",
|
|
]
|
|
)
|
|
|
|
# Add pattern and search path
|
|
cmd.extend([pattern, search_path])
|
|
|
|
# Execute grep command
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore"
|
|
)
|
|
|
|
# Parse grep output
|
|
matches = []
|
|
if result.stdout:
|
|
for line in result.stdout.strip().split("\n"):
|
|
if ":" in line:
|
|
# Parse grep output format: filepath:line_number:content
|
|
parts = line.split(":", 2)
|
|
if len(parts) >= 3:
|
|
file_path = parts[0]
|
|
try:
|
|
line_number = int(parts[1])
|
|
line_content = parts[2]
|
|
matches.append(
|
|
{
|
|
"file": file_path,
|
|
"line_number": line_number,
|
|
"line_content": line_content,
|
|
"match": pattern, # grep already matched, so pattern is the match
|
|
}
|
|
)
|
|
except ValueError:
|
|
# Skip malformed lines
|
|
continue
|
|
|
|
return {
|
|
"pattern": pattern,
|
|
"search_path": search_path,
|
|
"total_matches": len(matches),
|
|
"matches": matches,
|
|
"command": " ".join(cmd), # Include the actual command for debugging
|
|
"method": "system grep",
|
|
}
|
|
|
|
except subprocess.SubprocessError as e:
|
|
return {"error": f"Grep command failed: {str(e)}", "matches": []}
|
|
except Exception as e:
|
|
return {"error": f"Unexpected error: {str(e)}", "matches": []}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run the server with stdio transport
|
|
mcp.run(transport="stdio")
|