Line anchors in this issue were verified at HEAD e5806e24 (docs: correct the CI route named in seven bun test wrappers (#1345)). Re-check any anchor that has moved before editing.
Problem
@webjsdev/mcp bundles a frozen snapshot of the docs corpus into resources/ at prepack, and the knowledge layer serves that snapshot with no version or staleness signal. A globally installed MCP server therefore keeps teaching pre-fix guidance forever, and the agent reading it has no way to notice.
Hit while building a real app (a paid-workshop platform) against @webjsdev/core@0.7.47 / server@0.8.59 / cli@0.10.51. The session's MCP server was a bun-global @webjsdev/mcp@0.1.4, whose bundled resources/AGENTS.md still said:
A page/layout module still loads in the browser for its top-level side effects: registering imported components (so their tags upgrade) and, for a layout, enabling the client router via import '@webjsdev/core/client-router'.
That sentence was corrected in dbaf3f43 (feat: client router auto-enables from the bundle, drop layout imports (#628), 2026-06-19). The current text at AGENTS.md:134 reads correctly:
The client router is automatic and needs no import: it auto-enables when @webjsdev/core loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620).
So the docs are already right. The defect is that a stale server keeps serving the pre-#628 text, and contradicts the very same file sitting in the app's own node_modules/@webjsdev/mcp/resources/AGENTS.md, with nothing in the init output to reveal the conflict.
Blast radius, observed
Following the stale init output, the agent wrote import '@webjsdev/core/client-router'; into three layouts (app/(site)/layout.ts, app/admin/layout.ts, app/portal/layout.ts). That import is not merely dead:
importsClientRouter() at packages/server/src/component-elision.js:419 treats it as a genuine client side effect. Its caller at :999 puts the file in clientRouterFiles, so the reason lookup at :1167 returns 'imports the client router' and the layout ships whole.
- Removing the import, client navigation still worked (verified: a
window marker survived a / to /faq link click, proving a soft nav), and webjs doctor's reason for that layout changed from ships whole because it imports the client router to the next blocker.
So stale guidance produced dead code that silently defeated elision. That is the cost worth fixing, not the wording.
Corrections to the original statement of this issue
Every claim above was re-verified at e5806e24. Five anchors in the first draft were stale or named something that does not exist, and are corrected here so an implementer does not chase them:
importsClientRouter() is at :419, not :408. Its JSDoc block opens at :412; the function line is :419.
- There is no function named
elisionReason(). The reason string lives in clientEffectReason(), an inner arrow declared at packages/server/src/component-elision.js:1165 inside the exported analyzeElision() (:918), and the 'imports the client router' branch is :1167. The draft cited elisionReason() at :1143.
- The stale-text line anchors cannot be checked against a file in this repo.
resources/AGENTS.md is a GENERATED copy that exists only inside a published tarball, so resources/AGENTS.md:111 and resources/AGENTS.md:133 name lines in an artifact no checkout contains. Both were verified against the SOURCE that copy-mcp-resources.js reads, the repo-root AGENTS.md: git show 8a560962:AGENTS.md | sed -n '111p' (the chore: release ... mcp 0.1.4 ... commit, 2026-06-18) carries the stale sentence verbatim, and AGENTS.md:134 at HEAD carries the corrected one. The 0.1.4 release predates the dbaf3f43 fix by one day, which is exactly how that tarball froze the wrong text.
packages/mcp/resources/ does not exist in the checkout at all. The draft said it was empty. git ls-files packages/mcp/resources is empty AND ls packages/mcp/resources reports no such directory, because postpack removes the whole tree (clean-mcp-resources.js:27 calls rmSync(destRoot, { recursive: true, force: true })).
resolveDocsLocation() spans packages/mcp/src/mcp-docs.js:44 through :63. The draft cited L45-55 in one place and L36-60 in another. The other mcp.js anchors have all moved too and are restated in the plan below.
Ground truth re-verified at HEAD, unchanged from the draft: packages/mcp/package.json is version 0.1.12, its scripts are exactly prepack: node scripts/copy-mcp-resources.js and postpack: node scripts/clean-mcp-resources.js, and its files array is ["bin", "src", "README.md", "resources"].
Design / approach
The original draft listed four moves "in rough order of value". That ordering is not a plan, so each is settled here as a decision with its alternatives rejected.
Decision 1. The stamp is a single JSON file at resources/corpus.json
Rejected, a plain resources/VERSION text file. It forces every reader to invent a parse format for three fields, and the format breaks the moment a fourth is wanted. There is no ecosystem convention for the contents of a bare VERSION file, so two readers can disagree about it.
Rejected, front matter injected into each copied doc. It mutates the served doc text so it no longer matches its repo source, which gives up the "source stays single" property that copy-mcp-resources.js:9-10 and packages/mcp/AGENTS.md invariant 3 both call out. It also shifts every line number in every doc relative to the repo copy, multiplies the write surface by the file count, and still leaves no single place to read the answer.
Chosen, one JSON object. JSON.parse is the parser, so no format is invented. The repo already has the precedent: buildInfo() at packages/server/src/build-info.js:42 composes a small JSON object identifying a running build for exactly this "which version is live" question, and this is its pack-time analogue. Prior art outside the repo is npm's own gitHead field, which records the publishing commit into published package metadata (verified present in this repo's installed tree, in node_modules/typescript/package.json and node_modules/parse5/package.json), so pack-time capture of a git SHA is an established practice rather than a novelty.
The filename is lowercase corpus.json, placed at resources/corpus.json beside AGENTS.md and SKILL.md, never inside resources/references/. Two reasons. First, catalogue() at packages/mcp/src/mcp-docs.js:101 lists only docsDir entries matching /\.md$/i (:112), and docsDir is resources/references, so a .json file one level up is invisible to the catalogue twice over and can never appear as a webjs-docs://corpus resource. Second, a doc-shaped name such as CORPUS.md inside references/ WOULD be catalogued and would surface to agents as a readable doc, which is not what a build stamp is.
Decision 2. The stamp carries package, version, SHA, and copy timestamp
{
"package": "@webjsdev/mcp",
"version": "0.1.12",
"sha": "e5806e2400000000000000000000000000000000",
"copiedAt": "2026-08-08T09:14:22.031Z"
}
version is what a later slice compares to decide whether a server is behind. sha is what makes the answer actionable, because a version alone cannot tell an agent WHICH docs it is holding, while a SHA resolves directly to a GitHub diff. copiedAt is wanted, not merely tolerated: the incident was a server months out of date, and a human-readable date is the most legible staleness cue for an agent that cannot resolve a SHA and may not know what the current version is. Nothing in this repo verifies tarball bit-identity, so there is no reproducible-build rule for a timestamp to violate, and prepack inside a publish is a one-shot event by construction. package makes the object self-describing when an agent encounters it out of context, at a cost of one line.
sha is the full 40-character hex string, not an abbreviation, because an abbreviated SHA is ambiguous by definition and the consumer can always shorten it for display.
Rejected, a git branch name or committer. Neither has a consumer, and a branch name is not stable in a squash-merge repo.
Rejected, a Node version. The corpus is markdown text, so the copier's runtime says nothing about it.
Rejected, a clean-tree guard that nulls the SHA when the working tree is dirty (npm's historical gitHead behaviour). It would misfire on every real release here, because release.yml:206-217 deliberately rewrites package.json in the runner's working tree before publishing, and because prepack itself writes resources/ and so dirties the tree before anything could check. A SHA naming the commit the docs came from is what the consumer needs, and the residual imprecision of a local npm pack from a dirty tree does not justify a false negative on every release. This paragraph exists so a reviewer does not "fix" the omission.
Decision 3. No git means sha: null, and the script never fails
prepack can run outside a git checkout, for instance when a consumer runs npm pack inside an extracted tarball or a Docker build with no .git. The git binary may also be absent entirely.
Chosen, spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }), treating a throw, a non-zero status, or stdout that does not match /^[0-9a-f]{40}$/ after trimming as absent, and writing "sha": null in that case. spawnSync rather than execSync because a missing binary must be a returned error object, not a thrown one.
The justification is that this script's job is copying markdown. Failing a publish because git was unavailable would convert a diagnostic nicety into a release blocker, and fail-soft is already this module family's posture: resolveDocsLocation's docblock (mcp-docs.js:38-39) states that either path may not exist and callers fail soft, initText swallows a failed agentsPath read to '' at :193, and frameworkVersion() swallows its read failure to '' at build-info.js:28.
Rejected, a WEBJS_CORPUS_SHA environment override for CI. Both publish paths (scripts/publish-npm.js:85 and scripts/publish-github-packages.js:148) run npm publish --workspace=... --ignore-scripts=false from the actions/checkout@v6 working tree (release.yml:55-57), so git is present in every real release and the override would have no caller. The fetch-depth: 2 shallow clone is not a problem, because git rev-parse HEAD resolves from the checked-out ref and needs no history.
Decision 4. PR 1 writes the stamp, PR 2 reads it
The stamping slice does not also surface the stamp in init, because surfacing is not one line. initText(deps) at mcp-docs.js:191 is pure over its injected deps, and deps today is { docsDir, agentsPath, skillPath, listDir, exists, readFile }. Printing the stamp needs a new corpusPath dep produced by resolveDocsLocation and threaded through the docsDeps construction at mcp.js:505-518, a read plus parse plus a fallback line for the dev path that has no stamp, updated initText tests, and updates to the doc surfaces that describe what init returns. That is a change to the tool's output contract, which is exactly what "purely additive and cannot regress anything" was meant to exclude. PR 1 therefore touches a build script, its test, and package-level docs only.
Decision 5. The app's corpus wins over the server's bundled one
Priority chain in resolveDocsLocation, highest first:
<appDir>/node_modules/@webjsdev/mcp/resources/references when that directory exists.
<pkgRoot>/resources/references, the running server's own bundled snapshot.
- The monorepo-root skill at
<repoRoot>/.agents/skills/webjs/references, the dev path.
Rung 1 is new and is the actual fix. It wins because the app's copy is version-matched to the framework the agent is editing, and that is the only corpus that can be correct about that app. Rungs 2 and 3 keep their current order and behaviour unchanged.
The probe is a plain existsSync on the directory, not require.resolve('@webjsdev/mcp') from appDir. Resolving the package would go through its exports map, and in this monorepo node_modules/@webjsdev/mcp is a workspace symlink back to packages/mcp, whose resources/ does not exist, so the resolve would land on the dev path anyway. The honest statement of what this needs is "a directory of markdown at a known path", which is what existsSync asks. When the running server IS the app's installed copy, rung 1 finds the same directory rung 2 would have found, which is a correct no-op.
Decision 6. The docs location is resolved per call, memoized by appDir
docsDeps is built once at server startup (mcp.js:505), before any tool call, while appDir is per call (mcp.js:620). A boot-time resolution would pin the corpus to the launch directory forever, which defeats decision 5 for any host that passes an explicit appDir.
Chosen, replace the single docsDeps object with a docsDepsFor(appDir) factory backed by a Map keyed on the appDir string. When opts.docsDeps is injected, the factory returns that injected object for every appDir, so every existing test keeps passing unchanged and the injection seam the tests rely on is preserved exactly.
resources/list (mcp.js:584) and resources/read (mcp.js:589) carry no appDir in the MCP protocol, so they call docsDepsFor(cwd), which is the same value tools/call defaults to when no appDir argument is given. In the ordinary case, where the host launches the server in the project directory, init, docs, and every webjs-docs://* resource read one corpus. They can diverge only when a tool call explicitly overrides appDir to a different project, which is the caller deliberately asking about another app. That is the defined behaviour, stated rather than pretended away, and it answers the "two surfaces disagree, which is worse than the current failure" landmine directly: the corpus is a pure function of appDir, so the surfaces agree whenever appDir does.
init keeps answering with no app present, because a nonexistent appDir simply fails the existsSync probe and falls through to rung 2. No hard dependency on appDir resolving is introduced.
Decision 7. The mismatch warning compares @webjsdev/mcp only, and warns rather than fails
The draft proposed comparing "the resolved @webjsdev/* versions in appDir". That is corrected to comparing @webjsdev/mcp alone. The corpus ships inside the mcp package, so mcp's version is the only version that bounds the corpus. A newer @webjsdev/core in the app says nothing about whether the DOCS are stale, and reading four package.json files to produce one warning multiplies the failure modes for no added signal.
The comparison is a dependency-free numeric compare of the major.minor.patch triple with any prerelease suffix stripped before comparing. It warns only when the app's version is strictly greater than the running server's. Equal or lower is silent, because a globally installed NEWER server reading an older app's docs is the normal monorepo-dev shape and is not a defect.
Warn, never fail. Settled on the ground that a read-only knowledge tool refusing to answer is strictly worse than one answering with a caveat, since the agent's alternative to a caveated answer is its training data, which is the thing the caveat exists to correct. The warning is advisory by the time it fires anyway, because decision 5 has already pointed the server at the app's corpus, so the text below the warning is already the right text. The warning's real job is telling the human to update the global install so the TOOLS match, not just the docs.
The warning is prepended to the init tool RESULT text, which reaches the client through the content array at mcp.js:634. It is never a console.log, per packages/mcp/AGENTS.md invariant 1 (stdout is the JSON-RPC channel).
Decision 8. importsClientRouter() is not touched
An explicit client-router import IS a real client side effect, and AGENTS.md:134 documents it as one of the three things that still ship a page or layout whole. The bug is upstream of the analyser, in what the agent was told to write. Do not "fix" packages/server/src/component-elision.js.
Implementation plan
Sequencing. This issue ships as four PRs against THIS issue, in order, and the issue stays OPEN until the staleness signal actually reaches the agent (the end of PR 4). PR 1 is the stamping slice and lands first, on its own, because it only adds a generated file to a tarball and changes no served byte, so it cannot regress anything. PR 2 surfaces the stamp in init. PR 3 flips the corpus priority so the app's copy wins, which is the actual fix. PR 4 adds the version-mismatch warning. Do NOT open new issues for the later slices, which are sequenced PRs against #1319.
No follow-up issues. Anything this work turns up is fixed inside the same PR that turned it up, not filed. The only exception is the sequencing above, which is PRs against this issue rather than new issues. If a finding is genuinely out of reach of the PR that found it, report it to the user as a note in that PR's description. Do not file it.
Each PR cuts its own worktree from origin/main per the repo's one-task-per-worktree rule, and runs npm run worktree:link inside it before running any test.
PR 1. Stamp the corpus at prepack
- In
packages/mcp/scripts/copy-mcp-resources.js, add a readGitSha(cwd) helper above bundleDocs (currently at :32). Import spawnSync from node:child_process. Return the trimmed stdout when status === 0 and it matches /^[0-9a-f]{40}$/, otherwise null. Wrap the call in try { ... } catch { return null; } so an absent git binary returns null rather than throwing.
- Extend
bundleDocs({ srcRefs, srcAgents, srcSkill, destRoot, stamp }) with a fifth stamp property. After the two copyFileSync calls at :37-38, write writeFileSync(join(destRoot, 'corpus.json'), JSON.stringify(stamp, null, 2) + '\n') when stamp is provided, and skip the write when it is not. Keeping stamp optional preserves the function's purity over its arguments, which is what makes the existing temp-dir test at packages/mcp/test/mcp-docs.test.mjs:165 work, and lets the new test drive both branches.
- Import
writeFileSync from node:fs in that file's existing import at :19. The current import list is cpSync, mkdirSync, copyFileSync, rmSync.
- In
main() (:42), build the stamp from the package's own package.json (read version via readFileSync + JSON.parse on join(pkgRoot, 'package.json'), matching the pattern bin/webjs-mcp.js already uses) plus readGitSha(repoRoot) plus new Date().toISOString(), and pass it to bundleDocs alongside the existing destRoot: join(pkgRoot, 'resources') at :51.
- Extend the stderr diagnostic at
:54 to name the corpus identity, so a release log records what was stamped. Keep it on stderr, since :53 documents why (a tool parsing npm pack --json stdout must not see it).
packages/mcp/scripts/clean-mcp-resources.js needs NO change. cleanBundle at :26 removes destRoot recursively, so the stamp goes with the tree. Verify this in the test rather than assuming it, and add a one-line comment at :27 noting the stamp is covered by the recursive removal so a future reader does not add a redundant unlink.
packages/mcp/package.json needs no change. Its files array already contains resources, which carries every file inside the directory including the new one. State this in the PR description rather than editing the manifest.
PR 2. Surface the stamp in init
- In
packages/mcp/src/mcp-docs.js, extend resolveDocsLocation (:44-63) to return a fourth key corpusPath. On the bundled branch (:51-55) it is join(pkgRoot, 'resources', 'corpus.json'). On the repo-root dev branch (:58-62) it is null, because a dev checkout has no stamp and inventing one would make dev claim a provenance it does not have.
- Thread it through the
docsDeps construction at packages/mcp/src/mcp.js:505-518, adding corpusPath: loc.corpusPath beside the existing docsDir / agentsPath / skillPath keys.
- In
initText(deps) (:191), read and parse deps.corpusPath inside a single try/catch, immediately after the agents read at :193. Emit exactly one line, placed between the # webjs: read first title and the router block in the parts array at :217-229:
- stamped,
Docs corpus: @webjsdev/mcp@<version>, copied from webjsdev/webjs <sha7> on <YYYY-MM-DD>.
corpusPath is null, Docs corpus: the live repo-root docs in this checkout, not a bundled snapshot.
corpusPath is set but the read or parse failed, Docs corpus: an unstamped @webjsdev/mcp bundled snapshot.
- stamped with
sha: null, drop the copied from clause and keep the version and date.
- A stamp failure degrades to the unstamped line and never throws, matching the swallow already at
:193.
Landmine for this PR. test/knowledge/knowledge-coverage.test.js derives the AGENTS.md headings the primer sources from the sectionByHeading(agents, /.../) calls in mcp-docs.js (framework-dev.md:130), so a heading rename cannot silently empty the primer. Adding a corpus line adds no sectionByHeading call and so does not touch that derivation, but do not introduce one while editing initText, or that test will start requiring a matching AGENTS.md heading.
PR 3. Prefer the app's corpus
- Change
resolveDocsLocation(moduleUrl) (mcp-docs.js:44) to resolveDocsLocation(moduleUrl, appDir), with appDir optional so every existing caller keeps working. Insert the rung-1 probe BEFORE the bundled branch at :49: when appDir is a non-empty string and existsSync(join(appDir, 'node_modules', '@webjsdev', 'mcp', 'resources', 'references')), return that references dir as docsDir with sibling AGENTS.md, SKILL.md, and corpus.json paths.
- In
packages/mcp/src/mcp.js, replace the single docsDeps binding built at :505-518 with a docsDepsFor(appDir) closure over a Map. When opts.docsDeps was injected, return it for every appDir unconditionally.
- Update the three call sites to go through the factory.
resources/list at :584 and resources/read at :589 both use docsDepsFor(cwd). The tools/call dispatch at :622-627 uses docsDepsFor(appDir) for both the initText call at :624 and the searchDocs call at :626, where appDir is already computed at :620.
- Leave
runSourceTool and sourceDeps alone. The source tool reads node_modules/@webjsdev/*/src and already resolves its roots from cwd at :529; it is not part of the docs corpus and is out of scope.
PR 4. Warn on a version mismatch
- Add a
readAppMcpVersion(appDir, { exists, readFile }) helper in mcp-docs.js that reads <appDir>/node_modules/@webjsdev/mcp/package.json, parses it, and returns its version string, returning null on any absence, read failure, parse failure, or missing field.
- Add a
compareVersions(a, b) helper that strips any -prerelease suffix, splits on ., coerces each of the three segments with Number, treats a NaN segment as 0, and returns -1 / 0 / 1. Dependency-free, consistent with the package's zero-dependency posture stated in packages/mcp/AGENTS.md.
initText gains the server's own version. runMcpServer already has it at mcp.js:479, so pass it into docsDepsFor and put it on the deps object as serverVersion, alongside a new appDir key so the helper knows where to look.
- When
compareVersions(appVersion, serverVersion) > 0, prepend one line ABOVE the corpus line from PR 2:
Warning: this MCP server is @webjsdev/mcp@<server>, but this app has @webjsdev/mcp@<app>. The server's own docs and tools may be stale. Update the global install with npm i -g @webjsdev/mcp@latest, or bun add -g @webjsdev/mcp.
- Silent in every other case, including equal versions, a lower app version, and an unreadable or absent app
package.json.
Tests
Run npm test from the repo root for each PR. npm test does not run browser, e2e, or Bun. None of those layers apply here (see the Bun note below), so report the Node suite result and say explicitly that the other layers are not applicable and why.
PR 1, packages/mcp/test/mcp-docs.test.mjs
The file already imports bundleDocs and cleanBundle from the scripts at :27-28 and already drives them against temp dirs in the test at :165, so the new cases follow that exact shape and naming.
bundleDocs writes a parseable stamp. Build a temp srcRefs / srcAgents / srcSkill fixture as the existing test does, call bundleDocs({ ..., stamp: { package: '@webjsdev/mcp', version: '9.9.9', sha: 'a'.repeat(40), copiedAt: '2026-08-08T00:00:00.000Z' } }), then assert resources/corpus.json exists in destRoot, that JSON.parse of its contents deep-equals the stamp, and that the four fields have the expected types.
bundleDocs omits the stamp when none is passed. Assert corpus.json does not exist in destRoot. This is the branch every existing caller of the two-argument form takes, and it proves the addition is opt-in.
cleanBundle removes the stamp with the tree. Bundle with a stamp, call cleanBundle(destRoot), assert neither destRoot nor the stamp path exists.
readGitSha returns null outside a git checkout. Call it with a freshly created temp dir as cwd and assert null. This proves decision 3 without needing a machine that lacks git.
- The stamp is inside the published
files allowlist, asserted without packing. Read packages/mcp/package.json, assert its files array contains resources, and assert in the same test that the path bundleDocs writes is join(destRoot, 'corpus.json') where main() sets destRoot to join(pkgRoot, 'resources'). Those two facts together prove the tarball carries the stamp.
- Do NOT add an
npm pack test. Packing runs prepack, which WRITES into the real packages/mcp/resources/ in a repo several agents share, and postpack does not run when a pack fails, so a failed run leaves a stale bundle behind. clean-mcp-resources.js:4-8 documents that exact hazard: a lingering resources/ shadows the live repo-root skill in dev and in every in-repo app that resolves through the workspace symlink.
Counterfactual for PR 1. Delete the writeFileSync call from bundleDocs and the "writes a parseable stamp" test must fail on the existsSync assertion. Delete the /^[0-9a-f]{40}$/ guard from readGitSha and the outside-a-checkout test must fail, because git rev-parse HEAD in a temp dir under /tmp either errors or prints a message that is not a 40-hex SHA.
PR 2, packages/mcp/test/mcp-docs.test.mjs
Every case drives initText with the in-memory fixture() helper at :33, extended with a corpusPath plus a matching entry in its files map.
- Stamped corpus, the output contains the version, the seven-character SHA prefix, and the date.
corpusPath: null, the output contains the repo-root wording and no version string.
corpusPath set but the file is absent (the fixture readFile throws ENOENT), the output contains the unstamped wording.
corpusPath set but the file content is not valid JSON, the output contains the unstamped wording and initText does not reject.
- Stamped with
sha: null, the output contains the version and date and no copied from clause.
resolveDocsLocation returns a non-null corpusPath on the bundled branch and null on the repo-root branch, driven the way the existing resolveDocsLocation test drives it.
Counterfactual for PR 2. Remove the corpus line from the parts array in initText and the stamped-output assertion must fail.
PR 3, packages/mcp/test/mcp-docs.test.mjs plus packages/mcp/test/mcp.test.mjs
resolveDocsLocation prefers the appDir corpus. In a temp dir, mkdirSync a full node_modules/@webjsdev/mcp/resources/references tree and assert the returned docsDir is that path, not the bundled or repo-root one.
- It falls back to the bundled snapshot when the appDir has no
@webjsdev/mcp, and to the repo-root skill when neither exists. Both cases already have a shape to copy in the current resolveDocsLocation test.
- A nonexistent
appDir does not throw and lands on the same rung an omitted appDir would.
- Dispatch level, in
mcp.test.mjs. Drive runMcpServer in-process the way that file already does, calling tools/call for docs with an explicit appDir pointing at a temp app whose corpus contains a sentinel string, and assert the sentinel comes back. Then call resources/read with no appDir and assert it reads the cwd corpus. This is the test that proves the two surfaces resolve from one function of appDir.
- Injected
docsDeps still wins. Assert that a runMcpServer given opts.docsDeps returns the injected corpus regardless of appDir, which is what keeps every pre-existing test in that file valid.
Counterfactual for PR 3. Move the rung-1 probe below the bundled branch and the "prefers the appDir corpus" test must fail.
PR 4, packages/mcp/test/mcp-docs.test.mjs
- The warning fires when the app version is strictly greater than the server version.
- Silent when the versions are equal.
- Silent when the app version is lower.
- Silent when the app
package.json is absent, unreadable, unparseable, or has no version.
compareVersions unit cases covering a prerelease suffix on either side, a two-segment version, and a non-numeric segment.
Counterfactual for PR 4. Flip the comparison to >= and the equal-versions silence test must fail.
Bun parity, judged rather than defaulted
No test/bun/* test is owed by any of these four PRs, and no Bun run is required. The reasoning, per the runtime-sensitive list in the root AGENTS.md:
- PR 1 changes a Node build script that runs only under
npm publish on the CI runner, which is actions/setup-node@v6 on Node 24 (release.yml:63-66) and never Bun. packages/mcp/package.json invokes it as node scripts/copy-mcp-resources.js, so the interpreter is pinned in the manifest.
- PRs 2 through 4 add
existsSync, readFile, JSON.parse, string formatting, and a numeric comparison inside a read-only stdio JSON-RPC server. None of the divergent surfaces is touched: no serializer, no node:http versus Bun.serve listener or request path, no SSR / action / CSRF dispatch, no streams, no node:crypto, no TypeScript stripper, no auth / session / cors.
- The incident involved a bun-global INSTALL, which is about where the server was installed, not which runtime executed the changed code.
bun x @webjsdev/mcp runs the same JavaScript through the same node:fs calls Bun implements, and nothing in these PRs branches on the runtime.
.claude/hooks/require-bun-parity-with-runtime-src.sh will not fire on any of these commits. Its first filter (:61) matches ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, and its second (:62) then requires a runtime keyword in the PATH. PR 1 stages packages/mcp/scripts/, which fails the first filter outright, and PRs 2 through 4 stage packages/mcp/src/mcp.js and packages/mcp/src/mcp-docs.js, neither of which matches any keyword in that list. If it fires anyway because the list has grown, re-run with WEBJS_BUN_VERIFIED=1 and put this paragraph's reasoning in the commit body.
Docs
Every surface below that describes the corpus, the bundling, or what init returns. Invoke the webjs-doc-sync skill and sync all of them in the PR that changes the behaviour they describe, not in a later cleanup pass.
| Surface |
What changes, and in which PR |
packages/mcp/AGENTS.md |
The scripts/ block of the module map (copy-mcp-resources.js now also emits the stamp) and invariant 3, "The docs bundle is transient", which should name corpus.json as part of that transient tree. PR 1. Then the mcp-docs.js line of the module map for the corpusPath return and the appDir rung. PRs 2 and 3. |
packages/mcp/README.md |
The closing paragraph, "The docs corpus is bundled into the package at prepack, so npx @webjsdev/mcp is self-contained", gains the stamp. PR 1. The "Knowledge layer" bullet under "What it exposes" gains the corpus line init now prints. PR 2. A short note that the corpus is version-matched to the app when the app has its own @webjsdev/mcp, plus the global-install staleness trap this issue documents. PR 3. The mismatch warning. PR 4. |
website/app/docs/ai-first/page.ts |
The knowledge-layer paragraph near :57 describes the init tool as "a read-first pointer with the mental model and invariants". Add that it also reports which docs corpus it is serving, and (PR 3) that the corpus prefers the app's own installed copy. PRs 2 and 3. |
Root AGENTS.md |
The "Framework source: where to find it" section names webjs mcp and enumerates its tools including "a knowledge layer (docs / recipes / framework source)". Extend that clause once the corpus is version-matched and self-identifying. PR 3. |
framework-dev.md |
Nothing is owed unless the release flow changes, which it does not. The prepack script keeps the same name and invocation. Confirm and say so in the PR description rather than editing the file. |
.agents/skills/webjs/** and packages/cli/templates/.agents/skills/webjs/** |
Nothing is owed. Neither the skill nor its references mention @webjsdev/mcp or webjs mcp (verified by grep at HEAD), and this work adds no app-authoring API. |
packages/cli/templates/.claude.json |
Nothing is owed. The scaffold's MCP server entry (npx @webjsdev/mcp) is unchanged. |
Acceptance criteria
PR 1, stamp the corpus
PR 2, surface the stamp
PR 3, prefer the app's corpus
PR 4, warn on a mismatch
Out of scope
Do not widen into any of the following. Each is a deliberate exclusion, not an oversight.
packages/server/src/component-elision.js. Do not change importsClientRouter() or clientEffectReason(). An explicit client-router import is a real client side effect and the root AGENTS.md:134 documents it as one. The bug is upstream.
- The
source tool and sourceDeps. It reads node_modules/@webjsdev/*/src and resolves its roots independently at mcp.js:529. It is not part of the docs corpus.
- A new
webjs-docs://corpus resource. The stamp is build metadata, not a doc. Keeping it out of catalogue() is a design decision, not an omission.
- Making
init hard-fail on a large version gap. Settled as warn-only in decision 7.
- Comparing
@webjsdev/core, server, cli, or ui versions. Settled as mcp-only in decision 7.
- An auto-update or self-install path. The MCP server is read-only (
packages/mcp/AGENTS.md invariant 2). It may report a stale install, but it must never mutate one.
- Any
npm pack, npm publish, or prepack invocation inside the test suite. Explained in the Tests section.
- A back-compat shim for the old
resolveDocsLocation(moduleUrl) one-argument shape. The second argument is optional, so existing callers keep working, and WebJs has no external consumers of this internal function to preserve beyond that.
- Changing the release or changelog flow.
prepack keeps its name and invocation, and both publish scripts already pass --ignore-scripts=false.
- Filing new issues. The later slices are PRs against this issue.
Problem
@webjsdev/mcpbundles a frozen snapshot of the docs corpus intoresources/atprepack, and the knowledge layer serves that snapshot with no version or staleness signal. A globally installed MCP server therefore keeps teaching pre-fix guidance forever, and the agent reading it has no way to notice.Hit while building a real app (a paid-workshop platform) against
@webjsdev/core@0.7.47/server@0.8.59/cli@0.10.51. The session's MCP server was a bun-global@webjsdev/mcp@0.1.4, whose bundledresources/AGENTS.mdstill said:That sentence was corrected in
dbaf3f43(feat: client router auto-enables from the bundle, drop layout imports (#628), 2026-06-19). The current text atAGENTS.md:134reads correctly:So the docs are already right. The defect is that a stale server keeps serving the pre-#628 text, and contradicts the very same file sitting in the app's own
node_modules/@webjsdev/mcp/resources/AGENTS.md, with nothing in theinitoutput to reveal the conflict.Blast radius, observed
Following the stale
initoutput, the agent wroteimport '@webjsdev/core/client-router';into three layouts (app/(site)/layout.ts,app/admin/layout.ts,app/portal/layout.ts). That import is not merely dead:importsClientRouter()atpackages/server/src/component-elision.js:419treats it as a genuine client side effect. Its caller at:999puts the file inclientRouterFiles, so the reason lookup at:1167returns'imports the client router'and the layout ships whole.windowmarker survived a/to/faqlink click, proving a soft nav), andwebjs doctor's reason for that layout changed fromships whole because it imports the client routerto the next blocker.So stale guidance produced dead code that silently defeated elision. That is the cost worth fixing, not the wording.
Corrections to the original statement of this issue
Every claim above was re-verified at
e5806e24. Five anchors in the first draft were stale or named something that does not exist, and are corrected here so an implementer does not chase them:importsClientRouter()is at:419, not:408. Its JSDoc block opens at:412; thefunctionline is:419.elisionReason(). The reason string lives inclientEffectReason(), an inner arrow declared atpackages/server/src/component-elision.js:1165inside the exportedanalyzeElision()(:918), and the'imports the client router'branch is:1167. The draft citedelisionReason()at:1143.resources/AGENTS.mdis a GENERATED copy that exists only inside a published tarball, soresources/AGENTS.md:111andresources/AGENTS.md:133name lines in an artifact no checkout contains. Both were verified against the SOURCE thatcopy-mcp-resources.jsreads, the repo-rootAGENTS.md:git show 8a560962:AGENTS.md | sed -n '111p'(thechore: release ... mcp 0.1.4 ...commit, 2026-06-18) carries the stale sentence verbatim, andAGENTS.md:134at HEAD carries the corrected one. The0.1.4release predates thedbaf3f43fix by one day, which is exactly how that tarball froze the wrong text.packages/mcp/resources/does not exist in the checkout at all. The draft said it was empty.git ls-files packages/mcp/resourcesis empty ANDls packages/mcp/resourcesreports no such directory, becausepostpackremoves the whole tree (clean-mcp-resources.js:27callsrmSync(destRoot, { recursive: true, force: true })).resolveDocsLocation()spanspackages/mcp/src/mcp-docs.js:44through:63. The draft citedL45-55in one place andL36-60in another. The othermcp.jsanchors have all moved too and are restated in the plan below.Ground truth re-verified at HEAD, unchanged from the draft:
packages/mcp/package.jsonis version0.1.12, itsscriptsare exactlyprepack: node scripts/copy-mcp-resources.jsandpostpack: node scripts/clean-mcp-resources.js, and itsfilesarray is["bin", "src", "README.md", "resources"].Design / approach
The original draft listed four moves "in rough order of value". That ordering is not a plan, so each is settled here as a decision with its alternatives rejected.
Decision 1. The stamp is a single JSON file at
resources/corpus.jsonRejected, a plain
resources/VERSIONtext file. It forces every reader to invent a parse format for three fields, and the format breaks the moment a fourth is wanted. There is no ecosystem convention for the contents of a bareVERSIONfile, so two readers can disagree about it.Rejected, front matter injected into each copied doc. It mutates the served doc text so it no longer matches its repo source, which gives up the "source stays single" property that
copy-mcp-resources.js:9-10andpackages/mcp/AGENTS.mdinvariant 3 both call out. It also shifts every line number in every doc relative to the repo copy, multiplies the write surface by the file count, and still leaves no single place to read the answer.Chosen, one JSON object.
JSON.parseis the parser, so no format is invented. The repo already has the precedent:buildInfo()atpackages/server/src/build-info.js:42composes a small JSON object identifying a running build for exactly this "which version is live" question, and this is its pack-time analogue. Prior art outside the repo is npm's owngitHeadfield, which records the publishing commit into published package metadata (verified present in this repo's installed tree, innode_modules/typescript/package.jsonandnode_modules/parse5/package.json), so pack-time capture of a git SHA is an established practice rather than a novelty.The filename is lowercase
corpus.json, placed atresources/corpus.jsonbesideAGENTS.mdandSKILL.md, never insideresources/references/. Two reasons. First,catalogue()atpackages/mcp/src/mcp-docs.js:101lists onlydocsDirentries matching/\.md$/i(:112), anddocsDirisresources/references, so a.jsonfile one level up is invisible to the catalogue twice over and can never appear as awebjs-docs://corpusresource. Second, a doc-shaped name such asCORPUS.mdinsidereferences/WOULD be catalogued and would surface to agents as a readable doc, which is not what a build stamp is.Decision 2. The stamp carries package, version, SHA, and copy timestamp
{ "package": "@webjsdev/mcp", "version": "0.1.12", "sha": "e5806e2400000000000000000000000000000000", "copiedAt": "2026-08-08T09:14:22.031Z" }versionis what a later slice compares to decide whether a server is behind.shais what makes the answer actionable, because a version alone cannot tell an agent WHICH docs it is holding, while a SHA resolves directly to a GitHub diff.copiedAtis wanted, not merely tolerated: the incident was a server months out of date, and a human-readable date is the most legible staleness cue for an agent that cannot resolve a SHA and may not know what the current version is. Nothing in this repo verifies tarball bit-identity, so there is no reproducible-build rule for a timestamp to violate, andprepackinside a publish is a one-shot event by construction.packagemakes the object self-describing when an agent encounters it out of context, at a cost of one line.shais the full 40-character hex string, not an abbreviation, because an abbreviated SHA is ambiguous by definition and the consumer can always shorten it for display.Rejected, a git branch name or committer. Neither has a consumer, and a branch name is not stable in a squash-merge repo.
Rejected, a Node version. The corpus is markdown text, so the copier's runtime says nothing about it.
Rejected, a clean-tree guard that nulls the SHA when the working tree is dirty (npm's historical
gitHeadbehaviour). It would misfire on every real release here, becauserelease.yml:206-217deliberately rewritespackage.jsonin the runner's working tree before publishing, and becauseprepackitself writesresources/and so dirties the tree before anything could check. A SHA naming the commit the docs came from is what the consumer needs, and the residual imprecision of a localnpm packfrom a dirty tree does not justify a false negative on every release. This paragraph exists so a reviewer does not "fix" the omission.Decision 3. No git means
sha: null, and the script never failsprepackcan run outside a git checkout, for instance when a consumer runsnpm packinside an extracted tarball or a Docker build with no.git. Thegitbinary may also be absent entirely.Chosen,
spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }), treating a throw, a non-zerostatus, or stdout that does not match/^[0-9a-f]{40}$/after trimming as absent, and writing"sha": nullin that case.spawnSyncrather thanexecSyncbecause a missing binary must be a returned error object, not a thrown one.The justification is that this script's job is copying markdown. Failing a publish because git was unavailable would convert a diagnostic nicety into a release blocker, and fail-soft is already this module family's posture:
resolveDocsLocation's docblock (mcp-docs.js:38-39) states that either path may not exist and callers fail soft,initTextswallows a failedagentsPathread to''at:193, andframeworkVersion()swallows its read failure to''atbuild-info.js:28.Rejected, a
WEBJS_CORPUS_SHAenvironment override for CI. Both publish paths (scripts/publish-npm.js:85andscripts/publish-github-packages.js:148) runnpm publish --workspace=... --ignore-scripts=falsefrom theactions/checkout@v6working tree (release.yml:55-57), so git is present in every real release and the override would have no caller. Thefetch-depth: 2shallow clone is not a problem, becausegit rev-parse HEADresolves from the checked-out ref and needs no history.Decision 4. PR 1 writes the stamp, PR 2 reads it
The stamping slice does not also surface the stamp in
init, because surfacing is not one line.initText(deps)atmcp-docs.js:191is pure over its injected deps, anddepstoday is{ docsDir, agentsPath, skillPath, listDir, exists, readFile }. Printing the stamp needs a newcorpusPathdep produced byresolveDocsLocationand threaded through thedocsDepsconstruction atmcp.js:505-518, a read plus parse plus a fallback line for the dev path that has no stamp, updatedinitTexttests, and updates to the doc surfaces that describe whatinitreturns. That is a change to the tool's output contract, which is exactly what "purely additive and cannot regress anything" was meant to exclude. PR 1 therefore touches a build script, its test, and package-level docs only.Decision 5. The app's corpus wins over the server's bundled one
Priority chain in
resolveDocsLocation, highest first:<appDir>/node_modules/@webjsdev/mcp/resources/referenceswhen that directory exists.<pkgRoot>/resources/references, the running server's own bundled snapshot.<repoRoot>/.agents/skills/webjs/references, the dev path.Rung 1 is new and is the actual fix. It wins because the app's copy is version-matched to the framework the agent is editing, and that is the only corpus that can be correct about that app. Rungs 2 and 3 keep their current order and behaviour unchanged.
The probe is a plain
existsSyncon the directory, notrequire.resolve('@webjsdev/mcp')fromappDir. Resolving the package would go through itsexportsmap, and in this monoreponode_modules/@webjsdev/mcpis a workspace symlink back topackages/mcp, whoseresources/does not exist, so the resolve would land on the dev path anyway. The honest statement of what this needs is "a directory of markdown at a known path", which is whatexistsSyncasks. When the running server IS the app's installed copy, rung 1 finds the same directory rung 2 would have found, which is a correct no-op.Decision 6. The docs location is resolved per call, memoized by
appDirdocsDepsis built once at server startup (mcp.js:505), before any tool call, whileappDiris per call (mcp.js:620). A boot-time resolution would pin the corpus to the launch directory forever, which defeats decision 5 for any host that passes an explicitappDir.Chosen, replace the single
docsDepsobject with adocsDepsFor(appDir)factory backed by aMapkeyed on theappDirstring. Whenopts.docsDepsis injected, the factory returns that injected object for everyappDir, so every existing test keeps passing unchanged and the injection seam the tests rely on is preserved exactly.resources/list(mcp.js:584) andresources/read(mcp.js:589) carry noappDirin the MCP protocol, so they calldocsDepsFor(cwd), which is the same valuetools/calldefaults to when noappDirargument is given. In the ordinary case, where the host launches the server in the project directory,init,docs, and everywebjs-docs://*resource read one corpus. They can diverge only when a tool call explicitly overridesappDirto a different project, which is the caller deliberately asking about another app. That is the defined behaviour, stated rather than pretended away, and it answers the "two surfaces disagree, which is worse than the current failure" landmine directly: the corpus is a pure function ofappDir, so the surfaces agree wheneverappDirdoes.initkeeps answering with no app present, because a nonexistentappDirsimply fails theexistsSyncprobe and falls through to rung 2. No hard dependency onappDirresolving is introduced.Decision 7. The mismatch warning compares
@webjsdev/mcponly, and warns rather than failsThe draft proposed comparing "the resolved
@webjsdev/*versions inappDir". That is corrected to comparing@webjsdev/mcpalone. The corpus ships inside the mcp package, so mcp's version is the only version that bounds the corpus. A newer@webjsdev/corein the app says nothing about whether the DOCS are stale, and reading fourpackage.jsonfiles to produce one warning multiplies the failure modes for no added signal.The comparison is a dependency-free numeric compare of the
major.minor.patchtriple with any prerelease suffix stripped before comparing. It warns only when the app's version is strictly greater than the running server's. Equal or lower is silent, because a globally installed NEWER server reading an older app's docs is the normal monorepo-dev shape and is not a defect.Warn, never fail. Settled on the ground that a read-only knowledge tool refusing to answer is strictly worse than one answering with a caveat, since the agent's alternative to a caveated answer is its training data, which is the thing the caveat exists to correct. The warning is advisory by the time it fires anyway, because decision 5 has already pointed the server at the app's corpus, so the text below the warning is already the right text. The warning's real job is telling the human to update the global install so the TOOLS match, not just the docs.
The warning is prepended to the
inittool RESULT text, which reaches the client through the content array atmcp.js:634. It is never aconsole.log, perpackages/mcp/AGENTS.mdinvariant 1 (stdout is the JSON-RPC channel).Decision 8.
importsClientRouter()is not touchedAn explicit
client-routerimport IS a real client side effect, andAGENTS.md:134documents it as one of the three things that still ship a page or layout whole. The bug is upstream of the analyser, in what the agent was told to write. Do not "fix"packages/server/src/component-elision.js.Implementation plan
Sequencing. This issue ships as four PRs against THIS issue, in order, and the issue stays OPEN until the staleness signal actually reaches the agent (the end of PR 4). PR 1 is the stamping slice and lands first, on its own, because it only adds a generated file to a tarball and changes no served byte, so it cannot regress anything. PR 2 surfaces the stamp in
init. PR 3 flips the corpus priority so the app's copy wins, which is the actual fix. PR 4 adds the version-mismatch warning. Do NOT open new issues for the later slices, which are sequenced PRs against #1319.No follow-up issues. Anything this work turns up is fixed inside the same PR that turned it up, not filed. The only exception is the sequencing above, which is PRs against this issue rather than new issues. If a finding is genuinely out of reach of the PR that found it, report it to the user as a note in that PR's description. Do not file it.
Each PR cuts its own worktree from
origin/mainper the repo's one-task-per-worktree rule, and runsnpm run worktree:linkinside it before running any test.PR 1. Stamp the corpus at
prepackpackages/mcp/scripts/copy-mcp-resources.js, add areadGitSha(cwd)helper abovebundleDocs(currently at:32). ImportspawnSyncfromnode:child_process. Return the trimmed stdout whenstatus === 0and it matches/^[0-9a-f]{40}$/, otherwisenull. Wrap the call intry { ... } catch { return null; }so an absentgitbinary returnsnullrather than throwing.bundleDocs({ srcRefs, srcAgents, srcSkill, destRoot, stamp })with a fifthstampproperty. After the twocopyFileSynccalls at:37-38, writewriteFileSync(join(destRoot, 'corpus.json'), JSON.stringify(stamp, null, 2) + '\n')whenstampis provided, and skip the write when it is not. Keepingstampoptional preserves the function's purity over its arguments, which is what makes the existing temp-dir test atpackages/mcp/test/mcp-docs.test.mjs:165work, and lets the new test drive both branches.writeFileSyncfromnode:fsin that file's existing import at:19. The current import list iscpSync, mkdirSync, copyFileSync, rmSync.main()(:42), build the stamp from the package's ownpackage.json(readversionviareadFileSync+JSON.parseonjoin(pkgRoot, 'package.json'), matching the patternbin/webjs-mcp.jsalready uses) plusreadGitSha(repoRoot)plusnew Date().toISOString(), and pass it tobundleDocsalongside the existingdestRoot: join(pkgRoot, 'resources')at:51.:54to name the corpus identity, so a release log records what was stamped. Keep it on stderr, since:53documents why (a tool parsingnpm pack --jsonstdout must not see it).packages/mcp/scripts/clean-mcp-resources.jsneeds NO change.cleanBundleat:26removesdestRootrecursively, so the stamp goes with the tree. Verify this in the test rather than assuming it, and add a one-line comment at:27noting the stamp is covered by the recursive removal so a future reader does not add a redundant unlink.packages/mcp/package.jsonneeds no change. Itsfilesarray already containsresources, which carries every file inside the directory including the new one. State this in the PR description rather than editing the manifest.PR 2. Surface the stamp in
initpackages/mcp/src/mcp-docs.js, extendresolveDocsLocation(:44-63) to return a fourth keycorpusPath. On the bundled branch (:51-55) it isjoin(pkgRoot, 'resources', 'corpus.json'). On the repo-root dev branch (:58-62) it isnull, because a dev checkout has no stamp and inventing one would make dev claim a provenance it does not have.docsDepsconstruction atpackages/mcp/src/mcp.js:505-518, addingcorpusPath: loc.corpusPathbeside the existingdocsDir/agentsPath/skillPathkeys.initText(deps)(:191), read and parsedeps.corpusPathinside a single try/catch, immediately after theagentsread at:193. Emit exactly one line, placed between the# webjs: read firsttitle and therouterblock in thepartsarray at:217-229:Docs corpus: @webjsdev/mcp@<version>, copied from webjsdev/webjs <sha7> on <YYYY-MM-DD>.corpusPathis null,Docs corpus: the live repo-root docs in this checkout, not a bundled snapshot.corpusPathis set but the read or parse failed,Docs corpus: an unstamped @webjsdev/mcp bundled snapshot.sha: null, drop thecopied fromclause and keep the version and date.:193.Landmine for this PR.
test/knowledge/knowledge-coverage.test.jsderives the AGENTS.md headings the primer sources from thesectionByHeading(agents, /.../)calls inmcp-docs.js(framework-dev.md:130), so a heading rename cannot silently empty the primer. Adding a corpus line adds nosectionByHeadingcall and so does not touch that derivation, but do not introduce one while editinginitText, or that test will start requiring a matching AGENTS.md heading.PR 3. Prefer the app's corpus
resolveDocsLocation(moduleUrl)(mcp-docs.js:44) toresolveDocsLocation(moduleUrl, appDir), withappDiroptional so every existing caller keeps working. Insert the rung-1 probe BEFORE the bundled branch at:49: whenappDiris a non-empty string andexistsSync(join(appDir, 'node_modules', '@webjsdev', 'mcp', 'resources', 'references')), return thatreferencesdir asdocsDirwith siblingAGENTS.md,SKILL.md, andcorpus.jsonpaths.packages/mcp/src/mcp.js, replace the singledocsDepsbinding built at:505-518with adocsDepsFor(appDir)closure over aMap. Whenopts.docsDepswas injected, return it for everyappDirunconditionally.resources/listat:584andresources/readat:589both usedocsDepsFor(cwd). Thetools/calldispatch at:622-627usesdocsDepsFor(appDir)for both theinitTextcall at:624and thesearchDocscall at:626, whereappDiris already computed at:620.runSourceToolandsourceDepsalone. Thesourcetool readsnode_modules/@webjsdev/*/srcand already resolves its roots fromcwdat:529; it is not part of the docs corpus and is out of scope.PR 4. Warn on a version mismatch
readAppMcpVersion(appDir, { exists, readFile })helper inmcp-docs.jsthat reads<appDir>/node_modules/@webjsdev/mcp/package.json, parses it, and returns itsversionstring, returningnullon any absence, read failure, parse failure, or missing field.compareVersions(a, b)helper that strips any-prereleasesuffix, splits on., coerces each of the three segments withNumber, treats aNaNsegment as0, and returns-1/0/1. Dependency-free, consistent with the package's zero-dependency posture stated inpackages/mcp/AGENTS.md.initTextgains the server's own version.runMcpServeralready has it atmcp.js:479, so pass it intodocsDepsForand put it on the deps object asserverVersion, alongside a newappDirkey so the helper knows where to look.compareVersions(appVersion, serverVersion) > 0, prepend one line ABOVE the corpus line from PR 2:Warning: this MCP server is @webjsdev/mcp@<server>, but this app has @webjsdev/mcp@<app>. The server's own docs and tools may be stale. Update the global install with npm i -g @webjsdev/mcp@latest, or bun add -g @webjsdev/mcp.package.json.Tests
Run
npm testfrom the repo root for each PR.npm testdoes not run browser, e2e, or Bun. None of those layers apply here (see the Bun note below), so report the Node suite result and say explicitly that the other layers are not applicable and why.PR 1,
packages/mcp/test/mcp-docs.test.mjsThe file already imports
bundleDocsandcleanBundlefrom the scripts at:27-28and already drives them against temp dirs in the test at:165, so the new cases follow that exact shape and naming.bundleDocswrites a parseable stamp. Build a tempsrcRefs/srcAgents/srcSkillfixture as the existing test does, callbundleDocs({ ..., stamp: { package: '@webjsdev/mcp', version: '9.9.9', sha: 'a'.repeat(40), copiedAt: '2026-08-08T00:00:00.000Z' } }), then assertresources/corpus.jsonexists indestRoot, thatJSON.parseof its contents deep-equals the stamp, and that the four fields have the expected types.bundleDocsomits the stamp when none is passed. Assertcorpus.jsondoes not exist indestRoot. This is the branch every existing caller of the two-argument form takes, and it proves the addition is opt-in.cleanBundleremoves the stamp with the tree. Bundle with a stamp, callcleanBundle(destRoot), assert neitherdestRootnor the stamp path exists.readGitShareturns null outside a git checkout. Call it with a freshly created temp dir ascwdand assertnull. This proves decision 3 without needing a machine that lacksgit.filesallowlist, asserted without packing. Readpackages/mcp/package.json, assert itsfilesarray containsresources, and assert in the same test that the pathbundleDocswrites isjoin(destRoot, 'corpus.json')wheremain()setsdestRoottojoin(pkgRoot, 'resources'). Those two facts together prove the tarball carries the stamp.npm packtest. Packing runsprepack, which WRITES into the realpackages/mcp/resources/in a repo several agents share, andpostpackdoes not run when a pack fails, so a failed run leaves a stale bundle behind.clean-mcp-resources.js:4-8documents that exact hazard: a lingeringresources/shadows the live repo-root skill in dev and in every in-repo app that resolves through the workspace symlink.Counterfactual for PR 1. Delete the
writeFileSynccall frombundleDocsand the "writes a parseable stamp" test must fail on theexistsSyncassertion. Delete the/^[0-9a-f]{40}$/guard fromreadGitShaand the outside-a-checkout test must fail, becausegit rev-parse HEADin a temp dir under/tmpeither errors or prints a message that is not a 40-hex SHA.PR 2,
packages/mcp/test/mcp-docs.test.mjsEvery case drives
initTextwith the in-memoryfixture()helper at:33, extended with acorpusPathplus a matching entry in itsfilesmap.corpusPath: null, the output contains the repo-root wording and no version string.corpusPathset but the file is absent (the fixturereadFilethrowsENOENT), the output contains the unstamped wording.corpusPathset but the file content is not valid JSON, the output contains the unstamped wording andinitTextdoes not reject.sha: null, the output contains the version and date and nocopied fromclause.resolveDocsLocationreturns a non-nullcorpusPathon the bundled branch andnullon the repo-root branch, driven the way the existingresolveDocsLocationtest drives it.Counterfactual for PR 2. Remove the corpus line from the
partsarray ininitTextand the stamped-output assertion must fail.PR 3,
packages/mcp/test/mcp-docs.test.mjspluspackages/mcp/test/mcp.test.mjsresolveDocsLocationprefers the appDir corpus. In a temp dir,mkdirSynca fullnode_modules/@webjsdev/mcp/resources/referencestree and assert the returneddocsDiris that path, not the bundled or repo-root one.@webjsdev/mcp, and to the repo-root skill when neither exists. Both cases already have a shape to copy in the currentresolveDocsLocationtest.appDirdoes not throw and lands on the same rung an omittedappDirwould.mcp.test.mjs. DriverunMcpServerin-process the way that file already does, callingtools/callfordocswith an explicitappDirpointing at a temp app whose corpus contains a sentinel string, and assert the sentinel comes back. Then callresources/readwith noappDirand assert it reads thecwdcorpus. This is the test that proves the two surfaces resolve from one function ofappDir.docsDepsstill wins. Assert that arunMcpServergivenopts.docsDepsreturns the injected corpus regardless ofappDir, which is what keeps every pre-existing test in that file valid.Counterfactual for PR 3. Move the rung-1 probe below the bundled branch and the "prefers the appDir corpus" test must fail.
PR 4,
packages/mcp/test/mcp-docs.test.mjspackage.jsonis absent, unreadable, unparseable, or has noversion.compareVersionsunit cases covering a prerelease suffix on either side, a two-segment version, and a non-numeric segment.Counterfactual for PR 4. Flip the comparison to
>=and the equal-versions silence test must fail.Bun parity, judged rather than defaulted
No
test/bun/*test is owed by any of these four PRs, and no Bun run is required. The reasoning, per the runtime-sensitive list in the rootAGENTS.md:npm publishon the CI runner, which isactions/setup-node@v6on Node 24 (release.yml:63-66) and never Bun.packages/mcp/package.jsoninvokes it asnode scripts/copy-mcp-resources.js, so the interpreter is pinned in the manifest.existsSync,readFile,JSON.parse, string formatting, and a numeric comparison inside a read-only stdio JSON-RPC server. None of the divergent surfaces is touched: no serializer, nonode:httpversusBun.servelistener or request path, no SSR / action / CSRF dispatch, no streams, nonode:crypto, no TypeScript stripper, no auth / session / cors.bun x @webjsdev/mcpruns the same JavaScript through the samenode:fscalls Bun implements, and nothing in these PRs branches on the runtime..claude/hooks/require-bun-parity-with-runtime-src.shwill not fire on any of these commits. Its first filter (:61) matches^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, and its second (:62) then requires a runtime keyword in the PATH. PR 1 stagespackages/mcp/scripts/, which fails the first filter outright, and PRs 2 through 4 stagepackages/mcp/src/mcp.jsandpackages/mcp/src/mcp-docs.js, neither of which matches any keyword in that list. If it fires anyway because the list has grown, re-run withWEBJS_BUN_VERIFIED=1and put this paragraph's reasoning in the commit body.Docs
Every surface below that describes the corpus, the bundling, or what
initreturns. Invoke thewebjs-doc-syncskill and sync all of them in the PR that changes the behaviour they describe, not in a later cleanup pass.packages/mcp/AGENTS.mdscripts/block of the module map (copy-mcp-resources.jsnow also emits the stamp) and invariant 3, "The docs bundle is transient", which should namecorpus.jsonas part of that transient tree. PR 1. Then themcp-docs.jsline of the module map for thecorpusPathreturn and the appDir rung. PRs 2 and 3.packages/mcp/README.mdprepack, sonpx @webjsdev/mcpis self-contained", gains the stamp. PR 1. The "Knowledge layer" bullet under "What it exposes" gains the corpus lineinitnow prints. PR 2. A short note that the corpus is version-matched to the app when the app has its own@webjsdev/mcp, plus the global-install staleness trap this issue documents. PR 3. The mismatch warning. PR 4.website/app/docs/ai-first/page.ts:57describes theinittool as "a read-first pointer with the mental model and invariants". Add that it also reports which docs corpus it is serving, and (PR 3) that the corpus prefers the app's own installed copy. PRs 2 and 3.AGENTS.mdwebjs mcpand enumerates its tools including "a knowledge layer (docs / recipes / framework source)". Extend that clause once the corpus is version-matched and self-identifying. PR 3.framework-dev.mdprepackscript keeps the same name and invocation. Confirm and say so in the PR description rather than editing the file..agents/skills/webjs/**andpackages/cli/templates/.agents/skills/webjs/**@webjsdev/mcporwebjs mcp(verified by grep at HEAD), and this work adds no app-authoring API.packages/cli/templates/.claude.jsonnpx @webjsdev/mcp) is unchanged.Acceptance criteria
PR 1, stamp the corpus
bundleDocsaccepts an optionalstampand writes<destRoot>/corpus.jsonwhen given onepackage,version,sha, andcopiedAt, withversionread frompackages/mcp/package.jsonandshathe full 40-charactergit rev-parse HEADshaisnulland the script exits 0 when git is unavailable or the directory is not a checkoutcleanBundleremoves the stamp along with the rest ofresources/filescoverage is asserted by inspection, with nonpm packrun anywhere in the suitepackages/mcp/AGENTS.mdandpackages/mcp/README.mdupdatedPR 2, surface the stamp
resolveDocsLocationreturnscorpusPath, non-null on the bundled branch andnullon the repo-root branchinitreports which corpus version it is serving, on one lineinitstill answers with noappDirand in a dev checkout with no bundlewebsite/app/docs/ai-first/page.tsandpackages/mcp/README.mdupdatedPR 3, prefer the app's corpus
appDircontainingnode_modules/@webjsdev/mcp/resources/,init,docs, andwebjs-docs://*all read that corpus, not the server's bundled snapshotresources/listandresources/readresolve from the same corpus as a no-appDirtools/call, never a different oneappDirand an injectedopts.docsDepsstill wins for everyappDir@webjsdev/mcpAGENTS.md,packages/mcp/AGENTS.md, andpackages/mcp/README.mdupdatedPR 4, warn on a mismatch
initwarns when the app's@webjsdev/mcpis strictly newer than the running server'sconsole.loginitnever fails or refuses to answer because of a mismatchpackages/mcp/README.mdupdatedOut of scope
Do not widen into any of the following. Each is a deliberate exclusion, not an oversight.
packages/server/src/component-elision.js. Do not changeimportsClientRouter()orclientEffectReason(). An explicitclient-routerimport is a real client side effect and the rootAGENTS.md:134documents it as one. The bug is upstream.sourcetool andsourceDeps. It readsnode_modules/@webjsdev/*/srcand resolves its roots independently atmcp.js:529. It is not part of the docs corpus.webjs-docs://corpusresource. The stamp is build metadata, not a doc. Keeping it out ofcatalogue()is a design decision, not an omission.inithard-fail on a large version gap. Settled as warn-only in decision 7.@webjsdev/core,server,cli, oruiversions. Settled as mcp-only in decision 7.packages/mcp/AGENTS.mdinvariant 2). It may report a stale install, but it must never mutate one.npm pack,npm publish, orprepackinvocation inside the test suite. Explained in the Tests section.resolveDocsLocation(moduleUrl)one-argument shape. The second argument is optional, so existing callers keep working, and WebJs has no external consumers of this internal function to preserve beyond that.prepackkeeps its name and invocation, and both publish scripts already pass--ignore-scripts=false.