docs-cache-mcp
This MCP server locally fetches, caches, and serves official library docs for coding agents, but the provided schema exposes only three tools.
list_libraries()— list configured libraries and their cache status.get_docs(library, topic?, maxTokens?)— fetch-or-cache a library's docs; with a topic, return BM25-ranked relevant sections; without, return the document head and section list.refresh(library?)— force-refetch one library or all libraries, bypassing the cache TTL.The README documents additional capabilities (
search,resolve_library,doctor,warm_project, andget_docssnippets mode) that are absent from the schema.
Fetches and caches official documentation for Fastify, providing relevant sections to coding agents via llms.txt-first retrieval.
Fetches and caches official documentation for Hono, providing relevant sections to coding agents via llms.txt-first retrieval.
Fetches and caches official documentation for Prisma, providing relevant sections to coding agents via llms.txt-first retrieval.
Fetches and caches official documentation for React, providing relevant sections to coding agents via llms.txt-first retrieval.
Fetches and caches official documentation for TimescaleDB, providing relevant sections to coding agents via llms.txt-first retrieval.
Fetches and caches official documentation for fastify-type-provider-zod (Zod integration), providing relevant sections to coding agents via llms.txt-first retrieval.
Click on "Deploy 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., "@docs-cache-mcpget docs for Fastify request validation"
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.
VibeCTX
A local MCP server that fetches official library documentation (llms.txt-first), caches it to disk, and serves the relevant sections to your coding agents — offline, deterministic, zero recurring cost.
Installed from source — clone this repository and build it. See Install.
Do not install the npm package. npm is no longer the distribution channel, and the copy still sitting there —
@blackraptorai/vibectx0.1.2, published July 2026 — is both stale and unsafe: it predates the 0.1.3 hotfix that closed a redirect escape in the fetcher, a quadratic link regex, and unbounded response bodies. 0.1.3 was never published, the account is no longer maintained, and that version will not be superseded or withdrawn. Everything since 0.1.2 exists only in this repository. (Older still:@blackraptorai/docs-cache-mcp≤ 0.1.1, superseded by the rename — same advice.)
By BlackRaptor AI · MIT
Why
Coding agents need current, correct docs in context. Cloud docs services work, but you
trade away control, offline use, and repeatability. This server keeps the whole loop
local: fetch once from the official source (preferring each project's published
llms.txt / llms-full.txt), cache to disk with a TTL, serve
sections matched to the agent's question. When the network is down you get the cached
copy, clearly flagged as stale, instead of a failure.
Related MCP server: docs-mcp
Install
VibeCTX runs from a local clone. You need git and Node — package.json declares
Node ≥ 18, and CI builds and tests on Node 22, which is the version this is actually
proven on. Running the test suite needs Node ≥ 20.19 regardless (vitest's vite dependency
declares ^20.19.0 || >=22.12.0); building and running the server does not.
git clone https://github.com/BlackRaptorAI/VibeCTX.git && cd VibeCTX && npm ci && npm run buildThe same thing, one step at a time:
git clone https://github.com/BlackRaptorAI/VibeCTX.git
cd VibeCTX
npm ci # install dependencies
npm run build # compile TypeScript to dist/That is the whole install. dist/index.js is now the server, and launching it contacts no
package registry — which is the point. An npx-style install has to reach the network on
every start to resolve what it runs; a docs cache whose own launch depends on the network
would defeat itself.
To use the vibectx CLI (every command example below assumes it is on your PATH):
npm linknpm link writes into npm's global prefix. On a stock Node install that is root-owned, so
this either needs sudo or — better — a user-owned prefix first:
npm config set prefix ~/.npm-global and put ~/.npm-global/bin on your PATH. If you would
rather not link at all, every vibectx … example below also works as
node /absolute/path/to/VibeCTX/dist/index.js ….
Nothing is cached yet. A fresh install has an empty cache, so vibectx doctor and
vibectx search will both exit 1 with empty results until documents are fetched — that is
correct behaviour, not a broken install. Run vibectx warm in a project to cache its
dependencies' docs, or call get_docs for one library. See
Warm your project's docs.
Update with git pull && npm ci && npm run build.
On pinning: this repository carries no release tags yet, so
mainis currently the only thing to track. Once av*tag exists, checking it out (git checkout v0.1.3) is how you pin a version — with source distribution the tag is the release artifact.
Quickstart
Point your MCP client at the built server using the absolute path to your clone:
# Claude Code
claude mcp add vibectx -- node /absolute/path/to/VibeCTX/dist/index.js
# or any MCP client (stdio):
node /absolute/path/to/VibeCTX/dist/index.jsTools
Tool | What it does |
| Registry + per-library cache status |
| Fetch-or-cache, then return the sections best matching |
| Search every cached library at once and get the best sections grouped by library — for when you don't know which library owns a concept. Cache-only and offline; see Don't know which library? |
| Force refetch past the TTL (all libraries when omitted; a resolved entry is re-resolved). A successful refresh also drops that library's other cached pages — the ones followed from links in the document being replaced — so a later |
| Turn any npm / PyPI package name into a docs source and report how — see Any library, no config |
| Prove retrieval works per library — same report as |
| Read the project's dependency manifests and cache every dependency's docs — same table as |
library is a name from list_libraries, one of its aliases (next, tailwind, remix, …),
or any npm / PyPI package name — an unknown name is resolved on the spot.
How ranking works
No embeddings, no network at query time, same answer every run.
Tokenizer. Text is lowercased and split on non-alphanumerics, and identifiers are
split at camelCase and PascalCase boundaries — useEffect becomes use + effect,
HTTPServer becomes http + server — while the whole compound (useeffect) is kept
too, so a literal useEffect still scores. A light suffix stemmer folds policies onto
policy, hooks onto hook, and — after the plural and ing/ed rules — repairs the
spelling the suffix changed, in both directions: it drops a silent e so parse /
parsing / parsed / parses all land on pars, undoubles a consonant so running
lands on run, and puts a silent e back so type / typed / typing all land on
type rather than on the bare typ. Three exceptions are deliberate and documented in
src/tokenize.ts: using is a stopword and keeps its own form; there is no agent-noun
rule, so handler and router stay distinct from handle and route; and embed does
not meet embedded, because no suffix rule can tell a real -ed from a word that merely
ends in one. A small stopword list drops "how do I use the …"
scaffolding, unless the query is nothing but stopwords. The result: asking for "use
effect cleanup" finds useEffect, and asking for useEffect finds "use effect".
BM25. Sections are scored with Okapi BM25 (k1 = 1.2, b = 0.75) over the sections of
that call — the primary document plus any followed index pages. Because BM25 weighs a
term by how rare it is, a section containing upsert beats a long section that merely
repeats query, and length normalization stops a big section winning on bulk. A term in
the section's own heading counts three times; one in an ancestor heading or in the body
counts once. Sections that score zero are dropped; ties keep document order.
Heading path. Sections know where they sit in the heading tree, so a returned H4 is
rendered as ## Auth > Row Level Security > Policies rather than a context-free
## Policies. A # line inside a fenced code block is code, not a heading — with one
known limitation: a fence indented four or more spaces, or with a tab, is an indented code
block under CommonMark and is not recognised as a fence, so its contents are not protected
as code. In practice the block's own lines are indented with it and an ATX heading must
start at column 0, so they do not become headings; a line inside such a block that is
not indented with it — a # at column 0 — is outside that protection and does start a
new section. The primary document and each followed index
page are split into sections separately, so an unclosed fence in one page cannot
swallow another, and a followed page's headings read under that page's own title. The
heading path, a snippet's language and its context line are stripped of control, bidi and
zero-width characters before they are rendered; section bodies are not, because the body
is the document.
The budget is priced on what you actually get back, the same discipline search uses
(details below): the rendered response — the Source:
line, any note about followed or skipped index links, and the sections or snippets
themselves, separators included — stays inside maxTokens × 4 characters across all three
response shapes (a topic's sections, mode: "snippets", and the table-of-contents-plus-head
response when no topic is given). The note block about followed and skipped links is capped,
not exempt, so it cannot by itself crowd out the answer it is reporting on — though at a
maxTokens too small to hold even the Source: line and a minimal note, the answer is what
gives way, the same "the cap always wins" rule that already applies to a single oversized
snippet. The table of contents on the no-topic path gets the same discipline: it is capped as
a share of the budget too, so a document with many headings cannot make the table of contents
itself crowd out the document head. The one exception is the short "no sections/snippets
matched" diagnostic: it is deliberately NOT bounded by maxTokens, so its advice ("try
broader terms") survives even a very small budget in full. The topic it echoes back is
length-clipped, and so is the note block folded into that same message; the library name and
source URL it names are not, so a very long configured URL is the one thing that can still make
this message large.
Measured comparison against the previous ranker: on the GitHub-README corpus the
build sandbox can reach, BM25 and the previous ranker tie at 18 of 60 probe
questions answered with the right section in the top result (+0/−0; "no sections
matched" falls from 12 to 7). The docs-site llms-full.txt primaries these
libraries publish — the documents the ranker is meant for — are unreachable from
that sandbox, so this is not yet a measurement of the ranking on real
documentation. Full numbers and method:
docs/eval/2026-09-06-par-658.md.
Code-first answers: mode: "snippets"
When the question is really "show me the call", pass mode: "snippets" and get the
fenced code blocks instead of the prose around them. Each snippet carries its heading
path, one line of context from the doc, and the fence's language:
// tool call
{ "library": "acme-pay", "topic": "checkout session create", "mode": "snippets" }Source: https://docs.acme.example.com/llms-full.txt
### Acme Pay > Checkout > Create a Checkout Session
Create the session on your server, then redirect the customer:
```js
const session = await acme.checkout.sessions.create({
line_items: [{ price: 'price_123', quantity: 1 }],
mode: 'payment',
success_url: 'https://example.com/thanks',
});
```Where that response came from. acme-pay is a made-up library on a
reserved documentation domain, and the
block above is the real, unedited output of this code against a fixture document — pinned
by test/get-docs.test.ts ("produces the README's snippets example verbatim"), which
fails if the two ever drift. It is the shape of a response, not a capture from any
vendor's documentation site; the exact headings depend on what the library publishes.
A snippet is ranked by its section's BM25 score plus a BM25 over the code itself, so the
block that actually contains the call you asked for wins. Blocks under two lines are
skipped unless the query names them exactly. The code is fenced with a backtick run
longer than any run inside it, so a code sample that itself contains a fence cannot break
out of its block, and the whole thing is clipped to maxTokens. mode needs a topic; the default is
"sections", and anything other than those two values is a schema error. When nothing
matches you get, in full:
No code snippets matched "<topic>" in <library> docs (source: <url>). Try mode "sections" or broader terms.Don't know which library? search
get_docs needs a library name. Half the time you don't have one: "how do I stream a
response to the client" could be Next.js, the AI SDK or Hono, and guessing wrong costs a
round trip. search runs one query across every document already in your cache and
groups the hits by library, so the answer to "which library documents this?" comes back
with the section that proves it.
vibectx search "server-sent events streaming"
vibectx search "server-sent events" --library hono --library ai-sdk
vibectx search "revalidate" --max-tokens 1500 --json# acme-pay
Source: https://docs.acme-pay.example.com/llms-full.txt
## Acme Pay > Webhooks > Listening for events
Open a server-sent events stream to receive payment events as they happen:
```js
const events = acme.events.stream({ types: ['payment.succeeded'] });
```
# acme-edge
Source: https://docs.acme-edge.example.com/llms-full.txt
## Acme Edge > Streaming responses
Return a `ReadableStream` from a handler and Acme Edge flushes each chunk as it is produced.
Searched 2 of 2 configured libraries; 2 matched.Where that response came from. acme-pay and acme-edge are made-up libraries on a
reserved documentation domain, and the block
above is the real, unedited output of this code against a fixture — pinned by
test/search.test.ts ("produces the README's search example verbatim"), which fails if the
two ever drift. It is the shape of a response, not a capture from any vendor's site.
Cache-only, by design. search never fetches, never resolves a new package name and
never touches the network — so it is deterministic and works on a plane. (How fast is
measured, not asserted: see the search index.) The flip side is that it
searches exactly what is already cached, which is why every response ends with how many
libraries it looked at, out of how many are configured, and how to cache the rest:
Searched 5 of 30 configured libraries; 4 matched.
Not cached, so not searched: supabase, tailwindcss, shadcn, stripe, expo, drizzle-orm, prisma, trpc and 17 more. Run `vibectx warm` in your project to cache your dependencies' docs, or call get_docs for one library.So the pairing is: vibectx warm once, then search freely.
How results are put together. Same tokenizer, same BM25 and the same field weighting as
get_docs (How ranking works) — but the corpus is every section of
every cached document at once, so a rare term picks the right library out of thirty instead
of the wordiest one. Libraries are ordered by their best section's score, sections within a
library by score, ties by document order. The maxTokens budget (default 4000) is shared
across libraries and spent round-robin — the best library's best section first, then the
next library's best, and so on — because the question is which library, and one verbose
library filling the whole response would defeat that. At most 8 libraries appear in one
response. Each group carries its Source: line, and a cached copy past its TTL is marked
stale with the vibectx refresh line that would fix it.
The budget is a cap, and the answer outranks it. It is priced on the text you actually
get back: whole rendered sections, their group headers, and the separators between them, with
the closing accounting line reserved out of it. Whenever the budget can hold an answer at all,
the rendered response and the sum of --json section bodies both stay inside maxTokens × 4
characters.
What the budget never buys is silence. At any budget the response carries the
best-scoring library's name, its Source: line, and at least one section of its text; when
the budget cannot hold both that and the accounting, the accounting is what gives way, in
this order — other libraries are dropped, the section body is clipped, the closing accounting
shrinks to one line (Searched 3/8 libraries; 3 matched, 1 shown. (Accounting shortened to fit the budget.)), and then it goes altogether. A section excerpt is never clipped below 120
characters, so a budget too small even for the smallest possible answer — one library name,
one Source: line, one 120-character excerpt — gets that answer anyway, over budget, with
one line saying by how much. That is the only case in which a response exceeds
maxTokens × 4, and it always announces itself.
--library <name> (repeatable; libraries: [...] over MCP) narrows the search; names and
aliases both work, and an unknown one is reported in the response rather than failing the
search. A filtered search reports both numbers — Searched 1 of 2 requested libraries (30 configured) — so narrowing the search can never make your cache look emptier than it
is. query is capped at 1000 characters — a longer one is clipped rather than refused, and
said so twice: on stderr for the terminal and in notes for --json. maxTokens is capped
at 200,000, on search, on get_docs, and at --max-tokens — unlike query, an over-budget
value is refused, not clipped. Exit codes: 0 something matched, 1 nothing matched, 2
usage or config error.
--json emits { schemaVersion: 1, generatedAt, query, maxTokens, groups, configured, requested, searched, searchedLibraries, matchedLibraries, unknown, uncached, fromIndex, tokenized, indexWritten, notes }. schemaVersion is bumped when a key is renamed, removed
or changes meaning; adding a key is not a bump, and where a key is added is not part of
the contract — read keys by name. The emitted order is stable (and pinned by a test) because
a diffable file is worth having, not because a reader may depend on it.
The search index
To avoid re-reading and re-tokenizing every llms-full.txt on every query, search keeps a
small inverted index at <cache>/index.json. Three things are worth knowing about it:
It is a derived cache, never a source of truth. It stores no document text at all — only per-section token counts and, per term, which sections it occurs in. Bodies and heading paths are read out of the cached document at query time. Every entry also carries a content hash, and an entry whose hash does not match the cached document is ignored and rebuilt. So a stale, hand-edited or planted index cannot make
searchreturn a single word the cache does not hold — at worst it costs a slower query.It is keyed to the code that built it. The file also records a retrieval version, and a file written by a build whose tokenizer, stemmer, section splitter or field weighting differed is refused whole and rebuilt. The content hash proves the document is unchanged, which is exactly why a change to that code slips past it: same bytes, different terms, and the answer would go quietly empty instead of visibly wrong.
It maintains itself. Every writer of a library's primary document updates it —
warm, the startup autowarm,get_docs,refresh, andresolve_library(so a newly resolved library is indexed the moment it is cached, not on some later run).refreshinvalidates that library's entry first and a successful refresh rebuilds it. Anything missing is rebuilt inside the nextsearch. Deleting the file is always safe: the next search rebuilds what it needs and answers the same way.What it costs is vocabulary, not bytes — and it does not stay at 44 ms as you scale. Two MEASURED points on the build sandbox, both worth carrying:
corpus
index file
warm
searchone-off index build
5.63 MB, 12 documents
0.55 MB (10%)
44 ms
~300 ms
146 MB, 30 documents
16.9 MB (12%)
820–893 ms
6.3 s
The 300 ms target this feature was built to is the first row — a normal project's stack of
llms.txtfiles. Thirty five-megabytellms-full.txtdocuments is 2.7–3.0× that target, and the cost there is dominated byJSON.parseof a 17 MB index plus a SHA-256 over every scanned document. The first row is printed bytest/search-perf.test.tson every run; the second bynpm run build && node scripts/probe-search-scale.mjs, which generates its own corpus (no network, nothing to download) and prints the row it measured — the figures above are one of its runs, and a re-run on your own machine is the only number worth trusting. A pathological corpus in which every token is globally unique is the other extreme — 146% of the corpus and 100 ms for 1.65 MB. Documents over 8 MiB are not indexed at all; they are tokenized at query time and the response says so for that library. The file itself is never written larger than the 64 MiB a read will accept: past that, the largest entries are left out and the response names them.
Warm your project's docs
One command, and your whole stack's docs are on disk — works offline, never a 429:
cd my-app
vibectx warmvibectx warm · /Users/me/my-app · cache /Users/me/.vibectx
manifests: package.json
dependency library status url
next next.js cached https://raw.githubusercontent.com/vercel/next.js/canary/packages/next/README.md
react react cached https://raw.githubusercontent.com/reactjs/react.dev/main/src/content/reference/react/useEffect.md
@supabase/supabase-js supabase cached https://raw.githubusercontent.com/supabase/supabase-js/master/packages/core/supabase-js/README.md
stripe stripe cached https://raw.githubusercontent.com/stripe/stripe-node/master/README.md
tailwindcss tailwindcss cached https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/README.md
typescript typescript resolved+cached https://raw.githubusercontent.com/microsoft/TypeScript/HEAD/README.md
@types/node — denied (noise list) —
eslint-config-next — denied (noise list) —
6/6 dependencies cached · 2 denied (noise list)(A real run against that package.json, reproduced on 2026-09-06 from a sandbox where
only raw.githubusercontent.com was reachable, so every entry fell back to its README
candidate; with the docs sites reachable you would see llms-full.txt / llms.txt URLs
where projects publish them. Every row and the totals are that run's; only the two paths
in the header line are shown as a typical macOS home rather than the run's temporary
directories.) Afterwards get_docs("stripe", "webhook signature verification") answers
from the cache with the network unplugged.
What it reads (each file once; names de-duplicated per ecosystem):
package.json—dependenciesanddevDependencies(not peer / optional / bundled).npm:alias specs are unwrapped to the real package;file:/link:/workspace:/portal:specs are local packages and skipped.pyproject.toml—[project].dependencies, every[project.optional-dependencies]group, every[dependency-groups]group,[tool.poetry.dependencies],[tool.poetry.dev-dependencies],[tool.poetry.group.<g>.dependencies]; a poetry inline table withpath,gitorurlis a local or VCS source and is skipped (likefile:in package.json). Read by a small built-in TOML reader (table headers,key = value, string arrays, one-line inline tables); dotted keys inside a table and multi-line strings are not supported.requirements*.txt—requirements.txtfirst, then the rest; versions, extras, markers, comments and\continuations stripped;-r/--requirementincludes followed one level, relative to the including file and only inside the project directory;-c,-e, options, URLs and paths skipped. PyPI names are normalised (PEP 503), soTyping_Extensionsandtyping-extensionsare one dependency. Only names that pass the npm / PEP 508 name rules are kept (the rest are counted in a note, never printed); a manifest over 32 MiB is not read. **Symlinks are refused:** every manifest and every-rtarget is checked component by component, and a symlink anywhere in the path — or a real path that resolves outside the project directory — isskipped (symlink)/ skipped as outside; the project directory itself may live behind a symlink. File names and include paths are stripped of control, bidi and zero-width characters before they are printed.Lockfiles, only when the ecosystem's manifest is absent, for names:
package-lock.jsonv2/v3 (the root package's lists; v1 has none and is reported),pnpm-lock.yaml(importers['.'], or the v5 top-level blocks).yarn.lock,uv.lockandpoetry.locklist every package with no cheap root marker and are reported, not read.
What it does per dependency. A registry name or alias (the npm package names of the
scoped entries are aliases: @supabase/supabase-js, @trpc/server, @clerk/nextjs,
@anthropic-ai/sdk, @playwright/test, @sveltejs/kit, @tanstack/react-query,
@tailwindcss/postcss; react-dom reaches react) is warmed through the same fetch
get_docs uses: a fresh cached copy is left alone (already fresh), a stale one is
revalidated with If-None-Match, a missing one is fetched (cached). Registry entries match
by name regardless of ecosystem: the shipped defaults are the npm packages, so a Python
project that depends on stripe or openai gets the npm entry's docs, and the row says so
— curated entry is the npm package (for an auto-resolved record, resolved entry is the <npm|pypi> package, with the --npm / --pypi switch to re-resolve). An ecosystem field
on entries is a queued follow-up. Anything else is resolved from the ecosystem the manifest
implies (package.json → npm only, Python files → PyPI only), exactly as
resolve_library would, and reported resolved+cached; these
resolutions share the process's 100-resolutions-per-hour cap with get_docs and
resolve_library — a large project can spend the hour's budget in one run. Names on the
noise list are denied (noise list) and never fetched. Up to four names are warmed at once;
names that map to one entry share one fetch.
Recent failures are not retried every run. A name the previous run left unresolved is
reported unresolved (recent) for 24 hours from that failure — no resolution slot spent,
no network — with the original reason and time in the detail line. vibectx warm --force
retries now; after 24 hours it retries by itself. --force is a CLI flag only — the
warm_project tool takes dir and nothing else, so spending the resolution budget on names
the last run already proved unresolvable stays a person's decision. The memo never applies
to a name the registry has since learned (pinned in config, or resolved another way).
Statuses: cached · already fresh · resolved+cached · unresolved (the resolver's
attempt summary is printed below the table) · unresolved (recent) (see above) ·
denied (noise list) · skipped (rate cap) (the 100-resolutions-per-hour cap was reached
mid-run; the run continues; run warm again later) · unreachable (nothing fetched — a
stale copy, if any, is kept and said so).
Exit code 0 when every attempted dependency is cached, already fresh or
resolved+cached (denied names do not count); 1 when any is unresolved,
unresolved (recent), unreachable or skipped (rate cap) — the promise is "your stack's
docs are on disk", and they are not yet; 2 for a usage error, an unreadable config, a
directory that is not a directory, or a directory with no manifest to read. vibectx warm [dir] takes any directory (default: the current one); the warm_project tool accepts only
the server's working directory or a directory beneath it — compared on real paths, so a
symlink inside the working directory that points elsewhere, or a sibling that merely shares
the prefix (/a/proj-evil against /a/proj), is refused — and answers "outside the project
directory" for anything else. --offline prints a cache-only report (fresh / stale / missing
per name, unknown names unresolved) without touching the network or writing anything;
--force retries recent failures; --json emits { schemaVersion: 1, generatedAt, dir, offline, manifests, notes, dependencies: [{ name, ecosystem, source, library?, status, url?, note?, failedAt? }], cached, attempted, denied, total } — keys in that order (each row's
keys in that order too); new keys may be appended; read keys by name. schemaVersion is
bumped when a key is renamed, removed or changes meaning and when a status value is added
or removed — readers drop rows whose status they do not know. A discovered config file the
loader skipped adds one notes entry, config: <path> (<scope>) not loaded: <reason>, to
both the table and --json, so a run that fell back to the shipped defaults never reads as a
clean one. --config <path> loads your config first, so pinned entries win.
Noise list. DEPENDENCY_DENYLIST in src/project-deps.ts (a trailing * is a prefix
rule): npm @types/*, eslint*, @eslint/*, prettier*, @typescript-eslint/*,
tslib, @babel/*, postcss, autoprefixer, husky, lint-staged; PyPI setuptools*,
wheel, pip, build, twine, black. Kept on purpose: typescript, pytest*, ruff,
mypy. Everything not listed is attempted.
What warm does not do. It caches each dependency's primary document only; index links
are followed by get_docs on demand, per topic, not during warm. It does not make a source
good: a project without llms.txt gets its README, as with resolution — vibectx doctor
tells you which you got. It does not re-resolve entries already resolved (that is
refresh). It does not read peer dependencies, transitive dependencies, or lockfiles when a
manifest exists.
Project record. Each run (not --offline) writes projects/<hash of the absolute directory>.json in the cache directory — { schemaVersion: 1, dir, manifests, dependencies, warmedAt }, atomically and validated on read. A file with an older
schemaVersion is replaced; one with a newer schemaVersion (written by a newer
vibectx) is left alone with a note on stderr and a project record not written: newer schema on disk note in the report (--json included) — the same rule resolved.json follows. The
record is best effort: if it cannot be written (an unwritable cache directory, a full
disk) the run still prints its table and still exits on the dependencies alone, with one
stderr line and a project record not written: <reason> note. It feeds one decision, the
24-hour unresolved (recent) memo above; otherwise it is informational.
Validation on read is per field, because anything with write access to the cache directory
can edit the file: a url that is not an https URL is dropped from its row; a source that
is not a plain relative manifest path (package.json, sub/requirements.txt — never
absolute, never containing ..) drops the whole row, as do a bad name, ecosystem,
status or failedAt; an over-long library is dropped from its row and an over-long
note is truncated. warmedAt and failedAt must be strict ISO-8601 UTC instants of the
form 2026-09-06T06:00:00.000Z — Date.parse on its own accepts a "date" with a trailing
parenthesised comment, and warmedAt is printed verbatim in the list_libraries summary
line, so a bad warmedAt makes the whole record read as absent. That accepted timestamp
shape is part of schemaVersion 1: a reader of this version rejects anything else, so
widening it — accepting a +01:00 offset, say — requires a version bump, exactly as adding
or removing a status value does. Control, bidi and zero-width characters are stripped from
every field.
The list_libraries summary line. When a record exists for the server's working
directory, list_libraries ends with Project deps (<dir>): N cached, M unresolved, K denied — warmed <time>. Two things to know about that line:
unresolvedis a bucket, not a status. It counts every row that is not cached and not denied —unresolved,unresolved (recent),unreachableandskipped (rate cap)together. So a run that hit the resolution cap, and one whose network was down, both read as "unresolved" here. Runvibectx warmfor the per-name breakdown; the summary is deliberately one line. (Splitting the bucket is a queued follow-up.)It emits the absolute project directory — the value of
dir— to whatever model is readinglist_libraries. That is the path the server was started in; it is not secret, but it is not nothing either.
A moved project gets a new record. The file name is a hash of the absolute directory, so
renaming or moving a project writes a fresh record at the new path and leaves the old one in
place. Nothing prunes them today (a queued follow-up); they are inert, a few KB each, and
rm -rf on the cache directory clears them.
Background revalidation on startup. When the MCP server starts (never under doctor,
resolve or warm), any configured library — default or config, not auto-resolved
records — that is uncached or past its TTL is fetched in the background after the transport
is connected: two at a time, If-None-Match first, so a warm cache costs one conditional
request per stale entry and nothing for fresh ones, while a first start with an empty cache
fetches the 30 defaults the way refresh would. It never delays the handshake or a tool
call; list_libraries shows warming… on entries in flight; the outcome is one line on
stderr (vibectx: autowarm cached N/M configured libraries), and every error stays there —
the server does not depend on it. When the client closes the connection, nothing further is
scheduled and the process ends (a fetch already in flight is given 100 ms). Opt out with
VIBECTX_NO_AUTOWARM=1 in the server's environment (see Configuration).
Any library, no config
Ask get_docs for a name the registry does not know and it resolves the package itself —
no curation, no config. resolve_library does the same step explicitly and shows its work;
vibectx resolve <name> prints the identical report from the command line.
$ vibectx resolve fastapi
Resolved "fastapi" via PyPI — https://pypi.org/pypi/fastapi/json
description: (package-supplied) FastAPI framework, high performance, easy to learn, fast to code, ready for production
homepage: —
docs: https://fastapi.tiangolo.com/
repository: https://github.com/fastapi/fastapi
candidates (probed in order; first usable document wins):
1. https://fastapi.tiangolo.com/llms-full.txt — no document
2. https://fastapi.tiangolo.com/llms.txt — no document
3. https://raw.githubusercontent.com/fastapi/fastapi/HEAD/README.md — chosen
4. https://raw.githubusercontent.com/fastapi/fastapi/HEAD/readme.md — not tried
…
chosen: https://raw.githubusercontent.com/fastapi/fastapi/HEAD/README.md (readme, 22,568 chars)
followed-link hosts: fastapi.tiangolo.com (plus the source document's own host; https only)
saved to ~/.vibectx/resolved.json — get_docs("fastapi") works now; pin or override it in vibectx.config.json.(A real run, re-verified on 2026-09-06 line by line, including the 22,568-char figure.
Two things are presentation, not output: candidates 5 and 6 are elided at the …, and the
last line shows the default cache directory in place of the VIBECTX_CACHE_DIR the run used.
The first two candidates report no document because that sandbox cannot reach
fastapi.tiangolo.com — from a machine that can, llms.txt may well win instead.)
What resolution does, in order, stopping at the first usable document:
Registry hit — a canonical name or alias is served as before; nothing is resolved.
Package metadata — npm (
registry.npmjs.org/<name>/latest), then PyPI (pypi.org/pypi/<name>/json). The name must look like a package name (npm rules or PEP 503) or nothing is fetched. When both registries know the name, the one whose metadata carries a homepage or docs URL wins; a hit that offers only a repository README yields to the other ecosystem if that one has a docs site (sohttpx,fastapiandrequestsresolve to the Python projects even though same-named npm packages exist); ties go to npm. npm'ssecurity-holderplaceholder counts as no package. Passecosystem: "npm" | "pypi"(CLI--npm/--pypi) to decide yourself.llms.txt probing —
llms-full.txtthenllms.txt, under the docs URL's path and its origin, then under the homepage's; at most 8 URLs. HTML served with a 200 does not count as a document.GitHub README — for a
github.comrepository only, viaraw.githubusercontent.com/<owner>/<repo>/HEAD/<README.md | readme.md | Readme.md | README.rst>(HEADis the default branch, whatever it is called).Otherwise one plain line: what was tried, and the config snippet to pin the library.
Fetch bound. A resolution makes at most 26 requests: 2 metadata documents, then the preferred ecosystem's candidates (8 llms.txt probes + 4 README variants), then — only if none of those served — the other ecosystem's candidates; it stops as soon as one document is usable (in practice the worst case is 18, since an ecosystem held back as README-only has no llms.txt probes). Metadata responses over 8 MiB are treated as absent; documents keep the normal 25 MiB cap. A process starts at most 100 resolutions per hour; beyond that, unknown names get a "resolution limit reached" line until the window slides (pin the library in config if you hit it).
Where it persists. Successful resolutions are written to resolved.json in the
cache directory (~/.vibectx/, or VIBECTX_CACHE_DIR) via a temp file and rename —
an internal file of shape { "schemaVersion": 1, "entries": [{ name, urls, description?, resolved: { source, resolvedAt, metadataUrl, homepage?, docsUrl? } }] }. On startup they
are merged below the defaults and your config: a real registry or config entry always
wins, and a persisted resolution never overrides one — not even a record whose name is a
different-case spelling of a curated one (record names must already be lowercase; PyPI
names are stored in their PEP 503 form, so typing_extensions and Typing-Extensions
are one record). Records are re-validated on every load (bad ones are skipped; a corrupt
file is ignored; a file written by a newer vibectx — a higher schemaVersion — is left
alone and new resolutions stay in memory, with a note on stderr; a lower one is replaced). The
resolved.json record stays in memory the same way, with the same kind of note, when the
write itself fails — a read-only $HOME or a full disk — rather than being refused by schema
version. list_libraries marks them [resolved] and prefixes their descriptions
(package-supplied); doctor checks them like any entry; refresh re-resolves them through
the same ecosystem, so a project that later publishes llms.txt is picked up.
When get_docs resolves a name on the spot, its response starts with one provenance line
— ecosystem, the package's own description, homepage / repository, the nearest
curated name when the request looks like a typo of one, and (when the write above
failed) resolution not saved: <reason> — ending in "not a curated
entry; verify this is the package you meant". A typo can resolve to a real, unrelated
package; that line is how you notice. A failed save of the record never costs you the
answer: the document itself was already fetched and cached before the record write is
attempted, so it is still returned, and the resolution still works for the rest of this
process. (A cache directory that is read-only from before this library was ever cached is a
different case — there the document cannot be cached either, and the name reports as
unresolvable rather than resolved-but-unsaved.)
Command line. vibectx resolve <name> [--npm | --pypi] [--config <path>] exits 0
when resolved (or already curated), 1 when it could not resolve, 2 on a usage or
config error. The report text and resolved.json are not a stable machine contract
(there is no --json); read them, do not parse them.
Pin or override. To fix a resolution you dislike, add the name to vibectx.config.json
with your own urls — config beats resolution, and the entry stops being [resolved].
To resolve a name into the other ecosystem, run vibectx resolve <name> --pypi (or
--npm); the later explicit resolution replaces the earlier one.
allowedHosts and followed links. Followed index links are https-only and confined
to the source document's own host plus the entry's allowedHosts. Redirects are
followed one hop at a time (at most 5): every Location is checked against the same rule
before it is requested, so a page that redirects to a private address never produces a
request. The same hop rule — https, public host — applies to every fetch vibectx makes,
curated primaries included (those may still redirect across hosts), with one narrow,
explicit exception: a config entry's own urls, when that entry sets allowInternalHosts: true — see below. For a resolved entry that set is derived from its metadata — the
homepage host, the docs-URL host and docs.<registrable domain of the homepage> — and is
recomputed on every load, never read from resolved.json. In config, allowedHosts is an
array of bare hostnames ("api.acme.com"), lowercase, or "*.acme.com" for subdomains
(never the apex); no scheme, path, port or userinfo. IP literals, localhost, .local,
.internal and single-label names are rejected on the way in and refused on the way out,
whatever any list says. Redirects are re-checked against the same rule. The
registrable-domain helper is deliberately small (last two labels, or three under a short
list of two-part suffixes
such as co.uk, com.au, github.io) — no Public Suffix List — so an unlisted
two-part suffix derives a docs. host that simply does not exist; harmless, but not
useful.
Honest limit. Resolution finds a source; it does not make the source good. A
project that publishes llms.txt gives full-text answers; most today do not, so the
README on GitHub is what you get — fine for "how do I install / basic usage", thin for
deep API questions. vibectx doctor --library <name> tells you which you got. A project
with no GitHub repository and no llms.txt cannot be resolved; pin it in config.
Security note. Resolution turns package-registry metadata — which anyone can publish —
into fetches. Every URL is checked by name (https only; no IP literals, localhost,
.local, .internal, single-label or trailing-dot hosts; GitHub repositories only via
raw.githubusercontent.com; redirects checked hop by hop), and per-name and per-hour
bounds cap the volume (up to 26 requests per unknown name, 100 resolutions per hour per
process, so N unknown names can mean up to 26·N requests to the registries, docs hosts
and GitHub). Name-based checks cannot see through DNS: a hostname such as
127.0.0.1.nip.io resolves to a loopback address and the connection will be attempted;
TLS certificate-name verification then prevents a body from being read from a host that
cannot present a certificate for that name. If your environment has internal services on
routable names, run vibectx where they are not reachable, or pin libraries in config and
do not rely on resolution.
Configuration
Ships with a default registry of the 30 libraries vibe coders and small startup teams reach for most:
Web frameworks:
next.js,react,react-router(Remix),astro,sveltekit,nuxt,vue,expoBackend / data:
supabase,firebase,convex,prisma,drizzle-orm,trpc,hono,zodUI:
tailwindcss,shadcn,motion(Framer Motion),tanstack-queryAI:
ai-sdk(Vercel AI SDK),openai,anthropic-sdkAuth / payments / email:
clerk,stripe,resendTooling:
bun,vite,vitest,playwright
Naming rule. A library's name is lowercase and is its npm package name — unless that
package is scoped (@supabase/supabase-js), too generic on its own (ai), or not what
people call the product (next); then it is the product's widely used short name
(supabase, ai-sdk, next.js). Where agents commonly send another name, the entry
carries aliases that resolve to the same docs: next / nextjs → next.js,
tailwind → tailwindcss, remix / react-router-dom → react-router,
svelte → sveltekit, framer-motion → motion, react-query → tanstack-query,
anthropic → anthropic-sdk, firebase-js → firebase, drizzle → drizzle-orm,
ai / vercel-ai → ai-sdk, supabase-js → supabase, shadcn-ui / shadcn/ui →
shadcn, react-dom → react, and the npm package name of every scoped entry
(@supabase/supabase-js, @trpc/server / @trpc/client, @clerk/nextjs, @anthropic-ai/sdk,
@playwright/test, @sveltejs/kit, @tanstack/react-query) so what package.json says
is a curated hit. Lookups are case-insensitive (Next.js works). list_libraries shows each
entry's aliases as (aka …); every tool that takes a library accepts an alias.
Add or override libraries with a JSON config:
vibectx --config ./vibectx.config.jsonA vibectx.config.json committed to your repo is picked up with no flag at all — see
Team config, no flags for the full resolution order.
{
"libraries": [
{
"name": "elysia",
"aliases": ["elysiajs"],
"urls": ["https://elysiajs.com/llms-full.txt", "https://elysiajs.com/llms.txt"],
"ttlHours": 168,
"description": "Elysia web framework",
"probeQueries": ["middleware"],
"allowedHosts": ["*.elysiajs.com"]
}
]
}URLs are candidates probed in order — list llms-full.txt first, then llms.txt,
then any curated fallback page (raw GitHub READMEs work well). Cache lives at
~/.vibectx/ (override with VIBECTX_CACHE_DIR). Default TTL is 7 days. A VIBECTX_CACHE_DIR
override must point at a directory vibectx owns: refresh deletes files inside it (its own
stale followed-page cache) as part of normal operation, not only under a byte cap.
Every file in there is written through a temp file and renamed into place, so a reader
never sees a half-written one; the server and vibectx warm sweep any .tmp file a
killed process left behind before they write anything — but only once it is at least a
minute old, so a second vibectx sharing the cache never has its in-flight write deleted.
Upgrading from ~/.docs-cache-mcp. The cache used to live at ~/.docs-cache-mcp and
the override used to be called DOCS_CACHE_DIR. Both still work, and you do not have to do
anything:
VIBECTX_CACHE_DIRwins.DOCS_CACHE_DIRis still read through0.2.x, with one deprecation note on stderr the first time a process uses it.With neither set, the first run renames
~/.docs-cache-mcpto~/.vibectxonce and says so on stderr. A rename, never a copy — so there is never a moment with two divergent caches.If
~/.vibectxalready exists, nothing is migrated and nothing is overwritten; the old directory is left exactly where it is for you to delete.If the rename fails (a different filesystem, permissions), vibectx keeps using
~/.docs-cache-mcpfor that run and says so once. Nothing is copied and no cached document is lost.Going back down to 0.1.x after the rename starts cold. The migration is one-way and 0.1.x knows nothing about
~/.vibectx: it looks for~/.docs-cache-mcp, does not find it, creates it empty and re-fetches everything. Nothing is lost — the documents are still in~/.vibectxand re-appear when you go back up — but you pay a cold cache, and you then have two directories. If you need a downgrade to keep its cache, setDOCS_CACHE_DIR=~/.vibectx(0.1.x reads it) or rename the directory back by hand before running the older version.The
docs-cache-mcpcommand still works — the build still provides it as a second bin name alongsidevibectx, so a shell alias or an.mcp.jsonthat invokes the command keeps running once you have runnpm link. (An.mcp.jsonthat invokes the old npm package —npx -y @blackraptorai/docs-cache-mcp— no longer reaches this code at all: it resolves to the abandoned 0.1.1 on the registry. Point it at your clone instead.) It is deprecated and will be removed in0.3.0, on the same schedule asDOCS_CACHE_DIRand the legacydocs-cache.config.jsonfilename; move tovibectxbefore then. (test/version.test.tsfails the day the version reaches0.3.0with any of the three still shipped, so this is a schedule rather than an intention.)
Size cap. The cache is capped at 512 MB by default — an assumed figure, not a
measured one; VIBECTX_CACHE_MAX_MB changes it and VIBECTX_CACHE_MAX_MB=0 turns it off.
When a write pushes the cache over, the least-recently-fetched library documents are
deleted until it is back under, and one stderr line and a vibectx doctor line say what
went. Recency is the document's fetchedAt, which a 304 revalidation refreshes, so a
document you keep using keeps its place.
Three things the cap deliberately does not do:
It never evicts the document whose own write triggered the sweep — that would make a warm loop fetch and delete the same file for ever — so the cap gives way for that one document instead, and the stderr line says so. The protection lasts one sweep, not the life of the process: a long-running server respects its cap, and every document it has cached takes its turn.
It never evicts
resolved.json,index.jsonor a project record, though it does count their bytes.It does not sweep on every write. It accumulates and sweeps once per 16 MiB written, or once per half the cap when that is smaller, plus once at the first write of a process. So the cache can sit over the cap by up to that amount plus one document between sweeps — a bound tied to the cap you set, not to a constant sized for the default one.
Units: the name says MB, the arithmetic is MiB. VIBECTX_CACHE_MAX_MB=512 is
512 × 1024 × 1024 = 536,870,912 bytes, and the MB/KB/GB in the stderr and doctor
lines are the same binary units. Fractional values work (VIBECTX_CACHE_MAX_MB=0.5 is
512 KiB). A value that is not a number, or is negative, falls back to the 512 default rather
than switching the cap off — a typo must not be a way to lose the bound — and says so once on
stderr, so a mistyped variable does not look like a variable that worked:
vibectx: VIBECTX_CACHE_MAX_MB=2GB is not a size — using the default 512 MB cap. Set a non-negative number of megabytes (0 turns the cap off).When a library will not cache: VIBECTX_DEBUG=1. Every fetch failure looks the same
from the outside — the library is simply not cached — because a 404, a connection timeout, a
name that does not resolve, a redirect the SSRF guard refused and a document over the byte
cap all mean "no document at this URL". Set VIBECTX_DEBUG=1 and each one writes a line to
stderr saying which it was:
vibectx [debug] fetch.miss url=https://example.com/llms.txt reason=http-status status=404 ms=50
vibectx [debug] fetch.miss url=https://example.com/llms.txt reason=timeout error="The operation was aborted due to timeout" ms=0
vibectx [debug] fetch.miss url=https://nope.invalid/llms.txt reason=dns code=ENOTFOUND error="fetch failed" ms=0
vibectx [debug] fetch.refused url=https://example.com/llms.txt reason=redirect-host to=http://169.254.169.254/latest/meta-data/ status=302 ms=1
vibectx [debug] fetch.refused url=https://elsewhere.example/page.md reason=link-policy to=https://elsewhere.example/page.md status=200 ms=1
vibectx [debug] fetch.too-large url=https://example.com/llms.txt reason=content-length bytes=31457280 limit=26214400 ms=0
vibectx [debug] fetch.too-large url=https://example.com/guide.md reason=body-cap limit=2097152 ms=57(A real capture, 2026-09-07, from a built dist/ with each failure injected in place of the
network — hence the ms figures, which are the stub's latency, not a real host's.)
There are three event names, one per outcome, and reason names the cause within it.
Every failure path in fetchUrl emits exactly one line:
fetch.miss—http-status·html-not-text(a site serving its 404 page with a 200) ·empty-body·redirect-no-location·redirect-hops(more than 5) ·redirect-unparsable· and, for a fetch that threw,timeout,dns,connection-refused,connection-reset,tls,networkoraborted.fetch.refused— the guard said no and no body was read:not-public(the URL is not https on a public host),redirect-host,final-host,link-policy(a followed link left its source origin).to=names the URL that was refused, which is a URL vibectx did not fetch.fetch.too-large—content-length(declared over the cap, refused before the body is read;bytes=is what the server declared) orbody-cap(the cap was hit mid-stream, so the true size is unknown and nobytes=is printed).limit=is the cap that applied.
aborted is in the vocabulary but no code path produces it today: the only abort signal
vibectx uses is the 20-second fetch timeout, which arrives as timeout. It is kept so that a
future caller-side cancellation is not silently reported as network.
These lines are for a human, not for a parser. The shape is stable enough to read and to
grep, and that is the whole guarantee: event names, fields, field order and the reason
vocabulary can change in any release without a version bump. The versioned, machine-readable
surfaces are --json on the CLI subcommands and the MCP tool payloads. Nothing else changes:
the fetch behaves identically with the variable set or unset, and stdout — which carries the
MCP protocol — is never written to.
VIBECTX_NO_AUTOWARM=1 in the server's environment turns off the
background revalidation on startup.
allowedHosts (optional) lists extra hosts followed index links may target — see
allowedHosts and followed links.
allowInternalHosts (optional boolean, default false) lets that entry's own urls
name a loopback, link-local, .local/.internal or single-label host — the air-gapped or
internal-docs case, an explicit choice the entry's author writes down, never a default.
Without it, urls naming such a host is rejected the same way allowedHosts is: one line
naming the file, the entry and the value, and that config layer is skipped rather than
silently truncated. The opt-in reaches only the entry's own primary fetch — an internal
document's own index links are still refused (allowedHosts above is unaffected by it),
and https-only / no-userinfo still apply.
probeQueries (optional, array of non-empty strings) are the topics vibectx doctor
uses to prove the entry answers; without them a query is derived from the description.
An empty array [] is accepted and behaves exactly as if probeQueries were absent.
aliases (optional, array of non-empty strings; [] = none) are other names that resolve
to the entry. ttlHours (optional) is the cache lifetime in hours; 0 means "always
revalidate". libraries itself is optional — a file without it loads as no entries.
Unknown top-level keys in the config (for example $comment) are ignored.
Names and aliases are trimmed and lower-cased before anything else, so "name": "Next.js"
overrides next.js. Precedence:
Config beats default alias. A config entry whose
nameor alias equals a default's alias wins; that alias is silently dropped from the default ({"name": "next"}loads andnextis yours, whilenext.jsandnextjsstill reach the default).Alias vs. canonical name is an error. A config alias equal to any library's
name(default or config), the same alias on two config entries, or an alias equal to its own entry's name fails to load, with a message naming both sides.An override keeps the default's aliases unless you say otherwise. Overriding a default (same
name) and omittingaliasesinherits them;"aliases": []clears them; an explicit list replaces them.A pin also claims its PEP 503 spelling. A config (or default) name or alias owns the form with runs of
-,_,.collapsed to-as well, so{"name": "typing_extensions"}answerstyping-extensionsandTyping.Extensions, and no auto-resolved record can sit beside it under that spelling.
Keep a private stack via committed config. The default registry is what most teams
share; what only your team uses belongs in a vibectx.config.json committed to your
repo, so every teammate's agent gets byte-identical context. Config entries merge over
the defaults. docs/examples/paragon.vibectx.config.json
is a complete example — the Fastify / TimescaleDB / pgvector / AWS CDK stack that shipped
as the default registry through 0.1.3:
vibectx --config ./docs/examples/paragon.vibectx.config.json doctorTeam config, no flags
An MCP client launches the server with a fixed command line, so a config that needs
--config never reaches it. Commit the file instead and vibectx finds it: put
{
"libraries": [
{ "name": "acme-platform", "urls": ["https://docs.acme.example.com/llms-full.txt"] }
]
}in vibectx.config.json at the root of your repo, and every teammate's agent — started
with plain vibectx, no flags — gets acme-platform in
list_libraries, in get_docs, and in the startup warm.
# | Source | Where |
1 |
| the launch command |
2 |
| the server's environment |
3 | project file |
|
4 | user file |
|
5 | shipped defaults | the vibe-coder 30 above |
An explicit source is authoritative. Pass --config (or set VIBECTX_CONFIG) and
discovery is skipped entirely — the flag alone decides, exactly as in 0.1.x. The flag beats
the environment variable. Otherwise the user file layers over the defaults and the project
file layers over that: project beats user beats default, by library name, with the same
alias rules as above applied at each layer.
The walk-up stops at your repository. vibectx checks the working directory, then each
parent, and stops after the directory holding .git — a config in an unrelated parent such
as /tmp or your home directory is never picked up, and with no .git anywhere above,
only the working directory is checked. The nearest file wins; a second one further up is
not layered under it. A symlinked config is fine as long as it resolves to a regular file.
list_libraries opens with the sources it actually loaded, highest precedence first, so
there is never a question of which file an agent is answering from:
config: ./vibectx.config.json (project) · ~/.config/vibectx/config.json (user)
Cache dir: /Users/you/.vibectxor config: --config ./x.json, or config: none (shipped defaults).
Legacy filename. docs-cache.config.json is still read at both locations through
0.2.x, with a deprecation note in list_libraries and once on stderr — rename it to
vibectx.config.json. If both names sit in one directory the new name wins and the old
one is ignored (also noted).
Failures are one line, in one grammar: the file, then
libraries[i].<field> ("<name>"), then what is wrong — never a stack trace, a validator
dump, or anything from inside the file (a config path can name any file on disk, so a
syntax error reports the position and nothing else — invalid JSON at line L column C
when the parser supplies a position, and a bare invalid JSON when it does not):
./vibectx.config.json: libraries[2].urls ("acme-platform"): must be a non-empty array of https URLs
./vibectx.config.json: invalid JSON at line 7 column 3
~/.config/vibectx/config.json: libraries[0].allowedHosts ("acme-platform"): "10.0.0.1" is a private, loopback or non-routable hosturls must be https: (the fetcher refuses anything else, so a non-https entry could only
ever be dead weight); unknown keys — top-level and inside an entry — are ignored, so a file
written for a later version still loads; a file over 1 MiB is refused.
A broken discovered file is skipped, not fatal. If the committed
vibectx.config.json (or the user file) fails to load, vibectx keeps going with the
remaining layers: one line on stderr —
vibectx: ./vibectx.config.json: libraries[0].urls ("acme"): must be a non-empty array of https URLs — file skipped, continuing without it— the same fact on the list_libraries header
(config: ./vibectx.config.json (project) — NOT LOADED: …), and vibectx doctor counts it
as unhealthy: a ✗ config … line, a configIssues entry in --json, and exit 1. An
explicit source is different: a --config or VIBECTX_CONFIG file that cannot be
loaded is still a hard failure (exit 2), because you asked for that file by name.
One bad file in one repository should not take down a server every teammate launches;
a flag that cannot be honoured should never be silently ignored.
Only directories you own are searched. The walk-up stops at the first directory whose
owner is not you, and reads no config from it — the same reasoning as git's safe.directory.
On a shared machine, nobody else can leave a .git and a vibectx.config.json in a
directory above yours and choose where your agent's documentation comes from. If a config
does sit in the directory the check stopped at, it is named as ignored rather than passed
over in silence (this is what you will see if your repository is owned by another account,
or bind-mounted with a different uid — pass --config explicitly there).
Upgrading from 0.1.x
Configs written for 0.1.3 keep working, with one exception:
Breaking: every URL in
urlsmust behttps:. The fetcher has always refused anything else, so anhttp:entry could never have served a document — but it used to load quietly and now names itself at startup. Change the URL tohttps:(or drop the entry).librariesis optional:{}and a file with the key commented out load as "no entries", exactly as before.ttlHours: 0still means "always revalidate" and is still accepted. Only negative and non-finite values are refused.Nothing else about
--configchanged: pass it and discovery is skipped entirely, so an existing launch command behaves exactly as it did.
Checking coverage: vibectx doctor
A library can look healthy — bytes cached, refresh succeeded — and still answer
nothing (an llms.txt that is only a link index, a README fallback that never
mentions your topic). doctor makes coverage a measured property: for every
library it runs the entry's probeQueries through the same get_docs path
your agent uses and reports what came back.
vibectx doctor # human table
vibectx doctor --json # machine shape (below)
vibectx doctor --library next.js # one library (aliases work: --library next)
vibectx doctor --offline # cache only; never touches the network
vibectx doctor --config ./vibectx.config.jsonlibrary kind cache probe links mark
next.js readme 0.0h 2 probes: 1 answered, 1 no match 0/0 ✗
react readme 0.0h 2 probes: 1 answered, 1 no match 0/0 ✗
supabase readme 0.0h 2 probes: 2 answered 0/0 ✓
…
resend readme 0.0h 2 probes: 2 answered 0/0 ✓
23/30 libraries healthy
✗ next.js: no match: "app router layout"
✗ react: no match: "context provider"
✗ tailwindcss: no match: "responsive breakpoints"
✗ prisma: no match: "upsert"
✗ hono: no match: "route params"
✗ vite: no match: "env variables"
✗ anthropic-sdk: no match: "tool use"(A real vibectx doctor run over the shipped 30, on 2026-09-06, from a sandbox where only
raw.githubusercontent.com was reachable — so every entry fell back to its README
candidate and the kind column is readme throughout. Rows are elided at the …; the
totals and the ✗ lines are that run's, in full. With the docs sites reachable you would
expect full-text or index-only in the kind column and fewer no match probes: a
README is a much thinner document than an llms-full.txt, which is the honest limit
doctor exists to show you.)
Per library it reports:
kind —
index-onlywhen the document is link-dense (structure, whatever the URL; answers depend on following links);readmewhen it is not an index and the resolved URL is README-style (hostraw.githubusercontent.com, or last path segmentREADME/README.ext) or has no llms.txt provenance (last path segment is notllms.txt/llms-*.txt);full-textfor prose at an llms.txt URL;unreachablewhen nothing could be fetched and nothing is cached.cache — age of the cached document in hours, plus
stalewhen past its TTL.probe —
answered(≥ 1 section returned),index-followed(answered, and a returned section came from a followed index page — the index alone would have returned only its link list), orno match.(derived)marks a query derived from the description because the entry has noprobeQueries.links — index links followed / dropped (outside the allowed hosts, over 2 MiB, or unreachable).
Exit code 0 when every checked library is healthy; 1 when any is
unreachable, index-only with zero links followed, has a probe with no match,
or has a cache older than 2× its TTL (inclusive; not applied when ttlHours is 0);
2 for a usage, config or unknown-library error. --offline reports anything not
cached as unreachable. A library whose check itself fails (unreadable cache file,
permission error) is reported unreachable with error: <message> as its reason and
never stops the rest of the table. At most three libraries are checked at a time.
--json emits { schemaVersion: 1, generatedAt, libraries: [{ library, kind, url, cacheAgeHours, stale, ttlHours, probes: [{ query, derived, status, followed, dropped }], followed, dropped, healthy, reasons }], healthy, total, configIssues: [{ path, scope, reason }] } — keys in that order, null for a missing URL or age. configIssues (added
in 0.2.0) lists discovered config files that were skipped; while it is non-empty the exit
code is 1 however healthy the libraries look, because the entries those files pin are
simply missing. New keys may be appended in later versions; consumers should
read keys by name and must not assert exact key sets. reasons[] strings are
human-readable and not a contract. If you snapshot the output, note that generatedAt,
cacheAgeHours, url (which candidate resolved) and reasons[] are non-deterministic
run to run; schemaVersion is bumped only when a key is renamed, removed or changes meaning.
Honest limit: doctor measures retrieval, not correctness. A ✓ means an agent
asking that question today gets sections back; it does not check that they are the
right ones. list_libraries shows the same kind per library, classified from the
cache without touching the network (unknown until something is cached).
Design notes
Offline-first: past-TTL cache is served (flagged
STALE:) when the network fails — an old answer beats no answer, but the agent is told which it got.Index-aware: many projects publish
llms.txtas a link index rather than full content. When the source looks like an index (link-dense, any size), the topic's best-matching links — absolute or relative — are fetched (and cached) one level deep: up to 3 links, or 5 when the index has more than 200. Each followed page is capped at 2 MiB (larger responses are dropped, not cached), and no new fetch starts once ~2 MB of followed content has accumulated. Primary documents are capped at 25 MiB. Onlyhttpslinks on the source document's host or the entry'sallowedHostsare followed, checked again after redirects; skipped, oversize or unreachable links are reported in the response rather than dropped silently.Cross-library search:
search(query)runs one BM25 query over every cached document and groups the hits by library, for the common case where the agent does not know which library owns a concept. Cache-only and offline; backed by a derived, self-maintaining index that stores no document text. MEASURED: a warm search is 44 ms over a 5.63 MB / 12-document corpus and 820–893 ms over 146 MB in 30 documents — see Don't know which library?search.Deterministic retrieval: markdown heading-split + BM25 scoring over a camelCase-aware, lightly stemmed tokenizer — see How ranking works. No embeddings, no external calls at query time, same answer every run.
Using this in a company / behind an air gap?
This tool is free and MIT-licensed, and will stay that way. If you have a private documentation, air-gapped, or enterprise deployment need it doesn't cover — open an issue and describe your setup. Real-world reports directly shape what gets built.
Development
npm ci
npm test # vitest (needs Node ≥ 20.19)
npm run build # tsc → dist/
npm run lint # tsc --noEmitCONTRIBUTING.md covers the rest: scope boundary, testing conventions, when a Change Record is expected, and the claim discipline that binds README text as well as marketing.
The project record
The reasoning behind this codebase ships with it, in .vibectx-plan/. It is
not product — nothing in src/ imports it, and the published package contains only dist/ — but
it is where design questions are already answered:
.vibectx-plan/DECISIONS.md— the decision register. EveryD-nncited in a code comment or issue resolves here..vibectx-plan/change-records/— Change Records with their gate verdicts..vibectx-plan/VibeCTX-audit-2026-09-08.md— the audit the current release remediates, with file:line evidence..vibectx-plan/README.md— the index, including which documents are retired and what replaced them.
Some documents there carry retirement banners. They are kept so past reasoning stays readable; each names its live replacement.
License
MIT © 2026 Tom Hanks / BlackRaptor AI
Available Tools
3 toolsget_docsA
Get official documentation for a library. With a topic, returns the best-matching sections (following index links when the source is an llms.txt index); without one, returns the document head and section list.
| Name | Required | Description | Default |
|---|---|---|---|
| topic | No | What you need docs about | |
| library | Yes | Library name from list_libraries | |
| maxTokens | No | Approximate response budget (default 4000) |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description discloses key behaviors: returns best-matching sections, follows index links if source is llms.txt, returns head and section list when no topic, and mentions response budget via maxTokens. It does not cover auth needs or error handling but is quite detailed.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, front-loaded with main purpose, each sentence adds value without redundancy. Highly concise.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given 3 parameters, no output schema, and no annotations, the description is fairly complete. It explains behavior for both usage modes. Lacks details on return format and error scenarios, but sufficient for typical use.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, and the description adds context beyond the schema: explains how topic affects output (with vs without), and that library comes from list_libraries. This enriches parameter understanding.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool gets official documentation for a library, with specific behavior for when a topic is provided. It distinguishes itself from siblings (list_libraries, refresh) which are obviously different.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description implies usage (when you need docs for a library) but does not explicitly state when to use this over alternatives or when not to use it. It lacks explicit guidance on prerequisites or exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_librariesA
List the libraries this server can fetch docs for, with cache status. Use get_docs to retrieve content.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries full burden. It discloses the output includes cache status but does not explicitly state read-only behavior or any side effects. The verb 'list' implies read-only, but more detail could be added.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, no wasted words. Purpose is front-loaded, and alternative usage is given succinctly. Conciseness is excellent.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple 0-parameter tool, the description covers purpose, output (libraries with cache status), and relationship to siblings. It is complete and sufficient for an AI agent to select and invoke correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
There are no parameters, so schema coverage is trivially 100%. The description adds context about what the list contains (libraries with cache status), which is helpful beyond the empty schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool lists libraries with cache status, using the verb 'list' and specifying the resource 'libraries'. It distinguishes itself from sibling 'get_docs' by directing to use that tool for content retrieval.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description tells when to use this tool (to list libraries) and provides an alternative ('Use get_docs to retrieve content'). It is clear but does not explicitly mention when not to use or cover the 'refresh' sibling.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
refreshA
Force-refetch a library's docs from the network, bypassing the cache TTL. Omit library to refresh everything.
| Name | Required | Description | Default |
|---|---|---|---|
| library | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Discloses network fetch, cache bypass, and the ability to refresh all libraries. No annotations exist, so description provides necessary behavioral context.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences with zero waste, action verb first, and essential details packed efficiently.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Covers key behavior and parameter usage for a simple tool. Could mention return format or side effects, but sufficient given no output schema and low complexity.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema has 0% coverage, but description explains the optional 'library' parameter and its default behavior (refresh everything when omitted), adding significant value.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the action (force-refetch), resource (library's docs), and distinguishes from siblings like get_docs and list_libraries by emphasizing bypassing cache.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly states when to use (when fresh data needed) and implies when not to (if cached data acceptable). No explicit alternatives named, but context is clear.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
get_docs - First observed
list_libraries - First observed
refresh
TDQS
Scored across 3 tools
Each tool serves a distinct purpose: get_docs retrieves documentation, list_libraries lists available libraries, and refresh manages the cache. No overlap in functionality.
All tool names follow the verb_noun pattern (get_docs, list_libraries, refresh) with consistent imperative verbs and underscore separation.
With 3 tools, the server is minimal but covers the essential operations for a documentation cache. It could benefit from a search or add tool but is still appropriately scoped.
The set covers core cache operations: listing available libraries, fetching docs, and refreshing cache. Minor gap: no explicit way to add/remove libraries, but that may be handled externally.
Maintenance
Related MCP Connectors
An MCP server that gives your AI access to the source code and docs of all public github repos
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
MCP server for accessing curated awesome list documentation
Augments MCP Server - A comprehensive framework documentation provider for Claude Code
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceAn MCP server that provides version-pinned, deterministic documentation sourced from DevDocs.io to AI assistants (Claude, RooCode, Cline, Copilot etc.) and also via offline mode. Not via Scraping! But using the supported downloading option from devdocs.34 npm13MIT
- AlicenseNot gradedqualityCmaintenanceProvides a local MCP server for searching and retrieving documentation from 22+ open-source projects, enabling AI coding assistants to access up-to-date docs without network dependency.11 npm2MIT
- FlicenseNot gradedqualityBmaintenanceLocal MCP server that indexes documentation from URLs/files into a vector database, enabling coding agents to search and use up-to-date library and API documentation.-
- AlicenseAqualityAmaintenanceMCP server for local, offline-capable library documentation. It resolves library IDs and retrieves up-to-date docs via hybrid search from a persistent SQLite cache.31MIT