Skip to content

Add the docs MCP server and a skill that reaches for it - #7

Merged
abernier merged 10 commits into
mainfrom
docs-mcp-skill
Aug 11, 2026
Merged

Add the docs MCP server and a skill that reaches for it#7
abernier merged 10 commits into
mainfrom
docs-mcp-skill

Conversation

@abernier

@abernier abernier commented Aug 11, 2026

Copy link
Copy Markdown
Member

Gives the plugin its first two components: the docs MCP server, and the skill that makes Claude actually reach for it.

Why both

They are different layers, not alternatives. .mcp.json is the only way to connect https://docs.pmnd.rs/api/mcp; the skill is what decides when to use it. Without the skill the tools are present but the model answers from memory — which is the whole problem, since R3F and drei move faster than any training cutoff.

Why no subagent

A third layer (a docs-lookup agent with its own context) was considered and measured first, against the live server:

payload cost
docs://{lib}/index 213 tok (r3f) → 1,294 tok (drei)
get_page_content page 1,200 – 4,100 tok, avg ~2,700

A typical lookup is one index plus one page: 3–5k tokens. That sits comfortably in the main context, so isolation would buy little and cost a round-trip plus the agent's blindness to the code being edited — it could report what the docs say, but not that the dpr prop in front of it is stale. The layer composes cleanly later if lookups turn out to be broader in practice; the skill just gains a routing line.

Why the skill insists on reading the index first

Not a token optimization — a correctness rule. Page paths differ per library (/api/hooks for r3f, /learn/guides/... for zustand, /abstractions/... for drei) and get_page_content matches exactly, no normalization. The server's own manifest documents zustand paths under /docs/... that 404, so a model following it lands on Page not found and retries variants.

Coverage is narrower than the enum

The tool advertises 11 libraries. Probing each one, only 4 publish the llms-full.txt dump the server parses:

served not served (404 on llms-full.txt)
react-three-fiber, drei, zustand, docs a11y, react-postprocessing, uikit, xr, prai, viverse, leva

For those 7, get_page_content always errors and the index resource returns an empty string. So the skill claims only what it can deliver and sends the rest to WebFetch — a description naming all 11 would fire the skill on questions it cannot answer.

Server-side fix in flight: pmndrs/docs#555. Once it lands, the enum and this skill can widen together as each site ships its dump.

No tests

This went through a test suite and back out again, on purpose. A plugin that ships configuration and prose has almost nothing to assert that isn't a file read back to itself, and the checks that did survive were carrying a package.json, a lockfile, vitest, a tsconfig and a CI job — more machinery than the thing they guarded.

Verifying that docs.pmnd.rs serves what it claims went too: that is the docs site's responsibility, and pmndrs/docs#555 tests it there. A consumer asserting things about its provider only duplicates that, on a slower loop, from a repo with no power to fix what it finds.

So the diff is three files: .mcp.json, the skill, and the README section describing them.


🤖 Generated with Claude Code

abernier and others added 10 commits August 11, 2026 09:28
.mcp.json wires https://docs.pmnd.rs/api/mcp, which serves the official docs
for the 11 pmndrs libraries it knows about. The skill is what makes Claude
actually consult them instead of recalling a stale API from memory.

It enforces reading docs://{lib}/index before get_page_content. That is a
correctness rule, not just a token one: page paths differ per library, and the
manifest the server ships documents zustand paths that 404.

Measured before choosing the shape: an index costs 213-1294 tokens and a page
1200-4100, so a typical lookup lands at 3-5k. That fits the main context, so
no subagent for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probing every library in the tool enum: only react-three-fiber, drei, zustand
and docs publish the llms-full.txt dump the server parses. The other seven
(a11y, react-postprocessing, uikit, xr, prai, viverse, leva) 404 on it, so
get_page_content always errors and their index resource comes back empty.

Naming all eleven in the description would have the skill fire on questions it
cannot answer, so it now claims only what it serves and points the rest at
WebFetch. Server-side fix is pmndrs/docs#555; widen this back once it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skill states coverage as fact -- these four libraries are served, those seven
are not -- and that claim is the part most likely to rot. It was already wrong
once: the description shipped naming eleven libraries, seven of which return
nothing. Nothing in the repo would have caught that.

So the suite runs the workflow the skill prescribes, end to end: read
docs://{lib}/index, take a path from it verbatim, fetch that page, assert real
content comes back. Plus two drift detectors -- one that fails when an unserved
library starts working, naming it and pointing at the SKILL.md section to widen,
and one that fails if SKILL.md stops mentioning a library the tests cover.

It talks to the real docs.pmnd.rs rather than a mock. A mock would keep passing
through precisely the drift this exists to catch. CI runs it on push and weekly,
since coverage can change with no commit here.

No dependencies and no package.json: node:test and fetch are enough, and a plugin
repo has no business carrying a lockfile for this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous suite hit docs.pmnd.rs on every PR. A patch touching nothing but a
README would go red when a third party had a bad day, and a build that fails for
reasons outside your change is a build people learn to ignore.

Split by what a failure means. test/plugin.test.mjs is offline and gates PRs:
.mcp.json shape, the skill frontmatter, the tool name matching the server key,
and the Coverage section pinned to the lists in scripts/docs-coverage.mjs. Seven
tests, 56ms, no sockets. scripts/check-docs-mcp.mjs keeps the live contract check
and runs weekly, where a failure is signal -- the server moved and the skill now
says something untrue -- and files an issue instead of reddening a build nobody
connects to a change.

It lives outside test/ because `node --test` walks that directory wholesale,
regardless of the .test. suffix -- naming the file .live.mjs did not exclude it,
as the run time gave away.

Both suites are mutation-checked: declaring leva served, or moving xr into the
skill's served list, each turns the offline suite red on the assertion that names
the drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
checkout@v4 and setup-node@v4 target Node 20, which the runners now force
onto 24 with a deprecation annotation on every run. Annotations that show up
unconditionally stop being read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UNSERVED was a hand-maintained mirror of SERVED, and it only covered the seven
libraries that happened to be broken the day it was written. A library that
started working was caught; one the server newly advertised was not, and neither
was the pair silently disagreeing.

The server publishes its own universe in the get_page_content `lib` enum, so the
check now reads it, probes every entry, and asserts that the set which actually
works equals SERVED. One list to maintain, and it fails in both directions --
a library that starts working reads as loudly as one that stops.

The skill stops enumerating them too, for the same reason: it states the rule
("anything not listed above is unserved, use WebFetch") rather than seven names
that rot the day pmndrs/docs#555 lands. The offline suite pins its served list to
SERVED by set equality, so a name added to the prose alone is the same failure as
one dropped.

Moves to vitest and .mts along the way. The suites split by config rather than by
directory -- vitest globs are explicit, so the .live.mts suffix genuinely excludes
the network check, which is what `node --test` walking all of test/ would not do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most of the offline suite was restating a file to itself: that SKILL.md has a
name and a description, that .mcp.json holds the url .mcp.json holds. That is not
a test, it is a copy, and it fails only when someone edits both halves in the same
breath.

There is no code in this plugin to unit-test. So the rule is now explicit: a check
earns its place only by pinning a relationship between two files that nothing else
enforces. Four survive -- the tool name in the skill against the server key in
.mcp.json, the live check's endpoint against the declared one, the skill's served
list against SERVED, and the marketplace against the plugin manifest. Rename the
server key and the first one catches what would otherwise be Claude silently
failing to find the tool.

The real check remains the live one, which compares our claim to something we do
not control.

Also drops the test/ directory, which is not a plugin convention -- Claude Code
discovers commands/, agents/, skills/, hooks/ and .mcp.json, and ignores the rest.
Four files at the root read plainly for a repo this size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whether docs.pmnd.rs serves what it advertises is the docs site's responsibility,
and it is tested there. A consumer asserting things about its provider only
duplicates that, on a slower loop, from a repo with no power to fix what it finds.

Takes the weekly workflow and its issue-filing with it, and coverage.mts too:
SERVED only meant something while a live probe anchored it to reality. Without
that, it was a second hand-written copy of what the skill already says -- the same
tautology this suite was just pruned of.

Two checks left, both pinning files that would otherwise drift apart in silence:
the tool name in the skill against the server key in .mcp.json, and the
marketplace against the plugin manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two assertions were left, and they were carrying a package.json, a lockfile,
vitest, a tsconfig and a CI job. For a repo that ships configuration and prose,
that is more machinery than the thing it guards.

Neither was quite tautological, so they are written down instead of deleted. Both
are edits that fail silently: renaming an .mcp.json server key invalidates every
mcp__<key>__* in the skills with no error anywhere, and a plugins[].name in
marketplace.json that disagrees with plugin.json breaks /plugin install while
`claude plugin validate` still passes -- checked, it does pass. AGENTS.md now says
so, which is where someone about to make either edit will actually be reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both sections only existed to explain an absence. AGENTS.md and the README are
for what the repo is, not for a record of what was considered and dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abernier
abernier merged commit e9f78db into main Aug 11, 2026
@abernier
abernier deleted the docs-mcp-skill branch August 11, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant