С любовью ко всем разработчикам на свете ❤️
point0-docs-mcp is an MCP server that gives an agent the Point0 documentation:
it exposes five read-only tools — list, search, get, outline, get-section — over
a prebuilt docs corpus with local hybrid search (keyword + semantic). The agent
answers framework questions from the real, current docs — and reads only the
section it needs instead of pulling a whole page. It ships in the @point0/docs
package and runs over stdio.
// .mcp.json (Claude Code) — also .cursor/mcp.json (Cursor), identical content
{
"mcpServers": {
"point0-docs": { "command": "bun", "args": ["run", "mcp:docs"] }
}
}// package.json — the script the config calls
"scripts": {
"mcp:docs": "point0-docs-mcp"
}That's the whole setup. create-point0-app writes both files, so a fresh app
already has the docs MCP wired.
create-point0-app shipsThe scaffold wires the docs MCP for both Claude Code and Cursor, alongside the
project MCP. The config calls the npm script
(bun run mcp:docs), not the bin directly, so the bin name lives in one place.
@point0/docs is a devDependency — it pulls in a local embedding stack
(@huggingface/transformers) and a prebuilt vector index, runtime-only tooling
you don't want in production deps.
The docs MCP takes no arguments. (The --meta flag belongs to the
project MCP, a different server — don't pass it here.)
Every tool is read-only and returns JSON. The cheap path on a large page is
search → get_section (or get_outline → get_section): a search hit
names the exact section, so the agent reads just that part instead of the whole
page.
search_docs — find a sectionsearch_docs({ query: "how do I gate a page behind auth" })
// => {
// "hits": [
// { "slug": "with", "title": ".with", "category": "methods",
// "heading": "Security: gate access in .with, not .ctx",
// "headingId": "security-gate-access-in-with-not-ctx",
// "ref": "with#security-gate-access-in-with-not-ctx",
// "snippet": ".with runs at render, including on the client…",
// "chars": 980, "score": 0.71 },
// …
// ],
// "total": 12, "hasMore": true, "nextOffset": 8
// }Hybrid search: BM25 keyword matching plus a 384-dim vector similarity, combined
in one query. query is required and natural-language; limit defaults to
8 and offset to 0.
Hits are sections, not whole pages. heading is the matched H2–H6 heading;
headingId is its anchor (the slug the docs site renders as #…, deduped per
page); ref is the ready-to-use slug#headingId; chars is the section body's
size, a cheap signal of how much get_section would return. The snippet is
the section's first ~280 characters, whitespace-collapsed, with an ellipsis when
truncated. After a search, call get_section(slug, headingId) for just that
section — or get_doc(slug) for the whole page.
get_doc — read a full pageget_doc({ slug: "overview" })
// => { "slug": "overview", "category": "intro", "title": "Overview",
// "description": "…", "index": 100,
// "content": "# full markdown body…" }slug is the bare file name — "overview", never "intro/overview". The
category is cosmetic and not part of the slug. content is the full markdown
with frontmatter stripped.
This returns the entire page, which can be large (the overview alone is
thousands of lines) — for a big page prefer get_outline + get_section.
The JSON object shown above is the tool's structuredContent; get_doc's
plain-text channel (content[0].text) is just the raw markdown body, not the
JSON. (search_docs, list_docs, and get_outline put
JSON.stringify(result) in their text channel.)
An unknown slug is a clean error result the agent can read, not a thrown exception:
get_doc({ slug: "nope" })
// => { content: [{ type: "text", text: 'No doc found for slug "nope".' }], isError: true }get_outline — a page's table of contentsget_outline({ slug: "overview" })
// => {
// "slug": "overview", "title": "Overview",
// "headings": [
// { "headingId": "introduction", "heading": "Introduction", "level": 2, "chars": 664 },
// { "headingId": "query", "heading": "Query", "level": 2, "chars": 10202 },
// …
// ]
// }Every section heading on the page with its anchor (headingId), level (2–6),
and body size (chars). It carries no body, so it's a cheap map of a large
page. The preamble before the first heading is omitted (it has no anchor — use
get_doc for the page top). An unknown slug is the same clean error result as
get_doc.
get_section — read one sectionget_section({ slug: "overview", heading: "query" })
// => { "slug": "overview", "headingId": "query", "heading": "Query", "level": 2,
// "content": "## Query\n\n…just this section…" }heading is the anchor — a search hit's headingId, the part after # in
its ref, or an id from get_outline. The result is that heading plus its
body, including any subsections nested under it (everything up to the next
heading of equal-or-higher level) — so asking for an H2 gives the whole H2
section, not just its first paragraph. Like get_doc, the plain-text channel
(content[0].text) is the raw markdown; structuredContent is the object
above.
An unknown slug or anchor is a clean error result, not a throw:
get_section({ slug: "overview", heading: "nope" })
// => { content: [{ type: "text", text: 'No section "nope" found in doc "overview".' }], isError: true }list_docs — the table of contentslist_docs()
// => {
// "docs": [
// { "slug": "overview", "category": "intro", "title": "Overview", "description": "…" },
// …
// ],
// "total": N, "hasMore": false
// // nextOffset is undefined here, so JSON.stringify drops the key entirely
// }Lists every page by slug, category, title, and description, ordered by
category (the order in categories.json) then by each page's frontmatter
index. Both limit and offset are optional — omit limit and you get all
pages from offset (and hasMore is false). The list is small, so you
rarely paginate it.
The search corpus is prebuilt at package-build time and shipped inside the npm package — search is offline and needs no API key at query time.
# packages/docs build step, run before tsdown
bun run build:content
# [point0/docs] built N docs, M sections → content/docs.jsonbuild:content reads the repo's docs/ directory, splits each page into
sections at its H2–H6 headings (a # inside a fenced code block is not a
heading), assigns each a GitHub-style anchor (the same slug rehype-slug
produces, so it matches the on-page #… link, deduped per page), embeds every
section locally, and writes one content/docs.json. That file is gitignored but
published (it's in the package's files), so installing @point0/docs gives
you the whole prebuilt index. CI uploads content/ with the build artifact and
the publish step refuses to publish @point0/docs if the corpus is missing, so
a release can never ship an empty index.
The embedding model is Xenova/all-MiniLM-L6-v2 (384-dim, ~23MB, via
@huggingface/transformers). At query time the server only embeds your search
query — the document vectors are already computed.
# the model downloads once into the shared Hugging Face cache, reused across projects
~/.cache/huggingfaceThe first search_docs call triggers the one-time ~23MB model download and
builds the in-memory index, so it's slow; everything after is local and fast.
list_docs, get_doc, get_outline, and get_section never touch the model,
so they're instant even on a cold start.
The corpus is a snapshot taken when @point0/docs was built. Search
reflects the docs as of that package version — not your local edits or a
newer docs site. To pick up newer docs, update the @point0/docs dependency.
(The project MCP, by contrast, re-reads your app's meta on every
call.)
If you don't want to run an MCP server at all, the Point0 site serves the same docs as plain text following the llmstxt.org standard:
https://1gr14.dev/llms.txt # an index: one link per doc, agent fetches what it needs
https://1gr14.dev/llms-full.txt # the entire docs corpus in one file, for a single fetchFeed either URL to an agent and it answers framework questions with no install
step. point0-docs-mcp is the local, searchable path (five tools, offline after
the model downloads); llms.txt is the fetch-based path (zero setup, no local
model). Both draw on the same docs/ source — the corpus at package-build time,
the llms.txt files built and served by the Point0 site.
| Tool | Input | Returns |
|---|---|---|
search_docs | query (required), limit (default 8), offset (default 0) | { hits, total, hasMore, nextOffset } — hits are sections |
get_doc | slug (required; bare file name) | the full Doc (content = full markdown), or isError if unknown |
get_outline | slug (required; bare file name) | { slug, title, headings } — heading anchors/levels/sizes, no bodies |
get_section | slug + heading (the anchor / headingId) | { slug, headingId, heading, level, content }, or isError if unknown |
list_docs | limit (optional → all), offset (default 0) | { docs, total, hasMore, nextOffset } — slug/category/title/description |
{ slug, title, category, heading, headingId, ref, snippet, chars, score }slug is the parent page; heading is the matched section and headingId its
anchor within the page; ref is the ready-to-use slug#headingId. Pass
(slug, headingId) to get_section for just that section (or slug to
get_doc for the full page). chars is the section body's size — a cheap
signal of how much get_section returns. score is Orama's raw hybrid score —
higher is a better match; rank by it, don't read a fixed range into it.
point0-docs. Its reported version is the installed
@point0/docs package version.intro, points, methods, core, engine, extra,
examples (from docs/categories.json) — grouping only, never part of a
slug.content/docs.json is read
and JSON.parsed with no fallback, so a build that skipped build:content
(or a truncated file) makes the first tool call throw.