lgrep is a code-intelligence server for AI coding agents. It speaks the Model Context Protocol (MCP). An agent such as OpenCode uses it to search a codebase by meaning when it does not know a name, and by symbol when it does.
Without it, an agent explores a repository with glob, grep, and random file reads, and repeats that work in every session. lgrep keeps a semantic index and a symbol index on disk between sessions and answers from them. One shared local server can keep both warm for every session. Indexes stay on the machine. The semantic engine sends only queries and the chunks it embeds to the Voyage API. Symbol lookup is fully local and needs no API key.
| What | MCP server with two engines: semantic search and exact symbol lookup |
| Stack | Python 3.11–3.13, Voyage Code 4 embeddings, LanceDB, tree-sitter |
| Surface | 14–19 MCP tools depending on transport and grants, a CLI, and a one-command OpenCode installer |
| Languages | 30+ for semantic chunking, 165+ for symbol parsing |
| Size | ~16k lines of source, ~25k lines of tests (1,100+ test functions) |
| Delivery | CI on Python 3.11, 3.12, and 3.13; automatic release on every merge to main |
flowchart LR
agent["AI agent<br/>(OpenCode)"] -- "MCP: stdio or<br/>streamable HTTP" --> server
subgraph daemon["lgrep server process"]
server["Tool layer<br/>typed arguments and responses"] --> supervisor["Runtime supervisor"]
supervisor --> qlane["Query lane"]
supervisor --> blane["Build lane"]
supervisor --> slane["Symbol lane"]
qlane --> semantic["Semantic engine"]
blane --> indexer["Indexer + file watcher"]
indexer --> semantic
end
semantic --> lance[("LanceDB<br/>vectors + full-text index")]
semantic -- "embed query" --> voyage["Voyage API"]
indexer -- "embed new or<br/>changed chunks" --> voyage
slane -- "pipe" --> worker
subgraph worker["Symbol worker process (spawned)"]
ts["tree-sitter parsing<br/>and symbol queries"] --> symidx[("Symbol index<br/>JSON on disk")]
end
Semantic engine. lgrep splits files into AST-aware chunks, embeds them with Voyage Code 4, and stores them in a local LanceDB table. A query runs vector search and full-text search, then merges the two rankings with reciprocal rank fusion. Before each search, lgrep checks whether the index has drifted from the files on disk. If it has, the search answers from the current index, marks the response stale, and refreshes the index in the background.
Symbol engine. tree-sitter parses each file into symbols, their hierarchy, and identifier occurrences. lgrep stores the result as a compact JSON index per repository. That index serves symbol search, file and repository outlines, source retrieval, and candidate references, with no network call.
Runtime. Blocking work never runs on the event loop. A supervisor sends each job to one of three bounded lanes: queries, builds, or symbol work. When a tool call hits its server-side timeout, the supervisor sets the job's cancellation event. Indexing checks that event between steps and also stops at a wall-clock budget, so abandoned work ends and frees its thread. Symbol work runs in a separate spawned process, so its CPU-bound Python never holds the server's global interpreter lock (GIL).
Cache sharing and cleanup. Git worktrees of one repository can share one semantic cache. Each worktree then stores only the files that differ from the main checkout. Garbage-collection commands remove orphaned caches, stale symbol indexes, and old LanceDB table versions. MCP tools delete nothing unless the server operator grants it.
One shared lgrep daemon serves many agent sessions at once, on repositories of more than a thousand files. The changes below keep it fast, bounded, and correct under that load. Each one states the measured problem, the fix, and the result recorded in its pull request.
| Problem | Before | After | PR |
|---|---|---|---|
| Symbol index rebuild cost grew with index size for every changed file | 81.6 s cold build, 500 files | 15.1 s | #53 |
| Symbol index stored full line text for every identifier | 432 MB index body | 60 MB (86% smaller, identical results) | #45 |
| LanceDB kept every table version forever | 9.3 GiB cache, ~270 ms query | 1.7 GiB, ~22 ms | #50 |
| Freed native memory stayed in fragmented allocator arenas | idle RSS climbing 1,778 → 2,482 MiB | flat at 697–792 MiB | #48 |
| Worktree searches scanned a large path-exclusion filter | 7.8–8.6 s hybrid search | 0.91–0.96 s, same top 10 | #51 |
| Symbol builds held the interpreter lock, stalling other tools | text search during a build: 1.14 s worst case | 0.095 s | #54 |
| Symbol worker imported the vector database it never uses | 4.1–5.0 s cold start, ~204 MB | 0.14 s, ~73 MB | #55 |
A profile of a cold symbol build on a 500-file repository spent 108 s of 134 s removing old entries for changed files. The merge rebuilt the whole symbol table and every occurrence bucket once per file, so k files cost k × index size. The merge now indexes symbols and occurrence buckets by source file, so a removal touches only the buckets that hold that file's rows. A cold build fell from 81.6 s to 15.1 s. Merging 100 changed files into a 427 MB index fell from 34.9 s to 1.44 s. The saved index was identical, field for field. (#53)
On a 1,154-file repository the symbol index body was 432 MB, because every identifier occurrence stored its full line text. lgrep now writes occurrences in one interned JSON encoding and rebuilds ordinary dictionaries on load. The body shrank 86%, to 60 MB. Every file, symbol, occurrence, and query-result digest stayed identical. Warm reference queries fell from 7.7–8.1 s to 12–17 ms. The trade-off is memory: resident size after a query rose from about 201 MiB to 762 MiB. An older index keeps its format until its next rewrite, which publishes the compact form. (#45)
Every LanceDB write leaves a new table version, and nothing deleted the old ones. The semantic cache on one host reached 97 GiB. One 170k-row table held 16,772 versions: 8.0 GiB of manifests beside 2.0 GiB of data. lgrep gc now compacts each table, deletes old versions, and retrains the vector index. Vector and hybrid search now re-rank candidates by exact distance. On the retrained index, that lifts top-10 agreement with exact search from 0.60 to 0.98. On a copy of that table, size fell from 9.3 GiB to 1.7 GiB and query time from ~270 ms to ~22 ms. Server connections recheck the latest version every 5 s, so a running server keeps working after another process cleans its tables. (#50)
After 58 idle hours, the shared daemon held 33 GiB of resident memory plus 27 GiB of swap, while its own caches reported almost nothing. Allocator statistics showed about 1,500 fragmented 64 MiB glibc heap arenas, left behind by LanceDB scans. The fix caps malloc arenas at startup, returns free heap to the OS on a timer after work finishes, and shares one bounded LanceDB session across all stores. In a 10-cycle probe over three real caches, idle memory stayed flat at 697–792 MiB. Under the old code it climbed from 1,778 to 2,482 MiB. lgrep_diagnostics now reports these figures. (#48)
- Cooperative cancellation. A search that timed out abandoned its auto-index thread, which kept running and held its worker. Enough abandoned threads wedged the pool for good. A cancellation event now reaches every blocking step of that indexing path, including the embedding retry sleep. A wall-clock budget backs it up. An abandoned job on a 2,157-file repository now ends and frees its worker. (#3)
- Lanes and off-loop I/O. Six tool calls ran 41–71 s, well past their timeout. The first LanceDB I/O on each store ran synchronously on the event loop, so the timeout never fired. Stores now open on worker threads, and index builds have their own lane, so queries no longer wait behind builds. (#28)
- Process isolation for CPU-bound work. Symbol builds and queries are CPU-bound Python and hold the GIL. All symbol work now runs in one persistent spawned worker. If the worker crashes, the next call starts a new one, and the calls in flight return a retryable error. During a full symbol build, the slowest overlapping text search fell from 1.143 s to 0.095 s (#54). A follow-up deferred the storage package's vector-database imports until first use, so the worker never imports LanceDB. Worker cold start fell from 4.1–5.0 s to 0.14 s, and worker memory from ~204 MB to ~73 MB (#55).
- Fast startup. MCP
initializewaited until every warm-up path had opened its store. With five paths that took 9.99 s and tripped the host's spawn deadline. Warm-up now runs in the background, andinitializeanswers in 2.45 s. (#49)
- Per-worktree views of one cache. With a shared cache, every git worktree used to search the first checkout's files. Rows now carry a checkout column, and each worktree stores overlay rows only for files that differ from the main checkout (#26). Worktree searches then filtered with a large path-exclusion list. A later change replaced it with a checkout prefilter plus over-fetch. Hybrid search fell from 7.8–8.6 s to 0.91–0.96 s with the same top 10 (#51).
- Model provenance. Voyage Code 3 and Code 4 both emit 1024-dimensional vectors. An index built by the old model therefore loads without error, but its vectors cannot be compared with new query vectors. Every chunk now records the model that embedded it. lgrep rebuilds a table with a mismatched model on first use instead of searching it. (#14)
Each change above landed as a pull request that records the problem, the evidence, and the verification. CI runs Ruff and the full test suite on every pull request. A successful CI run on main publishes a release.
Requirements: Python 3.11+, and a Voyage API key for the semantic engine only.
# From GitHub
pip install git+https://github.com/Sharper-Flow/lgrep.git
# Or from a source checkout
git clone https://github.com/Sharper-Flow/lgrep.git
cd lgrep && pip install .
# Try it without an agent
lgrep index-symbols /path/to/project
lgrep search-symbols "authenticate" /path/to/project
export VOYAGE_API_KEY=your-key
lgrep index-semantic /path/to/project
lgrep search-semantic "where are sessions validated" /path/to/projectstdio is the local default, for one user and one session. OpenCode starts lgrep itself, so no separate server process runs. For many sessions, use the shared HTTP server below.
lgrep install-opencodeThe installer is safe to run more than once. It:
- creates
~/.cache/lgrep/for indexes and logs - copies the packaged
instructions/lgrep-tools.mdandskills/lgrep/SKILL.mdinto~/.config/opencode/ - appends the instruction file to the
instructionsarray so agents trylgrepfirst - adds a
type: "remote"MCP entry pointing athttp://localhost:6285/mcp(the shared HTTP server) - edits
~/.config/opencode/opencode.json, oropencode.jsoncwhen only that file exists; it keeps every config value but rewrites the file as plain JSON, which drops any comments fromopencode.jsonc - prints systemd user-service and manual-daemon instructions for the shared server
lgrep uninstall-opencode removes the MCP entry, the instruction entry, and the skill file.
For stdio (local default), replace the mcp.lgrep entry with:
{ "mcp": { "lgrep": { "type": "local", "command": ["lgrep"], "enabled": true } } }OpenCode requires command for a local entry (OpenCode MCP docs). Put VOYAGE_API_KEY in its environment object, or in the environment OpenCode starts from.
For the shared HTTP server, keep the installer's remote entry and start one warm server that handles every session:
VOYAGE_API_KEY=your-key \
LGREP_WARM_PATHS=/path/to/project-a:/path/to/project-b \
lgrep --transport streamable-http --host 127.0.0.1 --port 6285{ "mcp": { "lgrep": { "type": "remote", "url": "http://localhost:6285/mcp", "enabled": true } } }Optional: lgrep init-ignore /path/to/project creates a .lgrepignore. lgrep always respects .gitignore as well. Cache maintenance, worktree cache sharing, and daemon tuning are covered in docs/operations.md. A complete config example is in examples/opencode.json.
The active agent profile must also allow the lgrep tools. A profile that allows only read, glob, and grep cannot call lgrep, even with the server configured.
- Ask an intent question:
search_semantic(query="authentication flow", path="/path/to/project"). A project with no index is indexed on its first search. - Inspect structure:
get_file_outline(path=...)orget_repo_outline(path=...). - Retrieve exact symbols:
search_symbols(...), thenget_symbols(symbol_ids=["src/auth.py:function:authenticate"], path=...). - List candidate usages, bounded and not exhaustive:
search_references(query="authenticate", path=...).
Symbol IDs are deterministic file_path:kind:name strings, for example src/auth.py:class:AuthManager or src/auth.py:method:login.
The server registers the names below, and MCP clients receive them unchanged. OpenCode displays each one with the server key as a prefix: with an MCP entry named lgrep, search_semantic appears as lgrep_search_semantic. Only lgrep_diagnostics has the prefix in its registered name. Response shapes and argument rules are in docs/operations.md.
The server lists only the tools it can run in its configuration. A stdio server lists 17 tools. A server started with --transport streamable-http lists 14, because the three explicit symbol-index job tools need a stdio process. The two cache-invalidation tools appear only when the server starts with LGREP_ALLOW_DESTRUCTIVE_MCP. Symbol queries index a missing local checkout on first use, so most agents never need the job tools.
MCP tools (19 at most)
| Tool | Purpose |
|---|---|
search_semantic(query, path, limit=10, hybrid=true, include_content=false) |
Search code by natural-language meaning |
index_semantic(path) |
Build or refresh the semantic index |
status_semantic(path="") |
Index/watcher status; omit path for all loaded projects |
| Tool | Purpose |
|---|---|
index_symbols_folder(path, max_files=500, incremental=true) |
Submit a local symbol-index job (stdio only) |
index_symbols_repo(repo, ref="HEAD", max_files=500, github_token=None) |
Submit a GitHub API symbol-index job (stdio only), no clone |
get_symbol_index_job(job_id) |
Read job state and final counters/truncation in the current daemon |
list_repos() |
List indexed symbol repos |
get_file_tree(path, max_files=500) |
Repo file tree with ignore rules applied |
get_file_outline(path, repo_root=None) |
Symbol outline for one file |
get_repo_outline(path, max_files=500) |
Symbol outlines across a repo |
search_symbols(query, path, limit=20, kind=None) |
Search symbols by name (case-insensitive substring) |
search_text(query, path, limit=50, case_sensitive=false) |
Search literal text |
search_references(query, path, limit=20, usage_filter="production_first", kind=None) |
Bounded candidate usages; not compiler-exhaustive |
get_symbols(symbol_ids, path) |
Metadata and source for one or more symbols; an unknown ID or stale range errors in its own entry |
invalidate_cache(path) |
Drop one repo's symbol index (listed only with the destructive-MCP grant) |
| Tool | Purpose |
|---|---|
prune_orphans(dry_run=true) |
Preview (or delete) orphan semantic cache dirs |
prune_symbols(dry_run=true) |
Preview (or delete) stale symbol-store indexes |
invalidate_worktree_cache(paths) |
Remove a worktree's cache alias and overlay rows (listed only with the destructive-MCP grant) |
lgrep_diagnostics() |
Read-only daemon snapshot: PID, uptime, projects, jobs |
Without the server-side LGREP_ALLOW_DESTRUCTIVE_MCP grant, the pruners return a preview instead of deleting; they also skip projects loaded in the running server. Background file watching is configured with LGREP_AUTO_WATCH, not a tool.
CLI commands
| Command | Purpose |
|---|---|
lgrep |
Start the MCP server: --transport {stdio,streamable-http} (default stdio), --host (default 127.0.0.1), --port (default 6285) |
lgrep --version |
Print the version |
lgrep search-semantic <query> [path] |
One-shot semantic search; -m/--limit N, --no-hybrid |
lgrep index-semantic [path] |
One-shot semantic index; --chunk-size N |
lgrep search-symbols <query> [path] |
One-shot symbol search; -m/--limit N, --storage-dir DIR |
lgrep index-symbols [path] |
Index symbols; --storage-dir DIR, --max-files N |
lgrep init-ignore [path] |
Create a recommended .lgrepignore; --force |
lgrep prune-orphans |
Inspect or delete orphan semantic caches; --execute, --dry-run, --cache-dir DIR |
lgrep prune-symbols |
Inspect or delete stale symbol indexes; --execute, --dry-run, --storage-dir DIR |
lgrep gc |
Combined GC: orphans + worktree aliases + symbol indexes + chunk-table optimize; --execute, --dry-run, --cache-dir DIR, --symbols-dir DIR |
lgrep remove <path> |
Show on-disk index info for a project |
lgrep install-opencode |
Install into OpenCode (MCP entry + instruction + skill) |
lgrep uninstall-opencode |
Remove lgrep from OpenCode |
search and index are aliases for search-semantic and index-semantic; init-lgrepignore aliases init-ignore. Prune commands are dry-run by default and --execute/--dry-run are mutually exclusive.
Environment variables
| Variable | Default | Description |
|---|---|---|
VOYAGE_API_KEY |
none | Required for semantic search; the symbol engine works without it |
LGREP_LOG_LEVEL |
INFO |
Log verbosity |
LGREP_LOG_FILE |
unset | Opt-in rotating JSON log file; see operations |
LGREP_CACHE_DIR |
~/.cache/lgrep |
Semantic cache directory |
LGREP_SYMBOLS_DIR |
~/.cache/lgrep/symbols |
Directory that prune-symbols, lgrep gc, and the MCP prune_symbols tool scan. Indexing and symbol queries use the default directory; the CLI index-symbols and search-symbols commands take --storage-dir instead |
LGREP_WARM_PATHS |
none | Colon-separated projects to warm on startup |
LGREP_AUTO_WARM_DISK |
true |
Auto-load discoverable disk caches at startup when no warm paths are set; set false on large shared machines |
LGREP_AUTO_WATCH |
false |
Auto-start file watchers for warmed projects |
LGREP_TOOL_TIMEOUT_S |
45 |
Per-tool server-side timeout (seconds) |
LGREP_EXPLICIT_INDEX_BUDGET_S |
150 |
Cooperative budget for explicit symbol-index jobs, independent of the tool timeout; positive finite overrides only |
LGREP_STALENESS_DEADLINE_S |
4.0 |
Bound on the per-search staleness check |
LGREP_ENSURE_BUDGET_S |
8.0 |
Budget for a worktree search to make its trunk base index current; 0 always defers to the background |
LGREP_INDEX_MAX_WALL_S |
60.0 |
Wall-clock budget per indexing window |
LGREP_AUTO_REFRESH |
1 |
0 opts out of the symbol-index freshness gate that refreshes a stale index before search_symbols answers |
LGREP_GITHUB_TOKEN |
unset | Token for index_symbols_repo when the call passes none |
GITHUB_TOKEN |
unset | Fallback when LGREP_GITHUB_TOKEN is unset; an explicit argument wins over both |
LGREP_WORKER_MAX_THREADS |
4 |
Query-lane worker threads for supervised blocking jobs |
LGREP_BUILD_MAX_THREADS |
1 |
Shared build-lane threads: semantic index windows, re-indexes, prune sweeps; explicit symbol jobs have a separate one-worker lane |
LGREP_PRUNE_MIN_AGE_S |
3600 |
Grace window (seconds) before pruning; 0 disables grace |
LGREP_WORKTREE_DEDUP |
unset | Any non-empty value makes git worktrees of one repo share one semantic cache |
LGREP_ALLOW_DESTRUCTIVE_MCP |
unset | true/1/yes lists the invalidate tools and lets MCP prune/invalidate tools delete; keep unset on any shared server |
lgrep supports two transports: stdio and streamable-http. stdio is the local default and suits one session. Use streamable HTTP only when you want one shared local daemon.
Explicit symbol-index jobs require stdio, so a streamable-HTTP server does not list their tools. Both submission tools return a job_id, not final counters; call get_symbol_index_job before using their results. Two limits can stop a run: max_files caps the file count, and a separate cooperative job budget, 150 seconds by default, caps the time. Status reports coverage and its truncation_reason. Remote results also report published: incomplete refreshes preserve complete saved snapshots rather than mixing old and new entries. A truncated run does not cover the whole repository. See job ownership, partial-index replacement, and recovery.
- The HTTP server binds
127.0.0.1by default and has no built-in authentication. - lgrep sets no CORS headers. Do not connect browser-based clients directly to the streamable-HTTP endpoint.
- Behind a proxy, enforce your own authentication and origin controls there.
- lgrep binds
0.0.0.0only when you pass that host explicitly. Do not do it without a reverse proxy or firewall.
VOYAGE_API_KEYnot set — set it in the MCP server environment; the symbol engine still works without it.- Slow first semantic index — the first run embeds the whole project; later runs skip unchanged files by content hash.
Repository not indexed— for a local git checkout, symbol tools build the index on the first query. A query on a subdirectory needs an explicitindex_symbols_folder. Details in operations.- Stale semantic results — a search answers from the current index, sets
is_stale: truewhen that index has drifted, and refreshes it in the background. Runindex_semanticto force a fresh index before a query. See operations. - Diagnosing high CPU or memory on a shared daemon — call
lgrep_diagnosticsfor PID, worker limit, active jobs, and process memory; see shared-daemon tuning. - A tool is missing from the list —
get_symboland thewatch_*_semantictools were removed in4.0.0; useget_symbolsandLGREP_AUTO_WATCH. The invalidate tools needLGREP_ALLOW_DESTRUCTIVE_MCP, and the symbol-index job tools need stdio. See Tool profile. - Native dependency build fails — prebuilt wheels usually work; otherwise install a compiler toolchain.
To upgrade from 3.x, see Upgrade from 3.x: 4.0.0 removes three tools and hides tools a server cannot run. Indexes need no rebuild. Since 3.0.0, every MCP tool returns structured dicts instead of JSON strings; to upgrade from 2.x, see Upgrade from 2.x.
git clone https://github.com/Sharper-Flow/lgrep.git
cd lgrep
pip install -e ".[dev]"
pytest -v
ruff check . && ruff format --check .Design specifications are in docs/specs/, and operational detail is in docs/operations.md. Release notes are on the releases page.
MIT — see LICENSE.