Skip to content

oumarkonate/project_search

Repository files navigation

project-search MCP Server

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.

Why this server exists — token savings

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.
  • .gitignore respected by default — vendor/, var/, build/ never pollute results.

Tools

Navigation & structure

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)

Search

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

Code intelligence

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

Git-aware search

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)

Tool reference

grep_code

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

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

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

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,
)

find_route

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

find_definition

Single call that tries class → interface → trait → enum → method → function → route → grep fallback. Use instead of chaining find_class + find_method.

who_calls

who_calls(method, class_name=None, directory=None)
→ [{path, line, snippet, caller: "enclosingMethodName"}, ...]

find_in_file_diff

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

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.


Language support

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

  1. JavaScript has no interface syntax — only class declarations are detected.
  2. Interfaces and implements are TypeScript/PHP concepts. JavaScript classes can extend but not implement.
  3. Arrow functions assigned to const (const fn = (x) => …) are not detected by find_method. They appear in get_file_outline and are found by grep_code/find_usages.
  4. who_calls returns the enclosing function name via regex heuristics. Works for function 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.


Configuration

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 selection

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.


Pagination

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 off

Results are ranked before pagination, so the first page always contains the most relevant matches.


Output formats

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.


Installation

See SETUP.md for detailed setup instructions.

Quick start

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 path

Register in Claude Desktop / Claude Code

Config 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"
      }
    }
  }
}

PYTHONPATH must point to the parent directory of project_search/.


Examples

# 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)

Architecture

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

Backend dispatch

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.

About

A local MCP server that provides fast file and code search for any project. Designed to save Claude tokens by returning only paths and snippets — never full file contents.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages