A local MCP server that provides fast, precise code search for any project. Designed to save Claude tokens by doing the work locally — returning only paths, line numbers, and snippets, never full file contents.
Without this server, Claude's default strategy is to read files one by one: list a directory, open a file, scan it, repeat. On a medium PHP/JS project (500–2 000 files) that pattern burns tokens fast:
| Task | Without server | With server |
|---|---|---|
Find where ContentRepository is declared |
List directory + read 5–20 files | One find_class call → path + line |
| Find all callers of a service method | Read each candidate file | One who_calls call → call sites + enclosing caller |
| Locate a test file for a source file | Read directory tree, guess name | One find_tests call → exact path |
| Find classes implementing an interface (multiline) | Read file, regex often misses multiline | One find_implementations call via AST |
| Navigate from a failing test to source | Manual path mapping | One find_source call |
Typical saving: 70–90 % of search tokens per task.
Key design principles:
- Every tool returns the minimum useful unit: path + line + ≤ 120-char snippet centered on the match.
- Results are ranked (declarations before usages, source before tests, code before comments).
- Compact output (
path:line:snippet\n) is ~60 % smaller than JSON for the same data. - Pagination (
has_more+next_cursor) prevents token waste on large result sets. .gitignorerespected by default — vendor/, var/, build/ never pollute results.
| Tool | Description |
|---|---|
find_files |
Find files by name substring or glob pattern |
directory_tree |
Show project directory tree (depth, respects excluded dirs) |
read_file |
Read a file with optional line range |
get_file_outline |
List all classes, methods and functions with line numbers (AST-backed for PHP/JS/TS) |
| Tool | Description |
|---|---|
grep_code |
Search for text/regex — returns file:line:snippet. Smart-case, whole-word, fixed-string, path-glob, multiline, pagination |
grep_with_context |
Like grep_code with N lines before/after each match (default 3, max 10) |
count_matches |
Count occurrences without returning all results — use before a full grep |
find_usages |
Whole-word symbol search with ranking and comment/string filters |
ast_search |
Structural pattern search using ast-grep syntax (e.g. class $NAME extends $BASE) |
multi_search |
Run up to 10 independent grep_code queries in parallel — total time ≈ slowest single query |
| Tool | Description |
|---|---|
find_class |
Locate a class/interface/trait/enum by name — PHP, TypeScript, TSX, JavaScript. Fuzzy matching supported |
find_method |
Locate method/function declarations by name — PHP, JS, TS, TSX, JSX (AST-backed) |
find_implementations |
Find all classes implementing an interface — PHP and TypeScript (AST-backed; JS has no interfaces) |
find_extends |
Find all classes extending a given class — PHP, JS, TS, TSX, JSX (AST-backed) |
find_route |
Find Symfony routes — PHP 8 attributes, legacy @Route annotations, and YAML files |
find_tests |
Find test files for a source file (PHPUnit, Jest, Vitest, Playwright) |
find_source |
Inverse of find_tests — navigate from a test file back to its source |
find_definition |
Universal definition lookup: class → method → route → grep, in one call |
who_calls |
Find all call sites of a method/function with the enclosing caller name |
what_calls |
List all outgoing calls from a method body |
| Tool | Description |
|---|---|
git_changed_files |
List files modified by git (unstaged, staged, or by commit SHA) |
grep_changed |
Grep only in files modified by git |
find_in_file_diff |
Return git hunk line ranges for a specific file (go straight to changed lines) |
grep_code(
query,
directory=None, # restrict to a subdirectory
extensions=None, # e.g. ["php", "ts"]
max_results=50,
case_sensitive="smart", # True | False | "smart" (default: case-insensitive when query is lowercase)
whole_word=False, # \bquery\b
fixed_string=False, # treat query as literal string (no regex)
path_glob=None, # e.g. "src/**/*.php"
respect_gitignore=True,
multiline=False, # enable rg -U (multiline). Use with anchored patterns only.
exclude_comments=False,
exclude_strings=False,
rank=True, # declarations first, comments last
output="compact", # "compact" (path:line:snippet) or "json"
cursor=None, # pagination cursor from previous call
)
find_usages(
symbol,
directory=None,
extensions=None,
max_results=50,
exclude_comments=False, # filter out comment lines
exclude_strings=False, # filter out string literals
rank=True, # declarations ranked first
output="compact",
cursor=None,
)
find_class(
class_name,
kind=None, # "class" | "interface" | "trait" | "enum"
# Note: "interface" and "trait"/"enum" are PHP/TS only
fuzzy=False, # find "AuthCodeRepo" → "AuthCodeRepository"
)
ast_search(
pattern, # ast-grep pattern, e.g. "class $NAME implements $IFACE"
lang, # "php" | "javascript" | "typescript" | "tsx" | "python"
path_glob=None,
directory=None,
max_results=50,
)
Searches all three Symfony route definition sources simultaneously:
- PHP 8 attribute syntax:
#[Route('/path', methods: ['GET'])] - Legacy annotation:
@Route('/path')in docblocks - YAML:
config/routes.yaml,config/routes/*.yaml
Single call that tries class → interface → trait → enum → method → function → route → grep fallback. Use instead of chaining find_class + find_method.
who_calls(method, class_name=None, directory=None)
→ [{path, line, snippet, caller: "enclosingMethodName"}, ...]
find_in_file_diff(path, scope="unstaged")
→ [{path, old_start, old_lines, new_start, new_lines}, ...]
Use this to jump directly to modified lines instead of reading the whole file.
multi_search(
queries=[ # up to 10 SearchQuery entries
{
query,
directory=None,
extensions=None,
max_results=None,
case_sensitive="smart",
whole_word=False,
fixed_string=False,
path_glob=None,
multiline=False,
exclude_comments=False,
exclude_strings=False,
rank=True,
output="compact",
},
...
],
max_parallel=5, # max concurrent rg processes (capped at 10)
)
→ {results: [{query, compact, has_more, token_savings, error}], token_savings}
Runs all queries concurrently. Total wall time ≈ slowest single query instead of sum of all. Use when you need several unrelated searches (e.g. class declaration + usages + tests in one call). Do not use for dependent queries where one result informs the next.
All tools work on any language for generic operations (grep, usages, outline, git). The table below covers the code-intelligence tools that require language knowledge.
AST-backed = uses tree-sitter via ast-grep-py — handles multiline declarations, generics, nested constructs. Regex = heuristic fallback when ast-grep-py is not installed.
| Tool | PHP | TypeScript | TSX | JavaScript | JSX |
|---|---|---|---|---|---|
find_class — classes |
100% AST | 100% AST | 100% AST | 90% AST ¹ | 90% AST ¹ |
find_class — interfaces |
100% AST | 100% AST | 100% AST | N/A ² | N/A ² |
find_class — traits / enums |
100% AST | N/A | N/A | N/A | N/A |
find_method |
100% AST | 90% AST ³ | 90% AST ³ | 85% AST ³ | 85% AST ³ |
find_extends |
100% AST | 100% AST | 100% AST | 100% AST | 100% AST |
find_implementations |
100% AST | 100% AST | 100% AST | N/A ² | N/A ² |
get_file_outline |
100% AST | 95% AST | 95% AST | 90% AST ³ | 90% AST ³ |
who_calls — call sites |
100% | 100% | 100% | 100% | 100% |
who_calls — caller name |
95% | 80% ⁴ | 80% ⁴ | 80% ⁴ | 80% ⁴ |
find_definition |
100% | 90% | 90% | 85% | 85% |
find_tests / find_source |
95% | 90% | 90% | 85% | 85% |
find_route |
100% Symfony | N/A | N/A | N/A | N/A |
grep_code / find_usages / ast_search / multi_search |
100% | 100% | 100% | 100% | 100% |
Notes
- JavaScript has no
interfacesyntax — onlyclassdeclarations are detected. - Interfaces and
implementsare TypeScript/PHP concepts. JavaScript classes can extend but not implement. - Arrow functions assigned to
const(const fn = (x) => …) are not detected byfind_method. They appear inget_file_outlineand are found bygrep_code/find_usages. who_callsreturns the enclosing function name via regex heuristics. Works forfunction foo(),async function foo(),const foo = () =>, and class methods. May miss class field arrow methods (onClick = () => {}).
Regex fallback (when ast-grep-py is absent): find_class, find_extends, find_implementations fall back to regex — efficiency drops to ~70–80% because multiline declarations and generic type parameters can confuse the pattern.
| Variable | Required | Default | Description |
|---|---|---|---|
PROJECT_SEARCH_ROOT |
Yes | — | Absolute path to your project root |
PROJECT_SEARCH_EXCLUDE_DIRS |
No | vendor,node_modules,.git,var,web,dist,build,coverage,playwright-report,.nyc_output,__pycache__,.next,.nuxt,storybook-static,.cache |
Directory names to skip (pruned at traversal, never descended) |
PROJECT_SEARCH_MAX_RESULTS |
No | 50 |
Default max results for grep/find tools |
PROJECT_SEARCH_EXTENSIONS |
No | php,js,ts,tsx,jsx,twig,yaml,scss |
File extensions searched by default |
PROJECT_SEARCH_BACKEND |
No | auto |
auto (use ripgrep if available), rg, or python |
PROJECT_SEARCH_RESPECT_GITIGNORE |
No | true |
Honor .gitignore rules (set false to search vendor/ etc.) |
| Backend | Speed | .gitignore |
Requires |
|---|---|---|---|
rg (ripgrep) |
~10–100× faster | Native | rg binary in PATH or common locations |
python |
Baseline | Via exclude_dirs | Nothing extra |
With auto, ripgrep is probed at startup. If found, it is used for all grep operations. AST-backed tools (find_class, find_implementations, etc.) always use ast-grep-py when available, regardless of backend.
All search tools support pagination:
r = grep_code("public function", max_results=10)
# r.has_more == True
# r.next_cursor == "eyJv..."
r2 = grep_code("public function", max_results=10, cursor=r.next_cursor)
# continues from where the first call left offResults are ranked before pagination, so the first page always contains the most relevant matches.
output="compact" (default) returns a single string path:line:snippet\n… — ~60 % fewer tokens than JSON for the same data.
output="json" returns a structured list, useful when chaining tool results.
See SETUP.md for detailed setup instructions.
git clone <repo>
cd project_search
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
# Edit .env: set PROJECT_SEARCH_ROOT to your project pathConfig file locations:
- Linux:
~/.config/Claude/claude_desktop_config.json - macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"project_search": {
"command": "/absolute/path/to/mcp/project_search/.venv/bin/python3",
"args": ["-m", "project_search"],
"env": {
"PYTHONPATH": "/absolute/path/to/mcp"
}
}
}
}
PYTHONPATHmust point to the parent directory ofproject_search/.
# Compact output — 60% fewer tokens
grep_code("implements ContentRepositoryInterface", extensions=["php"])
→ "src/Domain/Repository/ContentRepository.php:41: class ContentRepository implements ...\n"
# Fuzzy class search
find_class("ContentRepo", fuzzy=True)
→ [{path: "src/.../ContentRepository.php", line: 12, kind: "class", fuzzy_score: 90}]
# One call instead of find_class + find_method + grep_code
find_definition("ContentRepository")
→ [{path: "src/Domain/Repository/ContentRepository.php", line: 12, kind: "class"}]
# Who calls a method, with caller context
who_calls("save", class_name="UserService")
→ [{path: "src/Handler/RegisterHandler.php", line: 42, caller: "handle", snippet: "$this->userService->save($user)"}]
# Structural search (ast-grep patterns)
ast_search("class $NAME extends $BASE", lang="php")
→ [{path: "src/Controller/AuthorizeController.php", line: 14, snippet: "class AuthorizeController extends AbstractController {"}]
# Routes across all sources (attributes + YAML + annotations)
find_route("/oauth")
→ [{route: "/oauth/v2/auth", methods: ["GET","POST"], source: "yaml", line: 12},
{route: "/oauth/v2/token", methods: ["POST"], source: "yaml", line: 17}]
# Navigate from a failing test to source
find_source("tests/Domain/Services/UserServiceTest.php")
→ [{path: "src/Domain/Services/UserService.php"}]
# Jump directly to changed lines, skip reading the whole file
find_in_file_diff("src/Controller/TokenController.php")
→ [{new_start: 42, new_lines: 8, ...}]
# Pagination
r = grep_code("public function", max_results=10)
# r.has_more = True, r.next_cursor = "..."
grep_code("public function", max_results=10, cursor=r.next_cursor)
project_search/
├── __main__.py
├── server.py # MCPServer + tool registration (22 tools)
├── config.py # Settings from env vars, probes rg + ast-grep at startup
├── requirements.txt
├── lib/
│ ├── searcher.py # Dispatcher: routes calls to backends + post-processing
│ ├── backends/
│ │ ├── ripgrep.py # rg --json wrapper (grep_code, grep_with_context, count_matches)
│ │ └── astgrep.py # ast-grep-py wrapper (find_class, find_implementations, get_file_outline, ast_search)
│ ├── ranking.py # Score-based result ranking
│ ├── filters.py # Heuristic comment/string detection
│ ├── fuzzy.py # rapidfuzz WRatio scorer
│ ├── pagination.py # Base64 cursor pagination
│ └── compact_output.py # path:line:snippet formatter + snippet centering
└── tools/ # One file per MCP tool, Pydantic models + docstrings
grep_code / grep_with_context / count_matches / multi_search
→ backend=rg → lib/backends/ripgrep.py (rg --json, --type <lang>, 5 s TTL cache)
→ backend=py → lib/searcher.py (Python re + pathlib)
find_class / find_implementations / find_extends / find_method / get_file_outline / ast_search
→ ast_grep_available → lib/backends/astgrep.py (ast-grep-py tree-sitter, PHP+JS+TS+TSX+JSX)
→ fallback → lib/searcher.py (regex, multiline-safe, PHP+JS+TS+TSX+JSX)
multi_search
→ ThreadPoolExecutor (max 10 workers) → grep_code × N in parallel
ripgrep optimisations :
- Extensions connues (
php,ts,js,yaml,py,go,rs…) utilisent--type <lang>au lieu de--glob *.ext— plus précis et plus rapide sur les gros arbres. - Extensions sans type natif (
twig,scss) restent en--glob. - Cache TTL 5 s par hash d'arguments : une requête identique répétée dans la fenêtre est servie depuis la mémoire sans subprocess.