Local Elixir
FreeNot checkedRun local MCP for hexdocs digest, hex-search, gh-search, remember and recall learnings
About
Run local MCP for hexdocs digest, hex-search, gh-search, remember and recall learnings
README
A lightweight Elixir MCP server designed to run over stdio transport with a local SQLite database and an AI_API_KEY.
search_docs: ask a question about an Elixir package; the tool answers from the local index, or downloads and digests the package first if it is not indexed yet. A first-time ingestion of a large package may exceed one tool call — the payload then reports progress and the job continues in the background.list_indexed_packages: what is indexed, at which version, whether it is complete, and how that compares to the version this project depends on.remember: save one or more learnings — takes a list, so a whole session's lessons go in a single call. Curation runs in the background and may merge, append to or discard what you send.recall: check your knowledge databasesearch_github_issues: search a GitHub organization's issues and PRs, live. Not stored locally.
you can check your LLM helper consumption with
get_token_usage.
Tech
SQLite+ FTS5 +sqlite-vecanubis_mcp: Compatible with Claude Code, Cursor, and Google Antigravity CLI (agy).MDExfor markdown (parsing and source positions — never rendering),lazy_html(Lexbor) for HTML extraction and per-function source linksBumblebeeto run the cross-encoding reranker- Cloud AI models (embeddings, chat-small, chat-medium).
[!IMPORTANT] It uses AI support for computing embeddings and chat completions; you must provide an AI_API_KEY and three models.
| Tool | Embeddings (/embeddings) | Chat Model (/chat/completions) |
|---|---|---|
| search_docs | Yes (embed / embed_batch) | No |
| search_hex_packages | No | No |
| search_github_issues | No | No |
| recall | Yes (embed) | No |
| remember | Yes (embed) | Yes (small & large for taxonomy & deduplication) |
Features
- Hybrid search: FTS5 and
vec_distance_cosineselects candidates, rank-fusion merging process and final cross-encoding reranking. - Curated knowledge memory: submissions are embedded, compared to their nearest neighbours, and only then passed to a chat model that chooses
create,append,merge,replace,deprecateordiscard. - HexDocs ingestion: fetches a package's docs tarball from Hex, extracts it in memory, and indexes it with embeddings on demand. One HTTP request per package, never a page-by-page crawl.
- Version-aware: with
PROJECT_ROOTset,latestresolves to whatever your project'smix.lockpins — any age, pre-releases included — falling back to Hex's latest stable when nothing local pins the package. One version per package is kept, so results never interleave versions. - GitHub issues are queried live against the GitHub API and are not stored locally.
Tools
| Tool | Description |
|---|---|
search_docs |
Search one Hex package's documentation, typespecs, guides and examples. package is required; ingests on demand. Results carry hexdocs_url and, for functions, a source_url pinned to the exact file, line and version |
list_indexed_packages |
List indexed packages, versions, completeness, and the version this project depends on |
search_hex_packages |
Find which package to use — Hex.pm names, descriptions, download counts |
search_github_issues |
Live GitHub API search for issues and PRs in an organization (not indexed) |
remember |
Save learnings to the local knowledge base. Takes texts, a list — batch a session's lessons into one call |
recall |
Search that knowledge base before re-investigating a failure |
get_token_usage |
AI token consumption recorded locally, by model and date range |
Quickstart
Prerequisites
- Elixir 1.20+
- An AI API provider key. Mistral has a generous free tier and is the default, so the key alone is enough to start; any OpenAI-compatible
/embeddingsand/chat/completionsendpoint works viaAI_API_URL.
SQLite needs no separate install — ecto_sqlite3 bundles it — and neither does the vector extension: sqlite_vec ships the binary and config/runtime.exs loads it with SqliteVec.path().
Setup
Cloning is not the whole job. Five steps, and skipping any of the first three leaves a server that starts and then fails in a way that looks like a configuration problem.
1. Dependencies and database
DATABASE_PATH="priv/mcp.db" mix setup
mix setup runs deps.get, ecto.create and ecto.migrate. DATABASE_PATH must be set for every command that touches the database, migrations included — without it Ecto uses the configured default and you migrate a different file than the server will open.
2. Compile for prod, not dev
MIX_ENV=prod mix compile
The MCP config below runs mix mcp.server --no-compile under MIX_ENV=prod, so a dev build does not satisfy it. This is also the step to repeat after any code change — and recompiling alone is not enough, the server must then be reconnected, because a running BEAM keeps the modules it already loaded.
3. Warm the reranker
MIX_ENV=prod mix run -e ":ok"
Booting the app loads cross-encoder/ms-marco-MiniLM-L-6-v2 (~97 MB from HuggingFace) and compiles it with EXLA. Both are cached afterwards, but on a cold machine they happen inside Application.start/2 — that is, inside the window your MCP client is waiting for the server to come up. Doing it once here, where you can watch it, avoids a first connection that times out and looks broken.
4. Point your assistant at it
Copy .mcp.example.json (Claude Code, Cursor) or .mcp_config.example.json (Antigravity) into the project you want to use the server from, then edit the absolute paths, AI_API_KEY, DATABASE_PATH and PROJECT_ROOT. The next section explains each.
5. Copy the agent instructions
Copy CLAUDE.md — or AGENTS.md, the same content under the name other assistants read — into that same project. It is what teaches the assistant that package is required, that a multi-package question is several calls, and how to read the notices field. Without it the tools work but are used badly.
One server per project.
DATABASE_PATHandPROJECT_ROOTare per-project settings. Only one version per package is kept, so two projects sharing a database evict each other's docs and re-embed on every switch.
AI Code Assistant Configuration
Claude Code / Cursor (.mcp.json):
{
"mcpServers": {
"hex_local": {
"command": "sh",
"args": [
"-c",
"cd /absolute/path/to/local_hex_mcp && exec /opt/homebrew/bin/mix mcp.server --no-compile"
],
"env": {
"MIX_ENV": "prod",
"AI_API_KEY": "your-key-here",
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
"DATABASE_PATH": "/absolute/path/to/local_hex_mcp/priv/mcp.db",
"PROJECT_ROOT": "/absolute/path/to/the/repo/you/are/editing"
}
}
}
}
Google Antigravity (.agents/mcp_config.json):
{
"mcpServers": {
"hex_local": {
"command": "mix",
"args": ["mcp.server", "--no-compile"],
"cwd": "/absolute/path/to/local_hex_mcp",
"env": {
"MIX_ENV": "prod",
"MIX_QUIET": "1",
"AI_API_KEY": "your-key-here",
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
"DATABASE_PATH": "/absolute/path/to/local_hex_mcp/priv/mcp.db",
"PROJECT_ROOT": "/absolute/path/to/the/repo/you/are/editing"
}
}
}
}
Ingestion tuning
Set in the env block of your MCP config; they take effect on a server restart, with no recompile. A malformed value falls back to the default rather than raising.
| Variable | Default | Notes |
|---|---|---|
INGEST_TIMEOUT_MS |
25000 |
How long a search waits for an in-flight ingestion. Must stay below the MCP transport's request ceiling (Anubis' session call gives up at 30s), or the request dies before the progress notice can be returned. |
EMBED_BATCH_SIZE |
200 |
Inputs per embeddings request. The provider limits requests per second, so larger batches reduce 429s. The real ceiling is tokens per request, but a token-limit rejection is bisected automatically, so this rarely needs tuning. |
EMBED_CONCURRENCY |
2 |
Concurrent embedding requests. Bounded by the Finch pool and the provider's rate limit — not by CPU count; the work is IO-bound. |
EMBED_PAUSE_MS |
0 |
Pause after each embedding request, for providers that limit requests per second. 0 means no pacing. Raise only if 429s persist after lowering concurrency. |
Everything else the server reads:
| Variable | Default | Notes |
|---|---|---|
AI_API_KEY |
— | Required. AI_API_URL points at any OpenAI-compatible provider. |
DATABASE_PATH |
config | Per project. Two projects sharing one file evict each other. |
PROJECT_ROOT |
unset | The repo being edited. Its mix.lock then decides versions — see below. |
AI_EMBED_MODEL |
mistral-embed |
Changing it invalidates the whole index; mix docs.reindex is the supported path. |
AI_CHAT_MODEL_SMALL / _LARGE |
mistral-small-latest / mistral-medium-latest |
Structuring and curation for remember. |
AI_RERANK_MODEL |
cross-encoder/ms-marco-MiniLM-L-6-v2 |
Must be an architecture Bumblebee implements with a sequence-classification head. |
AI_RERANK_STRATEGY |
fused |
fused, pure or gated — how much authority the cross-encoder has over retrieval order. |
GITHUB_TOKEN |
unset | Raises the rate limit for search_github_issues. |
MCP_LOG_FILE / MCP_LOG_LEVEL |
unset / warning |
The only way to see anything: stderr is discarded by the client. |
Changing code requires
MIX_ENV=prod mix compileand reconnecting the MCP server — recompiling alone does not reload modules into a running BEAM.
Telemetry
The tool get_token_usage returns the token consumption.
The query:
get_token_usage(from: "2026-08-01", until: "2026-08-01")
returns a clean human-friendly Markdown
example
{
"total_requests": 12,
"total_tokens": 10450,
"total_prompt_tokens": 8200,
"total_completion_tokens": 2250,
"period": {
"from": "2026-08-01",
"until": "2026-08-01"
},
"by_model": {
"mistral-embed": {
"requests": 8,
"prompt_tokens": 6500,
"completion_tokens": 0,
"total_tokens": 6500
},
"mistral-small-latest": {
"requests": 4,
"prompt_tokens": 1700,
"completion_tokens": 2250,
"total_tokens": 3950
}
}
}
Optional: Litestream replication
Replicate your SQLite database to cloud storage (S3, B2, etc.) for backup and portability.
- Install Litestream
- Configure replication for
DATABASE_PATH
Which version do you get?
The server runs from its own directory, so :application.get_key/2 reports the
dependencies of local_hex_mcp and never the repo you are editing. Left at that,
latest falls through to Hex's latest stable, which may be a different major
line than your code compiles against — and nothing in the answer says so.
PROJECT_ROOT closes that. Point it at the repo you are working in and the
server reads that project's mix.lock itself:
explicit version: -> $PROJECT_ROOT/mix.lock -> the server's own BEAM -> Hex latest stable
| source | switches the index? | notes |
|---|---|---|
version: argument |
yes | Explicit wins over everything. |
$PROJECT_ROOT/mix.lock |
yes | Authoritative for this project, and may point backwards. |
| the server's own BEAM | no — reports drift | An accident of where the server was launched, not a statement about you. |
| Hex latest stable | no — reports drift | Correct when nothing local pins the package. |
Read fresh on every lookup, never cached, so mix deps.get mid-session is
picked up without restarting the server.
The lockfile may downgrade, deliberately. A project on boruta 2.3.0 gets
2.3.0 docs even though 3.0.0-beta.4 exists. Refusing to move backwards would
serve documentation for code the project does not run — silently, which is worse
than the re-ingest it costs. Pre-releases are equally fine: the lockfile is a
statement of fact, not a preference to be second-guessed.
Only one version per package is kept. Honouring a second project's lockfile therefore evicts the first project's docs, and the search says so:
Replaced indexed 'nimble_options' v1.1.1 with v1.1.0, which /path/to/mix.lock pins. Only one version per package is kept. If two projects share this DATABASE_PATH they will evict each other and re-embed on every switch — give each project its own DATABASE_PATH and PROJECT_ROOT.
A single switch is normal — you changed projects. Repeated switching means two projects share a database, and that is a configuration problem no retry fixes.
Without PROJECT_ROOT the old rule applies: pass version yourself whenever
the answer must match your lockfile, or accept Hex latest stable.
The embedding model is part of the index
Every vector must come from the same model. Two models are never comparable: different dimensions make sqlite-vec raise, and identical dimensions raise nothing at all while returning noise.
embedding_config records the model and dimension actually used, read from the
provider's response at ingest time. From that:
- Ingestion refuses to write vectors from a different model, before downloading anything.
- Search disables the vector arm on a mismatch and returns a notice, instead of raising into a rescue and silently degrading to keyword-only.
list_indexed_packagesreportsindex_model,dims,query_modelandmatches_config?.
So changing AI_EMBED_MODEL always costs a full re-embed of every package —
that is a property of embeddings, not of this storage. mix docs.reindex is the
supported way to do it.
Maintenance tasks
| Task | What it does | Costs |
|---|---|---|
mix docs.drift |
Re-derives every package's chunks from its tarball and compares to what is stored. Answers "would a refresh change anything?" | One download per package, no embeddings |
mix docs.reindex |
Re-downloads and re-embeds every indexed package under the current model. The supported way to change AI_EMBED_MODEL. |
Full re-embed |
mix docs.sources |
Backfills source_url on rows indexed before that column existed. |
One download per package, no embeddings |
mix docs.eval |
Retrieval benchmark over a fixed query set: recall@5, MRR@10, candidate recall, per arm. --show N prints the documents each query actually returned. |
One embedding per query |
mix docs.judge |
Writes priv/eval/judgements.md — what the server returned, for a human to mark relevant or not. mix docs.eval --judged then scores precision rather than a single guessed target. |
One embedding per query |
The reflex after changing the chunker is to re-index everything. mix docs.drift
is the cheap check that usually says you do not have to.
Knowledge curation
remember does not simply append. Each submission is embedded, compared against
its nearest stored neighbours, and — when one is close enough to be worth
thinking about — passed to a chat model that decides what to do with it.
| Action | When | What happens |
|---|---|---|
| create | No similar neighbors (similarity < 0.8) | New entry |
| discard | Too similar (> 0.9), no additional value | Do nothing |
| append | Similar neighbor, new info adds value | Concatenate to existing content |
| merge | Overlapping but complementary info | Synthesize old + new into one entry |
| replace | Old info is factually wrong/superseded | Replace content of existing entry |
| deprecate | Neighbor is outdated by new info | Mark old as outdated=true |
Two thresholds govern it. Below @similarity_threshold (0.8) nothing reaches the
LLM: the entry is created directly and flagged curated: false, which is what
makes the floor auditable rather than a guess. Above @duplicate_threshold (0.9)
a submission carrying no new fact is discarded.
The floor was 0.7 and that was too low to mean anything. Measured over 91 pairs of known-distinct entries: median similarity 0.738, maximum 0.943, and 73% cleared 0.70 — so the gate fired on three quarters of all pairs, none of them duplicates, and every submission reached the large model. Cosine similarity on prose measures topic, and a knowledge base that is all "Elixir debugging findings" is one topic. At 0.80 that drops to 19%.
It errs low on purpose. A threshold set too low costs one model call and the LLM
then correctly answers create; set too high, a real duplicate never reaches the
LLM and is stored forever. Cheap and self-correcting beats permanent.
Curation is asynchronous — remember returns a request_id per entry in
milliseconds and the work happens in a background task, in order. texts takes a
list precisely so a session's worth of lessons is one call: repeated calls buy no
parallelism (an Anubis session holds one request in flight) and firing several
concurrently is a reliable way to collect 429s.
Search engine
A query runs three stages: two retrieval arms in parallel, reciprocal rank fusion, then a cross-encoder whose verdict is fused back against the retrieval order rather than replacing it. Roughly 400ms end to end.
flowchart LR
Q([search_docs<br/>query + package])
Q -->|QuerySanitizer| FTS["FTS5 · BM25<br/>top 15 · 3ms"]
Q -->|embed, 1024-dim| VEC["sqlite-vec · cosine<br/>top 15 · 32ms"]
FTS --> RRF1{{"RRF k=60<br/>pool of 10"}}
VEC --> RRF1
RRF1 -->|10 pairs| CE["cross-encoder<br/>512 tokens · 390ms"]
CE -->|scored order| RRF2{{"RRF<br/>bounded rerank"}}
RRF1 -->|retrieval order| RRF2
RRF2 --> OUT([top 5 + notices])
classDef lex stroke:#b3600f,stroke-width:2px
classDef vecc stroke:#2b6d85,stroke-width:2px
class FTS lex
class VEC vecc
Fusion happens twice. The first pass unions the two arms — a union, not an intersection, so a document the keyword arm never matched can still be returned. The second pass fuses the cross-encoder's ordering with the one retrieval produced, so the model can move a document but not overrule the search outright.
Concretely, the second fusion is rank averaging: both inputs hold the same
ten documents, so each contributes 1/(60 + rank) twice and the result orders by
something very close to the sum of the two ranks. Nothing cleverer than that.
The case it was built for: on "prevent interception of the authorization code on a public client", all three retrieval arms put boruta's PKCE guide at rank 1 and
the cross-encoder dropped it to 8, preferring a typespec that lists
pkce: boolean() among thirty fields. Averaging the two rankings puts it back
at 2.
How well this generalises is not established. Against the 26-query set, fusing rather than letting the cross-encoder win outright moves 9 queries: five improve, four worsen, and every movement is one rank except the PKCE case that motivated the change. Remove that query and the effect is exactly zero. The aggregate difference (recall@5 0.92 → 0.96) is one query, and one standard error at this sample size is 0.043 — so the number is indistinguishable from noise.
It is kept on the mechanistic argument rather than the measurement: a bi-encoder and a cross-encoder fail differently, so averaging their ranks is the standard response to two rankers with uncorrelated errors. Treat it as unvalidated until the query set is large enough to test it.
Query, step by step
- Embed the query — one API call, 1024 dimensions. The only network hop in a warm query.
- Ensure the package is indexed — an unknown package triggers ingestion inline (below); a known one costs one row lookup and no network.
- Verify the embedding model — if the index was built by a different model, the vector is dropped and a notice says so. Vectors from two models are not comparable: different widths make sqlite-vec raise, identical widths return noise while raising nothing.
- Scope — package, version and the examples-only filter are applied to a base query that both arms join, so the depth budget is never spent on other packages.
- Keyword arm —
QuerySanitizerquotes each term as an FTS5 phrase and expands identifier-shaped terms into joined and split forms, soBoruta.Oauth.token/2searches for the exact symbol and its parts. BM25 order, top 15. - Vector arm — cosine distance over the scoped rows, top 15. This is the arm that bridges vocabulary gaps.
- Fuse to ten — each arm contributes
1/(60 + rank); an absent document contributes nothing. - Hydrate in fused order —
id IN (…)returns storage order, so the ranking is reimposed before the reranker sees it. - Rerank — the cross-encoder reads each query/document pair together and emits one relevance logit; the result is fused with step 7's order.
- Return five. The pool is ten; returning all of it hands back exactly the tail the reranker just demoted.
Ingestion
Runs inline on the first search naming a package. One HTTP request for the docs, nothing written to disk.
flowchart LR
S([unindexed package]) --> HEX["hex.pm API<br/>resolve version"]
HEX --> GUARD{same embedding<br/>model?}
GUARD -->|no| REFUSE([refuse before download])
GUARD -->|yes| TAR["docs tarball<br/>1 request, in memory"]
TAR --> IDX["search_data<br/>or sidebar_items"]
IDX --> CONTENT["item doc → markdown section<br/>→ HTML tree walk"]
CONTENT --> CHUNK["SectionChunker<br/>MDEx AST, sliced by sourcepos"]
CHUNK --> EMB["embeddings<br/>200 per batch, 2 concurrent"]
EMB -->|any failure| ABORT([abort, nothing written])
EMB --> REC["record model + dims<br/>from the response"]
REC --> SAVE[("package_docs<br/>FTS triggers sync")]
Notable decisions:
- A stored version that differs from the current release is reported, never silently replaced. Auto-switching would let one unpinned search discard a pinned version and force a full re-embed to get it back.
- The model guard runs before the download. A mixed-model index cannot be repaired by searching harder, and refusing costs one row read where proceeding costs a tarball plus a full re-embed.
- Chunking is structural, not byte-based. Boundaries fall on headings, then blocks, then list items, with a byte splitter kept only as the floor for a single oversized code block. Navigation sections — link lists made of the very titles people search for — are dropped. Nothing overlaps: text is sliced from the original markdown by source position, so what is stored is byte-identical to what the package published.
- Embedding failure aborts before a single row is written. Rows without vectors produce an index that looks complete, passes every count check, and is invisible to semantic search.
Why the depths are what they are
Every number was measured against a fixed 26-query set (mix docs.eval). Three
are counterintuitive.
| Setting | Value | Why not more |
|---|---|---|
| per-arm depth | 15 | Deeper is worse. RRF scores agreement, so at depth 40 an item ranked ~15 by both arms (1/75 + 1/75) outscores one ranked 3rd by a single arm (1/63), and the strong single-arm hit falls out of the pool. recall@5 drops 1.00 → 0.96 purely by retrieving more. |
| rerank pool | 10 | Retrieval wants depth; reranking does not. Candidate recall is already 1.00 at ten, so everything beyond is a distractor the model can mis-promote and nothing it can find. |
| sequence length | 512 | At 128 the pair truncates to ~400 characters. Symbol queries survive — the identifier is in the header — while conceptual answers sit deeper in the chunk and are never seen. Reranking at 128 scored worse than not reranking at all. |
Two properties worth knowing before trusting a measurement:
- BM25 is corpus-global. FTS5 uses collection-wide document frequency, so indexing any package shifts the keyword ranking of queries in other packages — measured, 53 new rows moved the keyword arm by 0.04 with nothing else touched. Vector search is immune. A control table is only valid for the corpus that produced it, which is why the report prints a corpus fingerprint.
- Bigger rerankers are not better here.
bge-reranker-base(278M) scores 0.78 MRR against MiniLM-L-6's 0.79 and is five times slower;ms-marco-MiniLM-L-12-v2loses two queries outright. Selectable viaAI_RERANK_MODEL.
When a stage fails
Every stage degrades to the one beneath it. None is an error the caller handles.
| Condition | Behaviour |
|---|---|
| No API key, or the index was built by another model | Keyword search alone, with a notice naming the fix |
| Cross-encoder not loaded, or its output unrecognised | Fused retrieval order, logged |
| Keyword terms match nothing | The vector arm carries the fusion alone |
| Both arms empty | No results — not an exception |
| Ingestion outruns the request budget | A progress notice; the job keeps running and the next identical search collects it |
Measured
26 queries — 14 conceptual, 12 bare identifiers — over 10 packages and 2,755
chunks. cand is the share of queries whose candidate pool contained the answer
at all, and is the ceiling on everything downstream.
| Strategy | recall@5 | MRR@10 | cand | ms |
|---|---|---|---|---|
| keyword only | 0.88 | 0.67 | 0.96 | 3 |
| vector only | 0.88 | 0.76 | 1.00 | 32 |
| intersection (previous design) | 0.92 | 0.79 | 0.96 | 31 |
| fusion | 0.92 | 0.76 | 1.00 | 28 |
| fusion + bounded rerank | 0.96 | 0.79 | 1.00 | 398 |
Split by query shape the last row reads very differently: 1.00 / 1.00 on bare
identifiers, 0.93 / 0.60 on conceptual questions. The reranker pulls
conceptual answers into the top five that retrieval missed, while ordering them
worse than plain retrieval does. Bounding its authority recovered the recall; the
ordering gap is the open problem.
Reproduce with AI_API_KEY=... mix docs.eval --verbose. A change that does not
move these numbers did not work, whatever it looked like in a spot check.
On the latency budget
400ms is not instant, and that is fine — but not for the reason it first appears.
A single assistant turn is seconds of token generation, so 400ms is noise against
it. The mechanism that actually matters is not a gradient of patience but a
hard wall: Anubis' session GenServer.call gives up at 30s and the client has
its own tool timeout. Below the wall, latency costs nothing behavioural; above it
the call dies. So the tail is what needs protecting, not the median — a 400ms
median with a 26s cold-ingest tail is a worse risk profile than a 900ms median
with a 3s tail. That is why INGEST_TIMEOUT_MS sits at 25s with a progress
notice, rather than the pipeline simply being fast on average.
What justifies spending latency is that cost compounds through retries, not through the call. If the tool answers, the agent moves on; if it does not, the agent reformulates — a second tool call plus a whole extra model turn. A 400ms call that answers is much cheaper than a 50ms call that forces a second turn. Against that, the reranker's 390ms buys recall@5 from 0.92 to 0.96: one query in 26 that no longer needs a retry. If a retry costs ~3s of model turn, it pays for itself above roughly a 4% retry-avoidance rate, and 0.04 is what was measured.
The operating rule, then, is close to the opposite of "keep it snappy": spend the median freely up to the wall when it buys correctness, and protect the tail absolutely. Concurrency is not a factor, and not for the reason you might expect: an Anubis session holds one request in flight and queues the rest, so parallel tool calls are serialised before they ever reach the reranker. What that does mean is that a slow request consumes the timeout budget of everything queued behind it — see "One call at a time" below.
Worked examples
Both of these are real questions from a session where the answer was genuinely
unknown, and both are now regression queries in mix docs.eval. You do not call
the tool yourself — you ask a normal question and the assistant decomposes it.
A question with no answer in your training data
can an MCP server ask the client's LLM to run a completion, and does
anubis support it?
The assistant issues one call, using the vocabulary the package's own docs would use rather than the words in the question:
search_docs(package: "anubis_mcp",
query: "server requests a completion from the client model, sampling create message")
It returns Anubis.Server.send_sampling_request/2 (server side),
Anubis.Client.register_sampling_callback/2 (client side), the guide section
that explains sampling alongside roots and elicitation, and the callback's return
shape — with a citable hexdocs_url on each.
A question whose answer shares no words with it
how do I check the client declared a capability before sending it a request?
search_docs(package: "anubis_mcp",
query: "check whether the connected client declared a capability before sending a server request")
Rank 2 is Anubis.Server.send_elicitation_request/3, whose docstring happens to
carry the rule for all three server-initiated request kinds:
The client must advertise the
elicitationcapability or the call returns{:error, :capability_not_supported}after enqueueing.
Nothing in the query lexically matches that function — no shared identifier, no shared phrasing. That is the vector arm doing the one thing BM25 structurally cannot, and it is the case the whole hybrid pipeline exists for.
What it does not tell you
Both answers above are correct. Extrapolating from them was not: the docs say
roots is supported and Claude Code advertises it, so sending a roots/list
request looks safe — and it deadlocks the stdio transport in anubis_mcp 1.14.0,
because ServerRequests.send_to_transport/3 calls back into a transport already
blocked on the session.
search_docs tells you what a library says, reliably and with a citation. It
cannot tell you what it does under your transport, at your concurrency, in
your session. Interactions between processes are in no docstring.
One call at a time
An Anubis session holds one MCP request in flight and queues the rest
(Session.Scheduler.enqueue_or_dispatch/5 dispatches only when in_flight is
nil). Tool calls are therefore serialised, and issuing several in parallel buys
nothing.
It costs something, though: the transport's GenServer.call(session, …, 30_000)
starts its clock when the request is sent, not when it is dispatched, so a slow
request burns the budget of everything queued behind it. A 25s ingest leaves a
queued search roughly 5s before its own transport call times out. This is the
main reason INGEST_TIMEOUT_MS sits below the transport ceiling rather than at
it.
For tools called repeatedly in one turn — remember especially — take an array
and iterate inside a single call rather than relying on parallel invocations that
cannot happen.
Multi-package questions
A search is scoped to one package, so a question spanning several becomes several calls, each written in that package's own vocabulary:
search_docs(package: "anubis_mcp", version: "1.14.0",
query: "StreamableHTTP Plug router forward authorization")
search_docs(package: "boruta", version: "3.0.0-beta.4",
query: "authorize access token bearer Plug protect resource")
Sending the same sentence to both matches neither well. Any package not already indexed is downloaded, chunked and embedded first.
Appendix: example model choices
Any provider exposing OpenAI-compatible /embeddings and /chat/completions
works; set AI_API_URL and the three model names. Prices drift — treat these as
a shape, not a quote.
| Model | Mistral / cost | OpenAI /cost | Gemini /cost |
|---|---|---|---|
| Embeddings | mistral-embed 0.1 /M | txt-embedding-3-small 0.02 /M | text-embedding-004 0.025 /M |
| Fast extraction | mistral-small-4 0.15/0.60 | GPT-5-nano 0.05,0.40 /M | gemini-2.5-flash-lite 0.10,0.40 /M |
| Classification | mistral-large-3 0.50/1.50 | GPT-5-mini 0.25,2.00 /M | gemini-3.1-flash-lite 0.25,1.50 /M |
Installing Local Elixir
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/ndrean/local-elixir-mcpFAQ
Is Local Elixir MCP free?
Yes, Local Elixir MCP is free — one-click install via Unyly at no cost.
Does Local Elixir need an API key?
No, Local Elixir runs without API keys or environment variables.
Is Local Elixir hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Local Elixir in Claude Desktop, Claude Code or Cursor?
Open Local Elixir on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectCompare Local Elixir with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
