Add the docs MCP server and a skill that reaches for it - #7
Merged
Conversation
.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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonis the only way to connecthttps://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-lookupagent with its own context) was considered and measured first, against the live server:docs://{lib}/indexget_page_contentpageA 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
dprprop 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/hooksfor r3f,/learn/guides/...for zustand,/abstractions/...for drei) andget_page_contentmatches exactly, no normalization. The server's own manifest documents zustand paths under/docs/...that 404, so a model following it lands onPage not foundand retries variants.Coverage is narrower than the enum
The tool advertises 11 libraries. Probing each one, only 4 publish the
llms-full.txtdump the server parses:llms-full.txt)For those 7,
get_page_contentalways 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.rsserves 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