Code Indexing MCP
Code Indexing MCP is a local, privacy-focused server for codebase indexing and search, using Tree-sitter for syntax-aware parsing, FastEmbed for local embeddings, and LanceDB for persistent vector and full-text search — no hosted services required.
Project Management: Register (
init_project), index (index_project), check status (project_status), list all registered projects (list_projects), and remove (remove_project) projects.Code Indexing: Incrementally scan, parse, embed, and commit changed source files into the vector index, with a
forceflag for full reindexing. Supports Python, Java, JavaScript, TypeScript, and more.Code Search: Perform hybrid semantic + keyword search (
search_code) across one or all projects, with filtering by file path (glob patterns), programming language, or symbol kind.Symbol Lookup: Find declarations by exact name, prefix, or substring match (
find_symbol), filterable by symbol kind and project.File Outline: Retrieve all declared symbols and their metadata (kinds, line ranges, qualified names) for a specific file (
file_outline).Chunk Retrieval: Fetch the full text and detailed metadata of a specific indexed code chunk by its ID (
get_chunk).Performance & Customization: Configure memory limits, batch sizes, and token windowing for embeddings. Leverage hardware accelerators (CUDA, WebGPU) with CPU fallback, and operate fully offline after the initial model download.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Code Indexing MCPsearch for 'database connection' in my project"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Code Indexing MCP
Code Indexing MCP is a local-only codebase indexer for MCP clients. It uses Tree-sitter to extract syntax-aware chunks, FastEmbed for CPU embeddings, a direct ONNX path for selected passage accelerators, and LanceDB for persistent vector and full-text search.
It does not require a hosted database, embedding API, or network service. A private per-user
daemon is started on demand so all connected MCP clients share one scheduler and model. The only
network access is the initial download of the default
jinaai/jina-embeddings-v2-base-code model (approximately 640 MB). Once cached, indexing and
search work offline.
Install
On macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/MarcinHamiga/code-indexing-mcp/main/install.sh | shOn Windows PowerShell:
$installer = Join-Path $env:TEMP "code-indexing-mcp-install.py"
Invoke-WebRequest https://raw.githubusercontent.com/MarcinHamiga/code-indexing-mcp/main/install.py -OutFile $installer
py -3 $installerThe installer clones the repository to ~/.local/share/code-indexing-mcp, creates its locked
virtual environment, and opens an interactive wizard. The wizard walks through accelerator
selection (with live hardware detection), the MCP clients to configure, and the server's
settings — indexing, embedding, memory, storage, and offline behavior — before a summary
screen runs everything. Settings you change are written into each client's MCP configuration
(an env block, or environment for OpenCode and KiloCode); anything you leave at its
default is not written anywhere.
The supported clients are Codex (CLI + Desktop, one shared configuration), Claude Code,
Kimi Code, Claude Desktop, OpenCode, and KiloCode. Configuration changes are limited to the
code-indexing-mcp entry; an existing configuration is backed up alongside the original
with a .bak suffix before it changes, and unrelated keys in the entry's env block are
preserved. That .bak is the file as you wrote it and is never overwritten — later
writes roll into a .bak.prev beside it, so re-running configure cannot cost you the
original. For Codex the whole [mcp_servers.code-indexing-mcp] table is rewritten, so any
other key you added inside that one table is replaced rather than merged.
Later, code-indexing-mcp update updates an existing clean checkout with a fast-forward-only
pull and refreshes its environment — see Update. Re-running the install command above
does the same thing. To change settings or harnesses without updating, run
code-indexing-mcp configure — it opens the same wizard offline, prefilled from your
current configuration. Naming what to change applies it directly instead:
code-indexing-mcp configure --set CODE_INDEXING_BROKER=off --unset CODE_INDEXING_INDEX_MODE.
The code-indexing-mcp command
Your MCP clients launch the server by absolute path and never need it on PATH, so the
installer adds a launcher for you: a symlink at ~/.local/bin/code-indexing-mcp (a .cmd
shim on Windows) pointing at the executable inside the installation's virtual environment.
That is what makes configure, index, status, projects, model, and daemon work
from any shell.
If that directory is not already on your PATH, the wizard offers — checked by default — to
add it to your shell profile (~/.zshrc, ~/.bashrc and ~/.bash_profile on macOS,
~/.config/fish/config.fish, or ~/.profile) as a marked block:
# >>> code-indexing-mcp >>>
export PATH="$HOME/.local/bin:$PATH"
# <<< code-indexing-mcp <<<The block is written once — a second install finds it and leaves the file alone, as does a
profile where you already put that directory on PATH yourself — and the original is backed up
with a .bak suffix first. The entry only reaches shells started afterwards; the installer
prints the exec line that makes it live in the session you are sitting in. Windows profiles
are not edited; add the directory yourself.
An existing file at the launcher's name that the installer did not create is moved aside to
code-indexing-mcp.bak rather than overwritten. If some other code-indexing-mcp sits
earlier on your PATH, the wizard says so instead of quietly losing the name.
Three flags control all of this, on both install.py and configure:
python3 install.py --bin-dir ~/bin # put the launcher somewhere else
python3 install.py --no-modify-path # create the launcher, never touch a shell profile
python3 install.py --no-launcher # do not create a launcher at allCODE_INDEXING_MCP_BIN_DIR and XDG_BIN_HOME set the directory too, in that order of
preference. None of these count as "scripted" flags, so passing them still opens the wizard.
On a terminal that cannot host the wizard (or with --no-tui), the installer falls back to
a plain text interface with the numbered harness menu. Flags that already say what to
install — --harnesses, --set, --unset, --no-prompt — skip the wizard too, so a
scripted run never waits for a keypress; pass --tui to open it over them anyway. For a
fully noninteractive installation, pass harness slugs and any settings:
curl -fsSL https://raw.githubusercontent.com/MarcinHamiga/code-indexing-mcp/main/install.sh |
sh -s -- --harnesses codex,claude-code,opencode --set CODE_INDEXING_INDEX_MODE=eagerBy default the installer detects whether this machine can be given an automatic GPU accelerator for indexing and prepares one when it can. Experimental backends must be named explicitly:
python3 install.py --accelerator auto # CPU, a supported CUDA installation, or Metal via MLX
python3 install.py --accelerator mlx # Metal on Apple Silicon through MLX
python3 install.py --accelerator webgpu # experimental Metal, Vulkan, or D3D12 path
python3 install.py --accelerator migraphx # experimental pinned AMD/ROCm pathDetection that finds nothing, an environment that cannot be built, and a probe that does not pass all leave the installation on CPU and report why. Nothing here changes system drivers, and no package is ever installed while the server is running. See Embedding backends.
Preparing an accelerator downloads the embedding model, because the probe that confirms it
embeds a real passage on the device. A later run reuses an environment whose accelerator,
driver, Python, and runtime-lock fingerprint all still match its record, so only the first
install — and one that finds something moved — pays for the build and the probe.
Reinstalling as --accelerator cpu removes the environment again.
Use --install-dir /custom/path or CODE_INDEXING_MCP_INSTALL_DIR to change the checkout
location. Run python3 install.py --help for all installer options.
Update
code-indexing-mcp update updates an existing installation in place. It checks first — git on
PATH, a clean checkout on main whose origin is this repository, and uv resolvable — and
refuses with one actionable sentence before touching anything if any of that does not hold. Then
it fast-forwards to the tip of main, refreshes the locked environment, rebuilds the prepared
accelerator environment only when the pull moved the runtime lock it was built against, stops the
daemon so the next client starts it on the new code, and re-runs the installation checks for the
harnesses you already have configured. Two updates cannot run at once; the second refuses
immediately.
code-indexing-mcp update
code-indexing-mcp update --check # report only, change nothing
code-indexing-mcp update --skip-accelerator # defer the accelerator rebuild--check prints a JSON report of the local and remote commits and exits 0 when you are up to
date, 10 when an update is available, and 1 when the check itself could not be made — offline,
no checkout, or an origin that is not this repository.
--skip-accelerator leaves a stale accelerator environment in place. Nothing retires it at
runtime, so the previously prepared accelerator keeps serving the old locked runtime until you
rebuild it; the command prints the exact repair command.
Once a day, in the background, the CLI compares your checkout against the tip of main and prints
one line on stderr when an update is available. It never updates anything by itself, it is silent
on a development checkout, and CODE_INDEXING_UPDATE_CHECK=off turns it off entirely.
Restart your MCP clients after an update: a running server keeps the code it started with.
On Windows, an update that raises the project's required Python cannot rebuild the environment it is running from — the sync fails and says so; re-run the installer.
Repair and uninstall
code-indexing-mcp configure --repair re-applies the cheap steps — the launcher, the client
entries, and the skill links — for the harnesses already configured, keeping the prepared
accelerator and every setting exactly as they are. It changes no choice; it puts back what
went missing.
code-indexing-mcp uninstall removes what the installer added: the code-indexing-mcp entry
from each configured client, the bundled skill links, the launcher, and the PATH block. It
prints what it will do and asks before doing any of it (--yes skips the prompt).
Removal is evidence-based, not name-based. A launcher that does not point into this checkout stays, even if it points into some other virtual environment; a skill directory entry that does not point into this checkout stays, so a second installation keeps its own links; a PATH block whose end marker was edited away stays, because removing to end-of-file would take your edits with it. Client configs are restored to what they were before the install — comments, formatting, and neighbouring servers included.
Indexes and caches are kept by default; they cost minutes of CPU to rebuild and an uninstall that discards them silently is not one you can undo. The checkout is kept too:
code-indexing-mcp uninstall # entries, skills, launcher, PATH block
code-indexing-mcp uninstall --purge # also delete the index and cache directories
code-indexing-mcp uninstall --remove-checkout # also delete ~/.local/share/code-indexing-mcp
code-indexing-mcp uninstall --keep-launcher --keep-path # leave the command in placeBoth deleting flags check the directory before touching it. --purge removes a data or
cache directory only if it is named code-indexing-mcp or holds a recognizable index or
model cache, and --remove-checkout only a directory that actually looks like a checkout.
A CODE_INDEXING_DATA_DIR pointing somewhere else is reported and left alone: a
confirmation prompt is not a safety net for a recursive delete you cannot undo.
Bundled skills
The installer also symlinks five agent skills into skill-capable harnesses
(Claude Code, Kimi Code, Codex, OpenCode), pointing into the cloned repo so
they update on every re-install: codebase-exploration (index-first
navigation), cross-repo-debugging (evidence-driven debugging across related
repositories), feature-dev (index-grounded feature workflow), indexed-review
(angle-based code review), and impact-analysis (blast-radius mapping before a
change). Harnesses without skill support are skipped. The update command re-links
this complete set after pulling so newly bundled skills are installed as well.
Related MCP server: agentmako
Manual setup
git clone https://github.com/MarcinHamiga/code-indexing-mcp.git
cd code-indexing-mcp
uv sync --locked --extra cpu
uv run code-indexing-mcp model pull--extra cpu is required: the embedding runtime is an extra rather than a plain dependency,
because the CPU, CUDA, WebGPU, and MIGraphX runtimes conflict and cannot share one environment. See
Embedding backends. Add --extra tui as well if you want the
code-indexing-mcp configure wizard; without it, scripted configure --set still works.
The model preparation step is optional; the first index operation downloads the model when it is not already cached.
MCP configuration
Run the server over stdio:
uv run code-indexing-mcp serveA generic MCP client configuration looks like this:
{
"mcpServers": {
"code-indexing-mcp": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/code-indexing-mcp",
"run",
"code-indexing-mcp",
"serve"
]
}
}
}The server exposes twelve tools. Only list_projects and get_chunk are annotated readOnlyHint,
so hosts may auto-approve them. The other query tools are not: on a root the server has not seen
before they register it first, which writes a .ci-mcp/project.toml marker, and the four code
queries also build its initial index. remove_project is annotated destructiveHint;
init_project carries the same hint and is non-idempotent because force_new_id=true can
overwrite a marker and orphan the previous index.
Tool | Kind | Purpose |
| destructive write | Register a directory and write its |
| write | Incrementally scan, parse, embed, and commit changed files. |
| destructive | Delete a registration and its whole index partition. |
| read, registers | Index state plus file and chunk counts. |
| read only | Every registered project, sorted by name. |
| read, registers and indexes | Hybrid semantic and keyword search returning ranked snippets. |
| read, registers and indexes | Globally ranked search across at least two explicitly selected projects. |
| read, registers and indexes | Exact, prefix, or substring lookup of declaration names. |
| read, registers and indexes | Structural references to one selected Python, JavaScript, TypeScript, or TSX declaration. |
| read, registers and indexes | Read-only rename or signature-change impact analysis for one selected declaration. |
| read, registers and indexes | One file's declared symbols, metadata only. |
| read only | Full stored text for one |
Search limit values are capped at 50 and match accepts only exact, prefix, or contains;
both are enforced by the tool schema, so an out-of-range value is rejected rather than silently
clamped.
get_chunk returns one chunk's full stored text with its path, symbol, line range, byte range, and
content hash. It deliberately excludes the embedding vector and the derived embedding_text and
search_text columns, which exist for ranking and are not useful to a caller.
Reference and refactor workflows
Use find_symbol to select one declaration, then pass its chunk_id to find_references (or use
the explicit project, path, and qualified_symbol selector). Reference results are paged with
an opaque cursor that stays bound to the original structural-table snapshot. They distinguish
exact bindings from likely and unresolved evidence; callers must review the latter and the
reported limitations rather than treating them as safe edits.
Structural references are extracted during the normal parse and are backfilled parse-only for an
older semantic index—no second embedding pass is needed. The first reference query may therefore
write structural coverage while it refreshes its index, which is why both tools carry the
registering-read annotation. Python, JavaScript, TypeScript, and TSX are supported; selecting a
declaration in any other language returns UNSUPPORTED_LANGUAGE rather than an empty result that
would read as "no callers". Runtime imports, wildcard imports, inferred receiver types, TypeScript
path aliases, and other dynamic dispatch stay conservative and are reported as limitations.
Because this is a syntax-only index, a repository usually contains files it cannot analyze — other
languages, or files that failed to parse. Those are reported as limitations with the codes
unsupported_language, parse_error, and stale_file, and any of them makes completeness.state
incomplete. A single unparseable file degrades the result; it does not disable the tools.
JavaScript, TypeScript, and TSX scopes carry no standing extraction cap: class/interface
heritage, generic and union/intersection type references, export * re-exports, member accesses
and writes, decorators, and JSX component tag references are fully captured.
analyze_refactor accepts a discriminated operation: {"kind":"rename","new_name":"..."}
or {"kind":"signature_change","parameters":[...]}. It never edits source. Its
must_change items are deterministic, likely_change and review require inspection, and
evidence includes, for a rename, exact aliases that identify the target but need no spelling
change; for a signature change, the same bucket holds compatible call sites that need no argument
edit. Signature analysis reports spread arguments and ambiguous declaration shapes for review
instead of guessing.
completeness.state is complete only when every indexed file was analyzed and every candidate was
proven; a result carrying likely_change or review entries reports
complete_with_dynamic_limitations instead. Apply renames at edit_start_byte/edit_end_byte,
which cover just the identifier — a finding's start_byte/end_byte span the whole reference, so
they include the receiver in auth.authorize and the alias in authorize as check. When the
identifier could not be located unambiguously both edit offsets are null and the edit has to be made
by hand.
Project workflow
cd /path/to/project
uv run --project /path/to/code-indexing-mcp code-indexing-mcp init
uv run --project /path/to/code-indexing-mcp code-indexing-mcp index
uv run --project /path/to/code-indexing-mcp code-indexing-mcp statusindex reports live progress — the phase, how many files it has walked against the previous run's
count, and how many chunks it has embedded — on stderr, as a status line on a terminal and as
periodic lines when redirected. The JSON report stays alone on stdout, so piping is unaffected. The
same numbers reach an MCP client as tool progress notifications, whether the work runs in this
process or in the shared daemon.
Benchmark the CPU indexing pipeline with a generated, deterministic corpus:
uv run --project /path/to/code-indexing-mcp code-indexing-mcp benchmark index \
--files 128 --functions-per-file 2 --batch-size 8The command writes one JSON document to stdout with cold_start, warm_index,
incremental_index, and forced_reindex results, including phase timings, peak memory, and
chunks per second. It reuses the configured model cache but isolates index data in a temporary
workspace. Pass --work-dir /fresh/path to retain the corpus and index for inspection.
Initialization creates .ci-mcp/project.toml and a self-ignoring .ci-mcp/.gitignore. The
marker contains a checkout-local UUID and scanning configuration. It is not intended to be
committed. Markers created by earlier releases under .code-indexing-mcp remain readable, but all new
markers use .ci-mcp.
CLI index refreshes are explicit and incremental. MCP indexing is lazy by default: listing tools
does not discover projects, load the model, or start indexing. Every project-scoped code query
compares eligible file paths, sizes, and nanosecond mtimes with the stored index. It refreshes and
waits only when that metadata has changed; the first query also discovers and indexes qualifying
roots. A new root qualifies when it has at least one supported,
non-ignored source file and contains .git, pyproject.toml, setup.py, setup.cfg,
package.json, tsconfig.json, or jsconfig.json. The server creates the usual local
.ci-mcp/project.toml marker only after that check passes.
Because a query waits for any required refresh, it reports progress while the initial index builds
so clients can tell a slow index from a stalled tool call. On a large repository the first query
can still take a while. CODE_INDEXING_INDEX_MODE=eager indexes during tool listing, then keeps one
debounced filesystem monitor per discovered root and refreshes it after later changes. Changes that
arrive during a refresh are coalesced into one follow-up pass. A stat-only reconciliation every 30
seconds catches missed notifications and Git exclusion changes outside the watched root, and a
failed filesystem watcher restarts with bounded backoff. CODE_INDEXING_INDEX_MODE=manual restricts
indexing to explicit index_project calls. The legacy CODE_INDEXING_AUTO_INDEX flag remains
supported. Clients that do not provide filesystem roots can still auto-refresh explicitly selected,
already registered projects; discovering a new project requires a root or init_project.
project_status performs the same metadata comparison without rebuilding and reports stale when
a stored ready or partial index has drifted from the source tree.
Two things can make an automatic refresh wait: another root queued ahead of it in the same session,
and another process holding the global index lock. One budget covers both. The refresh retries with
exponential backoff for up to five minutes, then fails the waiting query with INDEX_BUSY rather
than blocking indefinitely:
export CODE_INDEXING_INDEX_WAIT_SECONDS=300Set it to 0 to fail immediately whenever anything else is already indexing, or raise it when a
single cold index legitimately takes longer than the default.
Incremental refreshes:
Matching size and nanosecond mtime skips reading the file.
Changed metadata triggers SHA-256 verification.
Unchanged content is neither parsed nor embedded.
Changed files are replaced transactionally in LanceDB.
Removed files are deleted from the active index.
A parse or embedding failure preserves the previous indexed version.
Binary and undecodable files are detected while the file is read for indexing, not during the scan, so each changed file is read once. They count toward
skipped_filesand are not recorded as per-file errors.
These languages are supported, all through packaged grammars that need no toolchain of their own — no JDK, Maven, Gradle, .NET SDK, Godot, or database connection:
The languages column is the exact value search_code's languages filter accepts, which is not
always the lowercased language name — .tsx is classified as its own language rather than as
TypeScript, and the two Godot data formats are one language.
Language | Extensions |
| Extracted symbol kinds |
Python |
|
| classes, functions, methods |
Java |
|
| classes, interfaces, records, enums, annotation types, methods, constructors, enum constants |
JavaScript |
|
| classes, functions, methods |
TypeScript |
|
| classes, interfaces, type aliases, enums, functions, methods |
TSX |
|
| classes, interfaces, type aliases, enums, functions, methods |
C# |
|
| classes, interfaces, structs, records, enums, enum members, delegates, methods, local functions, constructors, destructors, properties |
GDScript |
|
| classes (including |
Godot shaders |
|
| functions, structs, uniforms (as properties), constants |
Godot scenes and resources |
|
| scene nodes by |
SQL |
|
| tables, views, materialized views, indexes, functions, triggers, types |
YAML |
|
| collection-valued keys, qualified by their path |
JSON |
|
| collection-valued keys, qualified by their path |
| Go | .go | go | types, functions, methods, constants | | Terraform | .tf, .tfvars | terraform | variable and resource blocks | | Rust | .rs | rust | structs, enums, functions, constants | | C | .c, .h | c | preprocessor constants, structs, functions | | C++ | .cc, .cpp, .cxx, .hh, .hpp, .hxx | cpp | classes, methods, functions | | Lua | .lua | lua | functions |
Nested declarations are qualified by their enclosing scope in every language, so a C# method
indexes as Outer.Inner.Work and a Compose service port list as services.web.ports.
The kinds above name what is extracted, not always the kind recorded against it. A C# destructor
is recorded as a method, under the type's own name — ~Catalog indexes as Catalog.Catalog,
the same symbol as the constructor — because the grammar names it with the type identifier alone.
Filter on method to reach either.
YAML and JSON deliberately extract only keys whose value is a mapping/object or a
sequence/array. Making a symbol of every scalar leaf would turn one large configuration file into
thousands of one-line chunks; scalars still reach the index inside the enclosing key's chunk.
SQL CREATE PROCEDURE is not extracted, because the bundled SQL grammar does not parse it.
Godot scene and resource files name only some of their sections: [gd_scene], [gd_resource],
[resource], [connection], and the sections of a project.godot carry neither a name nor an
id, so they reach the index as searchable text rather than as symbols.
The scanner respects Git's complete standard ignore stack: root and nested .gitignore files,
.git/info/exclude, and the configured global excludes file. It also excludes symlinks, binary
files, files over 1 MiB, build outputs, virtual environments, dependency directories, and Godot's
own .godot asset cache — a project.godot file is still indexed. That 1 MiB cap is what
usually keeps a large generated package-lock.json or similar out of the index; exclude it through
scan.exclude if a smaller one is not worth indexing.
Existing project markers whose scan.include still holds an older default list are upgraded to the
current one at runtime, so a project created before a language was supported picks it up without
being re-initialized. A customized scan.include list is never rewritten — add the patterns you
want (**/*.cs, **/*.gd, **/*.gdshader, **/*.tscn, **/*.tres, **/*.sql, **/*.yaml,
**/*.yml, **/*.json) explicitly.
Multi-project search
For deliberate cross-repository debugging, call list_projects first, choose the related
registrations, then call search_across_projects with at least two project IDs, unique names, or
paths. Selection is required: the dedicated tool has no all_projects switch, and aliases must
resolve to at least two distinct projects. It returns one globally ranked hit list with
project_id and project_name on every result.
languages, paths, kinds, and limit apply across the complete selected scope rather than
once per repository. Path globs are interpreted relative to each project root; *.py matches a
Python file at any depth while src/* matches only direct children of src. A single * and
** both span one path segment. Patterns are translated into the index scan itself, so a filtered
search finds matches that rank below the unfiltered result window instead of returning an empty
result.
search_code remains compatible: it still defaults to the active project, accepts an explicit
list of project IDs/names/paths, and supports all_projects=true when searching every registration
is intentional.
remove_project deletes only central index data. It never removes source files or the local
.ci-mcp marker.
Storage and offline operation
Platform-specific user data and cache locations are selected with platformdirs. Override them
when needed:
export CODE_INDEXING_DATA_DIR=/path/to/index-data
export CODE_INDEXING_CACHE_DIR=/path/to/model-cache
export CODE_INDEXING_OFFLINE=1Indexing uses a spawned embedding worker with an adaptive ceiling of 25% of physical RAM, clamped to 1–2 GiB and reduced further to retain 512 MiB of currently available RAM for the system. Configure it with:
export CODE_INDEXING_EMBED_MEMORY_MB=1536 # CODE_INDEXING_INDEX_MEMORY_MB is the older name and still works
export CODE_INDEXING_EMBED_BATCH_SIZE=auto # auto, or 1–256
export CODE_INDEXING_EMBED_MAX_TOKENS=1024
export CODE_INDEXING_EMBED_OVERLAP_TOKENS=64
export CODE_INDEXING_EMBED_THREADS=2
export CODE_INDEXING_EMBED_CPU_ARENA=0
export CODE_INDEXING_VECTOR_INDEX=exactThe ceiling covers indexing memory: the embedding worker plus any growth in the host process while
indexing runs. Memory the host already held when the worker started — the daemon's query model and
open Lance datasets — is not charged to the budget, so a warm daemon can still index. IndexReport
reports both the budget and the true combined peak, plus a scan/parse/embed/commit duration split.
Microbatches are bounded by three things at once: the item count above, the token budget per window,
and the padded matrix item_count × longest_padded_tokens, which is what a batch actually
materializes. That last budget scales with CODE_INDEXING_EMBED_MEMORY_MB — it is memory charged to the
same ceiling — floored so a single longest window always forms a batch and capped at eight times the
2 GiB reference, because padding cost is quadratic in the widest member and nothing has been
measured past that.
A batch that overruns the ceiling is halved and retried, and the size that survived is adopted for
the rest of that run — otherwise every group after it asks for the size that just overran and pays
the same retries again — and written to the probe cache so the next run starts there rather than
rediscovering the same limit. model status reports that size as "reduced" rather than
"measured", so a machine pinned low by one bad run says so. An explicit CODE_INDEXING_EMBED_BATCH_SIZE
overrides it outright.
The same applies to a batch that takes the worker down with it rather than tripping the ceiling, which is what a device allocation failure usually looks like: the sizes measured below it still stand, so the backend is calibrated rather than left unmeasured. Only the memory ceiling has a setting behind it, so only that case recommends one.
Embedding backends
Acceleration targets passage indexing only. The query model stays in the serving process on CPU, so a search never waits on a worker spawning or a model loading onto a device.
export CODE_INDEXING_EMBED_ACCELERATOR=auto # auto, cpu, cuda, mlx, webgpu, migraphx, coreml
export CODE_INDEXING_EMBED_STRICT=0 # 1 disables the CPU fallback and the crossover
export CODE_INDEXING_EMBED_CROSSOVER=auto # auto, off, or a character count
export CODE_INDEXING_EMBED_CALIBRATE=1 # 0 declines the one-time measurementauto selects the best backend that has passed its promotion gates and that this installation
actually offers. CUDA is the first backend promoted to automatic selection; WebGPU and MIGraphX are
still experimental and Core ML stays manual-only, because on this model it offloaded only part of
the graph and lost to CPU. Naming a backend explicitly overrides its stability, and is how one earns
the benchmark evidence its own promotion needs.
Promotion makes CUDA eligible, not present. auto still resolves to CPU on a machine where the
installer never prepared it, which is every machine without a supported NVIDIA driver.
The locked installation matrix is:
Backend | Installer support | Pinned runtime | Low-level route | Selection |
CPU | Python 3.12/3.13 on every supported OS |
| CPU | automatic fallback |
CUDA | Linux x86-64 or Windows x86-64; NVIDIA driver 525.60+ or 527.41+ | ONNX Runtime GPU 1.22–1.23, CUDA 12, cuDNN 9 | CUDA | automatic |
MLX | macOS 14+ Apple Silicon | MLX 0.32.0 | Metal | automatic |
WebGPU | macOS 14+ Apple Silicon, Linux x86-64 with glibc 2.27+, or Windows x86-64 | ONNX Runtime 1.24.4 + WebGPU plugin 0.1.0 | Metal, Vulkan, or D3D12/Vulkan | experimental, explicit only |
MIGraphX | Linux x86-64, Python 3.12, ROCm 7.2.1+ (ROCm 7 only) | AMD ONNX Runtime/MIGraphX 1.23.2 | ROCm/MIGraphX | experimental, explicit only |
Core ML | macOS, when the serving runtime exposes it | serving CPU environment | Core ML | manual only |
An unsupported explicit MIGraphX request tries the locked WebGPU path when that platform has the
complete plugin/core wheel pair, then falls back to CPU. An unsupported explicit MLX request does
not: a request for Metal on a machine with no Metal is not a request for Vulkan, so it reports CPU
and says why. WebGPU and MIGraphX are not considered by auto while experimental.
MLX is the Metal path because WebGPU lost on Apple Silicon. On an M4 Pro, WebGPU indexed 1,000
chunks at 1.11× of CPU against a 1.25× promotion threshold; MLX reached 1.52–1.56× on the same
corpus, with vectors matching CPU to cosine 1.0 and identical top-5 rankings, so auto prepares it
there.
The accelerator environment
fastembed and fastembed-gpu install the same module over two different ONNX Runtime
distributions that both own the onnxruntime import. The direct WebGPU and MIGraphX runtimes own
that import too, so none can share an environment. The serving environment is therefore pinned to
the cpu extra — it embeds queries in-process and is what every accelerator falls back to — and an
accelerator gets a second locked environment of its own under the install directory, sharing the
same model cache. All five runtime extras are resolved from one lockfile that declares them
mutually exclusive.
The installer prepares that environment and records it at accelerator.json in the runtime data
directory. It is written only after a real inference passes in the environment it describes:
detection nominates a backend, and only the probe confirms one. The record is re-checked on every
start — a missing interpreter or a server upgraded past the Python the environment was built for
retires it with a reason rather than being repaired, since repairing it would mean installing
something while serving.
CUDA continues through FastEmbed. WebGPU and MIGraphX instead use the project-owned direct ONNX
passage model because FastEmbed cannot configure those providers. It resolves the same
jinaai/jina-embeddings-v2-base-code snapshot and ONNX artifact as CPU, applies the same tokenizer
configuration, attention-mask mean pooling, float32 normalization, and 768-vector shape, and
reports the providers the session actually resolved.
MLX cannot execute ONNX at all, so it reproduces that model instead of running it: the float32
initializers are lifted out of the same ONNX artifact and the JinaBERT v2 graph they belong to —
ALiBi, post-norm query and key projections, a GEGLU feed-forward — is written out again in MLX. The
tokenizer, pooling, normalization, and vector shape are shared with the direct ONNX model, and the
configuration and every tensor name and shape are checked on load, so an artifact whose graph moved
fails rather than returning vectors that no longer retrieve. The one-time conversion is written to
<cache>/models/mlx/<revision>-jina-v1-f32.safetensors and memory-mapped afterwards; it is keyed by
model revision, so a model that moves is converted again rather than read from a stale file.
A passage worker for an accelerator runs from that environment's own interpreter, dialling back to
the serving process over an authenticated local socket. multiprocessing cannot cross that
boundary: spawn hands the child the parent's sys.path, so the accelerator interpreter would
start up and then import the serving environment's CPU runtime. Everything above the handshake —
the command protocol, the memory ceiling, the batch retries, the CPU fallback — is the same for
both kinds of worker.
That socket is a Unix socket in a private directory, or a loopback port on Windows, which has no filesystem permissions to lean on. One deadline covers connecting and authenticating, and a peer that fails either is dropped so the wait can go on — so no other local process can take the slot the worker needs, or hold a start-up open by connecting and going quiet.
Passage embedding runs in a disposable worker that is torn down after indexing, releasing VRAM or unified memory. Before any real content reaches an accelerator, the worker loads the model and runs a minimum-batch inference, and the parent checks that the vectors are the right width, finite, and normalised. A backend that fails to load, silently falls back to a different execution provider, returns unusable vectors, overruns the memory ceiling, or dies mid-run is terminated, and the chunks it had not committed are re-embedded on CPU. Chunks are committed per file only after they are fully embedded, so a worker crash can neither fail the run nor corrupt an existing index.
The ceiling is measured as host resident memory, which on unified memory covers the accelerator too. A discrete GPU's VRAM is not visible to it, so exhausting a graphics card surfaces as a worker that died rather than as a budget that was exceeded — both fall back to CPU, but only one names a number.
CODE_INDEXING_EMBED_STRICT=1 refuses that fallback and raises BACKEND_UNAVAILABLE instead, for callers
who would rather fail than index at CPU speed without noticing. An auto selection that settles on
CPU is not a fallback and is unaffected.
Successful probes are cached under the cache directory, keyed by model artifact, execution provider, ONNX Runtime version, OS/architecture, and device — so any of those moving invalidates the record rather than vouching for a backend that no longer works. The driver version comes from the installer's record, so a driver upgrade retires the verdict recorded under the old one. A cached probe skips the inference but never the model load, which is what proves the provider still initialises on this boot — so a driver change that breaks a provider outright still surfaces as a failed load.
A backend that fails is not retried for the life of the process. Only successes are cached, so without that a long-lived daemon would reload a dead accelerator onto the device before every index run.
When the accelerator is worth starting
A prepared accelerator is not worth using for every run. Starting one means spawning an interpreter, loading the model onto a device, and verifying it — which a re-index of a single edited file cannot repay. So the first run that verifies a backend also measures it, through the ordinary embedding path, at a ladder of batch sizes against a synthetic code-shaped corpus. It measures CPU the same way at the same time, because the decision is a comparison and a comparison needs both sides. Both results go in the probe cache, under the same key as the probe: model artifact, accelerator, provider, runtime version, platform, device, and driver. Anything moving re-measures.
Two policies then have a cost each — L_cpu + n/R_cpu for staying on CPU and L_accel + n/R_accel
for using the accelerator — and they meet at
crossover = (L_accel - L_cpu) / (1/R_cpu - 1/R_accel)characters of candidate text. What the accelerator has to earn back is the difference between the two loads, not its whole load: staying on CPU spawns a worker and loads a model too. On unified memory the accelerator often loads faster — an M4 Pro maps the converted MLX weights in 370 ms against 655 ms for the CPU ONNX graph — so the crossover is zero and it starts immediately.
Where there is a crossover, the run begins on CPU and switches to the accelerator on the request that carries it past the threshold, counting the request in hand so the one group large enough to justify the device is not itself sent to CPU. Deciding this late costs at most one model load on a run that turns out to be large, and saves the same load on one that turns out to be small; the pipeline streams, so the size of a run is not known until it is over. The run that does the measuring also embeds at the size it just measured, rather than at the one its plan was built with before the sweep existed.
An accelerator measured no faster than CPU has no crossover at all and is never started. That is
reported as no threshold rather than as a very large one — crossover_characters is null in both
model status and IndexReport, and the run says the backend measured no faster than CPU instead
of naming a size it stayed below.
A deferral is not a fallback. fallback_count stays at zero, no embedding_fallback_reason is set,
and the process is not pinned to CPU the way a real degradation pins it — the next run decides again
from its own size. IndexReport carries embedded_characters, embedding_crossover_characters, and
embedding_selection_reason, so a run that stayed on CPU because it was small reads differently from
one that fell back because something broke.
CODE_INDEXING_EMBED_CROSSOVER=off starts the accelerator on the first chunk, which is what every release
before this one did; a character count pins the threshold. CODE_INDEXING_EMBED_CALIBRATE=0 declines the
measurement, leaving both the batch size and the crossover unmeasured.
CODE_INDEXING_EMBED_STRICT=1 turns the crossover off too. Strict mode is for a caller who would rather
fail than quietly index at CPU speed, and a deferral is exactly that — quiet CPU indexing that no
degradation reports and that strict mode could not refuse, because nothing failed.
Measurement never installs, downloads, or changes anything: it embeds through a worker that is
already running and writes one JSON file under the cache directory. The sweep runs on the worker
the run will go on to use, so what it embedded, the retries it provoked, and the ceiling it walked
up to are put back afterwards: they are measurement, not work the run did, and an IndexReport that
counted them would describe a failure that never happened. The CPU reference is measured last of
all, once the accelerator's worker has been retired, so the two models are never resident against
the same ceiling at the same time.
code-indexing-mcp model status reports the whole resolution without loading or probing anything:
$ code-indexing-mcp model status
{
"accelerator_characters_per_second": null,
"accelerator_environment": null,
"accelerator_load_ms": null,
"accelerator_prepared": null,
"available_providers": ["CoreMLExecutionProvider", "AzureExecutionProvider", "CPUExecutionProvider"],
"batch_calibration": "default",
"batch_size": 1,
"cpu_characters_per_second": null,
"crossover_characters": null,
"device": "cpu",
"dimension": 768,
"driver_version": "",
"embedding_model": "jinaai/jina-embeddings-v2-base-code",
"execution_provider": "CPUExecutionProvider",
"fallback_reason": "no accelerator is prepared and eligible on this machine; reinstall with --accelerator to prepare one, or set CODE_INDEXING_EMBED_ACCELERATOR to force a backend this installation already offers",
"precision": "float32",
"probe_cache_state": "not-applicable",
"recommended_override": null,
"requested_accelerator": "auto",
"resolved_accelerator": "cpu",
"runtime_version": "1.27.0",
"stability": "automatic",
"strict": false
}accelerator_environment names the interpreter passage embedding will run in when that is not
this process's own, and accelerator_prepared names what the installer prepared, whether or not
selection chose it.
The measured fields are null until a run has verified and measured an accelerator on this machine.
crossover_characters is null both before measurement and when the accelerator never overtakes CPU
at any size — the two are distinguished by accelerator_characters_per_second being present.
recommended_override names the one setting change the measurements argue for, when they argue for
one: CODE_INDEXING_EMBED_ACCELERATOR=cpu for an accelerator that lost, or CODE_INDEXING_EMBED_MEMORY_MB for a
batch size a ceiling overrun pinned down. It stays null the rest of the time rather than offering
advice nothing measured.
If installation reports a fallback, rerun the explicit installer command to see the build/probe
reason, then use code-indexing-mcp model status to distinguish the requested backend, prepared
environment, resolved provider, probe state, and fallback reason. Native provider failures never
trigger a package install or driver change in the server; the installer removes a failed
environment before CPU is reported.
IndexReport carries embedding_backend, embedding_fallback_reason, and fallback_count, so a
run that started on an accelerator and finished on CPU says so rather than merely being slow.
embedding_backend is where the work actually happened, which is not always what selection named: a
run below the crossover reports cpu with an embedding_selection_reason and no fallback.
The embedding model, tokenizer, pooling, normalisation, and vector dimension are the same on every backend, and execution provider and precision are diagnostic metadata rather than part of index compatibility — switching backends never requires a reindex.
Token-bounded chunks
Sequence length, not character count, drives embedding memory: attention is quadratic in tokens. The same 4,096 characters cost wildly different amounts depending on how densely they tokenize — ordinary source is ~984 tokens, a minified line ~2,157 — and embedding the latter as one sequence adds ~1,172 MiB of resident memory against ~266 MiB for the same characters split into windows.
Every chunk is therefore windowed to at most CODE_INDEXING_EMBED_MAX_TOKENS tokens with
CODE_INDEXING_EMBED_OVERLAP_TOKENS of overlap before it reaches the model, and each window is stored as
its own chunk with its own byte and line offsets. Ordinary code is unaffected: a 1,024-token budget
is roughly 4,096 characters of source, so chunks that already fit stay whole and unchanged.
IndexReport carries embedded_segments, embedded_tokens, embedding_retries, and
token_windowing so a run's shape is visible without re-running it.
When a batch does trip the ceiling, the worker is replaced and the batch retried at half the microbatch size (4 → 2 → 1) before the error surfaces. Window boundaries come only from the tokenization, so a retry re-derives identical chunks.
Extraction is linear in file size and in definition count. Each Tree-sitter query is compiled once per language per process, and the definition and newline indexes are built once per file. A definition-dense generated file near the 1 MiB scan cap — 699 KB, 16,384 definitions — extracts in well under a second; earlier releases took roughly 31 seconds on the same shape because those indexes were rebuilt per definition.
Measured throughput
CODE_INDEXING_EMBED_BATCH_SIZE resolves to 1 on CPU. Measured with
scripts/benchmark_index_memory.py on Apple Silicon macOS against a 1.0 MiB, 6,330-chunk
dense-Python corpus at CODE_INDEXING_EMBED_MEMORY_MB=2048:
| Wall clock | Chunks/s | Peak worker RSS |
1 (default) | 147.0 s | 44.8 | 1,415 MiB |
2 | 136.2 s | 48.7 | 1,419 MiB |
4 | 130.4 s | 50.9 | 1,427 MiB |
8 | 126.7 s | 52.5 | 1,451 MiB |
Batch size 8 buys 17% throughput for 36 MiB more resident memory — not enough to justify spending
headroom that the worst-case file shape already needs. Embedding dominates: 141 s of the 147 s at
batch size 1. Plan for roughly 45 chunks per second, and remember that in the default lazy mode
the first search_code call waits for that work. On a large repository prefer
CODE_INDEXING_INDEX_MODE=eager (index during tool listing and monitor later changes) or
CODE_INDEXING_INDEX_MODE=manual with an explicit code-indexing-mcp index, so no query blocks on
a cold index.
Single-line and generated files
A single-line source file near the 1 MiB scan cap — a bundled or minified artifact — used to drive
the embedding worker past every ceiling measured (2048, 3072, and 4096 MiB alike) and abort the run
with INDEX_RESOURCE_LIMIT. Token-bounded windows fix that: the same file now indexes cleanly at
321/1,879/2,073 MiB parent/worker/combined against a 2,048 MiB ceiling.
Such files still cost time and index space for little retrieval value, so excluding them in
.ci-mcp/project.toml remains worthwhile:
[scan]
exclude = ["**/*.min.js", "**/*.bundle.js", "**/generated/**"]A file that cannot be parsed, planned, or embedded is recorded as a per-file issue and skipped until
it changes. Environment failures — MODEL_UNAVAILABLE, INDEX_RESOURCE_LIMIT, and
EMBEDDING_WORKER_FAILED — are not attributable to a file, so they abort the run and surface to the
caller instead of silently leaving that file permanently unindexed.
CODE_INDEXING_INDEX_EXECUTION=in-process is a temporary diagnostic rollback. It does not enforce the
worker ceiling. CODE_INDEXING_VECTOR_INDEX=hnsw opts into approximate vector indexing; exact search is
the safer default.
All stdio adapters use the per-user daemon by default. Administrative commands are:
uv run code-indexing-mcp daemon status
uv run code-indexing-mcp daemon restart
uv run code-indexing-mcp daemon stopSet CODE_INDEXING_BROKER=off or run serve --direct to bypass it. The daemon authenticates over a
current-user-only local socket, starts under leader election, and exits after five idle minutes.
The socket lives under XDG_RUNTIME_DIR when set and the platform temporary directory otherwise;
the containing directory must be a real directory owned by the current user, or startup fails
rather than binding somewhere another user controls. Startup output goes to daemon.log in the
data directory.
The daemon needs Unix domain sockets. Where they are unavailable — currently Windows — the default
CODE_INDEXING_BROKER=auto serves directly and logs a warning; an explicit CODE_INDEXING_BROKER=on fails with
INVALID_CONFIGURATION instead of being silently downgraded.
Storage schema v2 keeps a registry plus one LanceDB partition per project. On first upgrade from
v1, the old global store is moved to a timestamped lancedb-v1-backup-* directory and projects are
rebuilt lazily from source. Old chunk rows are never copied, which repairs duplicate chunk IDs.
With CODE_INDEXING_OFFLINE=1, Code Indexing MCP will not download a missing model and returns
MODEL_UNAVAILABLE instead. Source code, embeddings, and search queries remain local; there is
no telemetry.
Development
uv sync --all-groups --extra cpu --locked
uv run pytest
uv run pytest --cov=code_indexing_mcp
uv run ruff check .
uv run ruff format --check .
uv run mypy srcA version bump in src/code_indexing_mcp/__init__.py must ship with a regenerated uv.lock
(uv lock), or every user's update fails at the locked sync; CI's --locked sync gates it.
To exercise the real model integration, provide a persistent cache directory and opt in:
CODE_INDEXING_MODEL_TEST_CACHE=/path/to/cache uv run pytest -m modelDedicated MLX/WebGPU/MIGraphX runners can exercise the correctness and ≥1,000-chunk performance gates against an installer-created accelerator record:
CODE_INDEXING_MODEL_TEST_CACHE=/path/to/cache \
CODE_INDEXING_ACCEL_ENV=/path/to/accelerator.json \
CODE_INDEXING_TEST_ACCELERATOR=mlx \
uv run pytest -m acceleratorThe gate requires row cosine similarity of at least 0.999, search top-k overlap of at least 99%, and an end-to-end forced-index speedup of at least 1.25× before a backend can be considered for automatic promotion.
MLX's own unit tests need MLX, which the serving environment does not have. They run in the mlx
extra's environment, and check the forward pass against an independent NumPy implementation of the
same graph:
uv run --extra mlx pytest tests/test_mlx_backend.pyThe project intentionally excludes HTTP transports, dependency/call graphs, cross-reference resolution, and custom embedding profiles.
License
This project is licensed under the MIT License.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityAmaintenanceLocal-first code indexer that provides deep code understanding for Claude and other LLMs with symbol/text search across 48+ languages, semantic search capabilities, and real-time index updates through the Model Context Protocol.55MIT
- AlicenseBqualityAmaintenanceLocal-first codebase intelligence engine providing AI coding agents with a typed MCP toolset for understanding and navigating code repositories.10051Apache 2.0
- Alicense-qualityDmaintenanceA local, SQLite-backed code index for Claude Code, exposed over MCP, enabling targeted code retrieval without external APIs.1MIT
- AlicenseAqualityAmaintenanceLocal MCP server for semantic code search using Tree-sitter AST parsing, local embeddings, and hybrid search; enables indexing and querying codebases entirely offline.5MIT
Related MCP Connectors
Give your AI agent a persistent map of your project's structure, dependencies, and bugs.
Enterprise code intelligence for M&A, security audits, and tech debt. Hosted server with 200k free.
An MCP server that gives your AI access to the source code and docs of all public github repos
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/MarcinHamiga/code-indexing-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server