Skip to content

dogfood: mcp init serves a frozen docs snapshot, so a stale global server teaches reverted guidance #1319

Description

@vivek7405

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:

  1. importsClientRouter() is at :419, not :408. Its JSDoc block opens at :412; the function line is :419.
  2. 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.
  3. 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.
  4. 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 })).
  5. 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:

  1. <appDir>/node_modules/@webjsdev/mcp/resources/references when that directory exists.
  2. <pkgRoot>/resources/references, the running server's own bundled snapshot.
  3. 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

  1. 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.
  2. 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.
  3. Import writeFileSync from node:fs in that file's existing import at :19. The current import list is cpSync, mkdirSync, copyFileSync, rmSync.
  4. 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.
  5. 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).
  6. 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.
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  • bundleDocs accepts an optional stamp and writes <destRoot>/corpus.json when given one
  • The stamp carries package, version, sha, and copiedAt, with version read from packages/mcp/package.json and sha the full 40-character git rev-parse HEAD
  • sha is null and the script exits 0 when git is unavailable or the directory is not a checkout
  • cleanBundle removes the stamp along with the rest of resources/
  • Tests cover the write branch, the no-stamp branch, the clean, and the git-absent fallback, with the counterfactual proving the write assertion actually fires
  • The files coverage is asserted by inspection, with no npm pack run anywhere in the suite
  • packages/mcp/AGENTS.md and packages/mcp/README.md updated

PR 2, surface the stamp

  • resolveDocsLocation returns corpusPath, non-null on the bundled branch and null on the repo-root branch
  • init reports which corpus version it is serving, on one line
  • A missing, unreadable, or malformed stamp degrades to an unstamped line and never throws
  • init still answers with no appDir and in a dev checkout with no bundle
  • website/app/docs/ai-first/page.ts and packages/mcp/README.md updated

PR 3, prefer the app's corpus

  • With an appDir containing node_modules/@webjsdev/mcp/resources/, init, docs, and webjs-docs://* all read that corpus, not the server's bundled snapshot
  • resources/list and resources/read resolve from the same corpus as a no-appDir tools/call, never a different one
  • The resolution is memoized per appDir and an injected opts.docsDeps still wins for every appDir
  • Falls back to the bundled snapshot, then the repo-root skill, when the app has no installed @webjsdev/mcp
  • A counterfactual proves the priority order test actually fires
  • Root AGENTS.md, packages/mcp/AGENTS.md, and packages/mcp/README.md updated

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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions