@webjsdev/mcp 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,9 +34,11 @@ subcommand) delegates to this same server, so both routes run identical code.
34
34
  no verdict at all; identical to `webjs elision --json`),
35
35
  `check` (the structured `webjs check` violations). Each projects an existing
36
36
  `@webjsdev/server` data function and mutates nothing.
37
- - **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
38
- MCP `resources` (the skill references + `SKILL.md` + `AGENTS.md` as `webjs-docs://*`),
39
- and `prompts` (the recipes as guided workflows).
37
+ - **Knowledge layer**: an `init` mental-model primer (which also names the docs
38
+ corpus it is serving, and warns when this server's copy looks stale against
39
+ the app's), a `docs` retrieval tool, MCP `resources` (the skill references +
40
+ `SKILL.md` + `AGENTS.md` as `webjs-docs://*`), and `prompts` (the recipes as
41
+ guided workflows).
40
42
  - **`source` tool**: reads the framework's own no-build source from
41
43
  `node_modules/@webjsdev/*/src` (read-only, traversal-guarded).
42
44
  - **`ui` tool**: the `@webjsdev/ui` kit inventory (no args) or one component's
@@ -44,10 +46,65 @@ subcommand) delegates to this same server, so both routes run identical code.
44
46
  `name`). Kit-scoped (not `appDir`-scoped); shares one projector with
45
47
  `webjsui view`.
46
48
 
47
- The docs corpus is bundled into the package at `prepack`, so `npx @webjsdev/mcp`
48
- is self-contained; in the monorepo it falls back to the live repo-root docs.
49
+ ## Which docs you get
49
50
 
50
- That bundle is a snapshot frozen at publish time, so a published tarball keeps
51
+ The corpus is resolved in three rungs, highest first:
52
+
53
+ 1. `<appDir>/node_modules/@webjsdev/mcp/resources`, the app's own installed copy.
54
+ 2. `<pkg>/resources`, this server's bundled snapshot, so `npx @webjsdev/mcp` is
55
+ self-contained.
56
+ 3. Whatever the server resolves locally, which is the repo-root skill on the
57
+ monorepo dev path. This rung is an unconditional fallback rather than a
58
+ checkout probe, so a published install whose `resources/` bundle is missing
59
+ lands here too, with an empty corpus. That is why neither the corpus line nor
60
+ the warning asserts a checkout.
61
+
62
+ The app's copy wins because it is version-matched to the framework you are
63
+ editing, and so is the only corpus that can be correct about that app. This
64
+ matters most for a GLOBAL install (`npm i -g` / `bun add -g`), which otherwise
65
+ keeps serving the docs it was published with forever, contradicting the copy
66
+ sitting in your own `node_modules`. That is a real incident, not a hypothetical:
67
+ a server published one day before the client router stopped needing an explicit
68
+ `import '@webjsdev/core/client-router'` kept teaching that import for months, and
69
+ an agent following it wrote dead imports into three layouts, each of which the
70
+ elision analyser then correctly refused to strip.
71
+
72
+ `appDir` is a per-call argument, so the corpus follows it. `resources/list` and
73
+ `resources/read` carry no `appDir` in the MCP protocol and resolve from the
74
+ server's working directory, which is what a `tools/call` with no `appDir`
75
+ defaults to, so in the ordinary case every surface reads one corpus.
76
+
77
+ `init` names the corpus it served on its first line, and prepends a warning when
78
+ the app's installed `@webjsdev/mcp` is strictly newer than the running server's:
79
+
80
+ ```
81
+ Warning: this MCP server is @webjsdev/mcp@0.1.4, but this app has @webjsdev/mcp@0.1.12,
82
+ so the server may be stale. The docs below come from this app's own copy, so they
83
+ match it; update the server so its TOOLS match too. Update whichever copy runs this
84
+ server: a global install (npm i -g @webjsdev/mcp@latest, or bun add -g @webjsdev/mcp),
85
+ the package cache behind npx @webjsdev/mcp, @webjsdev/cli when the server is started
86
+ as webjs mcp, or the checkout it runs from.
87
+ Docs corpus: @webjsdev/mcp@0.1.12, copied from webjsdev/webjs e5806e2 on 2026-08-08.
88
+ ```
89
+
90
+ It warns rather than refusing to answer, because a knowledge tool that declines
91
+ sends the agent back to its training data, which is what the caveat exists to
92
+ correct. Equal versions, an older app, and an unreadable app manifest are all
93
+ silent.
94
+
95
+ Two details the wording is deliberate about. It does not name a global install as
96
+ the thing to fix, because nothing observes how the server was started and the two
97
+ shipped configurations are an `npx` invocation (whose staleness is a package
98
+ cache) and `webjs mcp` (whose staleness is the CLI's own dependency). And it does
99
+ not promise the docs below came from the app: the warning reads the app's
100
+ `package.json` while the corpus rung probes that install's `resources/`, so a
101
+ workspace-linked install can have a manifest to compare and no corpus to serve.
102
+ In that case the corpus falls through to the server's snapshot and the warning
103
+ says so, rather than contradicting the corpus line printed under it.
104
+
105
+ ## The build stamp
106
+
107
+ The bundle is a snapshot frozen at publish time, so a published tarball keeps
51
108
  serving the docs as they read on the day it was cut. `prepack` stamps it with
52
109
  `resources/corpus.json` so the snapshot can say which docs it holds:
53
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/mcp",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "The Model Context Protocol server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Gives AI coding agents live app introspection over routes, actions, components, and convention checks, plus a knowledge layer of docs, recipes, and framework source.",
6
6
  "bin": {
@@ -33,7 +33,7 @@ itself): commands, repo-health git config, changelog flow, dev error overlay.
33
33
 
34
34
  ## AI-driven development: guardrails for all agents
35
35
 
36
- **WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI, `.github/copilot-instructions.md` for Copilot in VS Code), never a duplicated rule set. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`).
36
+ **WebJs is AI-first. These rules apply to ALL agents (Claude, Cursor, Copilot, Antigravity, Gemini, opencode) through a SINGLE cross-agent source the scaffold ships**: `AGENTS.md` (the open standard Cursor / opencode / Antigravity / the Copilot coding agent read natively) plus the skill at `.agents/skills/webjs/` and the workflow rules at `.agents/rules/workflow.md`. Tools that do not read `AGENTS.md` natively get a THIN bridge pointing at it (`CLAUDE.md` for Claude Code), never a duplicated rule set. The scaffold shipped `GEMINI.md` and `.github/copilot-instructions.md` as further bridges until #1368 removed them, so `CLAUDE.md` is the only one today. Claude Code additionally ships the protective enforcement hooks (`.claude/`, `.hooks/pre-commit`).
37
37
 
38
38
  ### Before starting ANY work: verify and sync the branch
39
39
 
@@ -99,8 +99,8 @@ Every code change MUST include, automatically:
99
99
 
100
100
  1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** WebJs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `references/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
101
101
  2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
102
- 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
103
- 4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`): the required `conventions` CI job runs it over both, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257).
102
+ 3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
103
+ 4. **Convention validation.** Run `webjs check` and fix violations. Run it from INSIDE an app, never from the repo root: the root is a workspace, not an app, so the command refuses there with exit 1 rather than reporting the cross-app collisions no single runtime ever sees (#1301). In this repo that means `( cd gallery && npx webjs check )`, `( cd examples/blog && npx webjs check )` and `( cd website && npx webjs check )`. Run `webjs doctor` too when you touched an in-repo app (`gallery`, `examples/blog`, `website`): the required `conventions` CI job runs it over all three, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257).
104
104
 
105
105
  ### Git workflow (mandatory)
106
106
 
@@ -159,7 +159,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the
159
159
 
160
160
  ## Framework source: where to find it
161
161
 
162
- Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source).
162
+ Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source). That knowledge layer reads the docs corpus installed in the app itself when there is one, so it is version-matched to the framework you are editing rather than to whenever the server was published, and `init` names the corpus it served and warns when a global install looks stale against the app.
163
163
 
164
164
  ---
165
165
 
@@ -279,7 +279,7 @@ MyThing.register('my-thing');
279
279
 
280
280
  **Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
281
281
 
282
- **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`. A `reflect: true` property whose value has no attribute representation REMOVES its attribute and warns rather than writing one, which covers a function (or an array carrying one, whose source would otherwise ship to every visitor) and a JSON-typed value `JSON.stringify` cannot serialize (a cycle, a `BigInt`, a throwing `toJSON`); a value merely CARRYING a function still reflects whole, since `JSON.stringify` drops it to `null` without losing the rest.
282
+ **Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field declaration OR initializer (e.g., `count = 0` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`. A `converter.fromAttribute` runs on BOTH attribute readers (the SSR one and the client one) through one shared implementation, ahead of type coercion, so it executes server-side and must not touch browser globals, and a throw from it is not caught on either side. A source attribute is read identically by the SSR reader and the browser, because both resolve it against the same expression `observedAttributes` is built from: it is matched by its LOWERCASED name (so write kebab-case, never camelCase), a prop that renames its attribute answers to the new name ONLY, a `state: true` prop is skipped entirely, a name matching no declared property is ignored, and HTML character references are decoded before the value is coerced (including the legacy semicolon-less forms, wherever a browser decodes them). A `reflect: true` property whose value has no attribute representation REMOVES its attribute and warns rather than writing one, which covers a function (or an array carrying one, whose source would otherwise ship to every visitor) and a JSON-typed value `JSON.stringify` cannot serialize (a cycle, a `BigInt`, a throwing `toJSON`); a value merely CARRYING a function still reflects whole, since `JSON.stringify` drops it to `null` without losing the rest.
283
283
 
284
284
  **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
285
285
 
@@ -305,7 +305,7 @@ Default export receives `{ children, params, searchParams, url }`, must embed `c
305
305
 
306
306
  ### Error / loading / metadata routes
307
307
 
308
- `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML.
308
+ `error.{js,ts}` default-exports `({ error, ...ctx }) => TemplateResult` (catches sibling-page / deeper render errors, innermost wins, prod sends only `error.message`). `loading.{js,ts}` wraps the sibling page in `Suspense` with an immediately-flushed fallback. `forbidden.{js,ts}` / `unauthorized.{js,ts}` render the nearest 403 / 401 boundary for a thrown `forbidden()` / `unauthorized()` (#848). Two **root-only** boundaries (`app/` root exactly): `global-error.{js,ts}` is the app-wide catch-all tried after the nested `error` boundaries are exhausted, and it renders its **own** `<!doctype><html><body>` document (returned verbatim, since a root-layout failure is when it fires). Because it is returned verbatim (no framework `<head>` splice), it ships **no importmap or boot script**, so keep it **static HTML with no components/hydration** (a last-resort page must not depend on the module system that may have just failed); under an opt-in CSP, an inline `<script>` in it must carry the nonce via `cspNonce()` (an inline `<style>` needs one only if you tighten `style-src`, since the default allows inline style outright). `global-not-found.{js,ts}` renders for an unmatched-anywhere URL when no `not-found` matches. `not-found` is nearest-wins from the throwing page's chain (#848 fixed the prior root-only behavior). Metadata routes (`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`) live at app root or static segments only and default-export a possibly-async function; `sitemap(entries)` / `sitemapIndex(sitemaps)` from `@webjsdev/server` serialize spec-valid XML. **`icon` / `apple-icon` are auto-LINKED** into the head (`<link rel="icon" href="/icon">`, `<link rel="apple-touch-icon" href="/apple-icon">`, base-path prefixed, no asserted `type` / `sizes` since the route picks its content type at request time), so writing the file is the whole wiring. A declared `metadata.icons` **suppresses** them rather than merging, matching Next's precedence for its static icon files, so an app that outgrows a placeholder route names its real icons instead of deleting it. `opengraph-image` / `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice): point `metadata.openGraph.images` / `metadata.twitter.images` at them. Declare a favicon through `metadata.icons` or a metadata route, never a hand-written `<link rel="icon">`, since only the root layout may write a shell at all (invariant 8). See `references/routing-and-pages.md`.
309
309
 
310
310
  ### Route handlers (`app/**/route.{js,ts}`)
311
311
 
@@ -238,7 +238,9 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
238
238
  - Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
239
239
  - Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
240
240
  - Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
241
- - Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
241
+ - Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
242
+ - Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
243
+ - Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
242
244
  - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
243
245
  - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
244
246
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@webjsdev/mcp",
3
- "version": "0.1.13",
4
- "sha": "207f216844251c7f171060aa6ac18d25d7d117f6",
5
- "copiedAt": "2026-08-08T19:53:44.949Z"
3
+ "version": "0.1.14",
4
+ "sha": "d1ecf972d1bc34128ccdc71878cd87d5430cf4c7",
5
+ "copiedAt": "2026-08-10T21:12:38.703Z"
6
6
  }
@@ -145,7 +145,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
145
145
 
146
146
  ## The `"webjs"` config block (package.json)
147
147
 
148
- All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline. The one exception is `doctor.gate`, which is read by the `webjs doctor` CLI rather than the server and rejects a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
148
+ All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot rather than crashing the pipeline, some readers warning as they drop it. `headers` and `redirects` warn on every drop. Both warn for the key itself when it is present but not an array, and for each rule or entry inside it; `headers` has a third level and warns per header directive too. An absent key is the default and says nothing. An UNKNOWN top-level key no longer passes in silence either (#1300): the block is validated against the published JSON Schema once per boot, in dev and in prod alike, and one aggregated warning names what was ignored. So a `"redirect"` typed for `"redirects"` now says so in the server output instead of silently leaving the feature at its default. It never fails the boot. Know its exact reach, since what it passes over looks like what it catches. It reports any unknown TOP-LEVEL key, whatever it is called, and it checks the VALUE of 9 of the 17 known keys, being the one `enum` and the `boolean` / `integer` leaves. It does not descend into a nested object, so a misspelling inside `dev` or `start` is not reported, and it does not type-check the other 8 (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`, `start`, `doctor`), whose schemas are the free-form ones a blunt check would start refusing working configs over. Whether anything else notices a value this check passes over is up to that value's own reader, and it varies by reader. `doctor.gate` varies furthest, since the boot check never descends into any key and the `webjs doctor` CLI, not the server, is what reads this one, rejecting a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
149
149
 
150
150
  ### Security headers
151
151
 
@@ -135,17 +135,21 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
135
135
  |---|---|---|
136
136
  | `type` | `String` | Constructor feeding the default attribute converter |
137
137
  | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
138
- | `state` | `false` | Internal-only. No attribute, not observed |
138
+ | `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either |
139
139
  | `attribute` | derived from name | The HTML attribute name the property rides |
140
140
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
141
141
  | `hasChanged` | strict `!==` | Custom change detection |
142
- | `converter` | type-based | Custom attribute-to-property serialization |
142
+ | `converter` | type-based | Custom attribute-to-property serialization. `fromAttribute` runs on BOTH readers (the client upgrade and SSR), ahead of type coercion |
143
143
 
144
144
  For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
145
145
 
146
+ **A `converter.fromAttribute` runs SERVER-SIDE too.** Both attribute readers go through one shared implementation, so the converter fires during SSR as well as on the client upgrade, and it wins over the declared `type` on both. So keep it free of browser globals (`document`, `window`, `navigator`), since SSR has no DOM and touching one throws where the same code worked in the browser. A converter that THROWS is not caught by either reader, because an author who writes one owns the conversion: at SSR the throw lands in per-component error isolation (an error box in dev, an empty element at a 200 in production, with the cause in the server log) while sibling components still render, and on the client it escapes `attributeChangedCallback` during upgrade. Both readers hand the converter DECODED attribute text, so a converter that parses its input (`JSON.parse` for a Map or a Set, `new Date(...)`) sees the same string on both sides even when the attribute carries `&quot;` or `&amp;`.
147
+
146
148
  **A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
147
149
 
148
- **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value.
150
+ **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop, a camelCase attribute name, and an attribute matching no declared property are all ignored by both, and both are handed a value whose HTML character references are already decoded.
151
+
152
+ **Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so write kebab-case (`user-name`); a camelCase attribute (`userName="…"`) reaches no property on either side. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. Character references are decoded before the value is coerced, so `cfg="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=x` stay literal), and writing the semicolon avoids the question. A `state: true` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute.
149
153
 
150
154
  **Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
151
155
 
@@ -146,15 +146,41 @@ Everything the action declares applies here too, or an action would be protected
146
146
  - `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
147
147
  - `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
148
148
  - `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
149
+ - A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.
149
150
 
150
151
  The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
151
152
 
152
153
  A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
153
154
 
154
- ## HTTP-verb config exports
155
+ ## HTTP-verb config exports & decision guide
155
156
 
156
157
  A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
157
158
 
159
+ ### HTTP Verbs Decision Guide
160
+
161
+ | Action Kind | Target Verb | Example Declaration | HTTP Semantics & Features |
162
+ |---|---|---|---|
163
+ | **Form-Bound Action** (`<form action=${fn}>`) | **POST** (default) | *(no export or `export const method = 'POST'`) | Standard HTML form submission. Enforces `POST` + `multipart/form-data` or `urlencoded`. **Never export `method = 'GET'`** (triggers 405 refusal & `webjs check` violation). |
164
+ | **RPC Read Action (Query)** | **GET** | `export const method = 'GET'` | Read-only RPC calls (`await getTodos()`). Args ride URL query params (with POST fallback over 4KB). CSRF-exempt, supports ETags, 304 revalidation, and `export const cache`. |
165
+ | **RPC Write Action (Mutation)** | **POST** / **PUT** / **PATCH** / **DELETE** | Default or `export const method = 'DELETE'` | Data-modifying RPC calls (`await deleteUser(4)`). Carries CSRF protection, serialized payload body, and evicts cached query tags via `export const invalidates`. |
166
+
167
+ ### Choosing the right HTTP verb
168
+
169
+ 1. **Form-Bound Actions (`<form action=${fn}>` / `<button formaction=${fn}>`):**
170
+ - **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
171
+ - **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
172
+ - **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
173
+
174
+ 2. **Programmatic / RPC Read Actions (Queries):**
175
+ - **ALWAYS export `export const method = 'GET'` for read-only queries.**
176
+ - When an action only fetches data (`await getUser(id)`), exporting `method = 'GET'` instructs the client RPC stub to issue an HTTP GET request with arguments encoded in query parameters.
177
+ - Enables browser/CDN caching, weak ETags (returning 304 Not Modified on cache hit), and HTTP `Cache-Control` header generation when paired with `export const cache = ...`.
178
+
179
+ 3. **Programmatic / RPC Write Actions (Mutations):**
180
+ - **Use POST, PUT, PATCH, or DELETE for writes.**
181
+ - Use default `POST` or explicitly export `PUT`/`PATCH`/`DELETE` for RESTful RPC calls (`await removeUser(id)`).
182
+ - Pair mutating actions with `export const invalidates = (args...) => ['tag']` to evict cached reads matching those tags upon completion.
183
+
158
184
  ```ts
159
185
  // modules/users/queries/get-user.server.ts: a cached, tagged GET read
160
186
  'use server';
@@ -165,8 +191,9 @@ export async function getUser(id: number) { return db.query.users.findFirst({ wh
165
191
  ```
166
192
 
167
193
  ```ts
168
- // a mutation evicts the tags it touches
194
+ // modules/users/actions/update-user.server.ts: a mutation evicting matching tags
169
195
  'use server';
196
+ export const method = 'PATCH'; // explicit verb
170
197
  export const invalidates = (id: number) => ['user:' + id];
171
198
  export const middleware = [requireAuth]; // async (ctx, next) => result; read ctx via actionContext()
172
199
  export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
@@ -110,6 +110,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
110
110
  | `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
111
111
  | `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
112
112
  | `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
113
+
113
114
  | `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
114
115
  | `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
115
116
  | `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
@@ -118,6 +119,8 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
118
119
  | `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
119
120
  | a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
120
121
  | `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
122
+ | `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
123
+ | `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
121
124
  | `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
122
125
  | a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
123
126
  | a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
@@ -134,6 +137,27 @@ That last row is the one to remember: quoting a binding hole turns it back into
134
137
 
135
138
  `.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
136
139
 
140
+ **A bound submitter is self-sufficient and asks nothing of the form around it.** This is the shape people expect to have to wire up, and do not:
141
+
142
+ ```ts
143
+ // components/publish-button.ts <- the submitter lives here
144
+ class PublishButton extends WebComponent({}) {
145
+ render() { return html`<button formaction=${publishDraft}>Publish</button>`; }
146
+ }
147
+ PublishButton.register('publish-button');
148
+
149
+ // app/triage/page.ts <- the form lives here
150
+ // BOTH work. The button carries its own submission attributes.
151
+ html`<form><publish-button></publish-button></form>`;
152
+ html`<form action=${saveAll}><publish-button></publish-button></form>`;
153
+ ```
154
+
155
+ The renderer supplies the submission attributes at the level where the action is BOUND (#1307), so a bound `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the reserved `__webjs_action` identity riding the button's own `name`/`value` pair. A submitter's `formmethod` overrides the form's `method` per HTML, so the submission is a POST whatever the enclosing form declares, including no `method` at all or `method="get"`, and the identity travels in the body where the dispatcher reads it.
156
+
157
+ That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.
158
+
159
+ **Two runtime signals cover what is left.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
160
+
137
161
  **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
138
162
 
139
163
  Two things that "renders it empty" understates, both worth knowing before you go looking:
@@ -192,7 +192,28 @@ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere
192
192
 
193
193
  Metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `apple-icon.ts`, `opengraph-image.ts`, `twitter-image.ts`) live at app root or static segments and default-export a possibly-async function; `sitemap()` / `sitemapIndex()` from `@webjsdev/server` serialize spec-valid XML.
194
194
 
195
- The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). Then point `metadata` at the route via `openGraph.images` / `twitter.images` / `icons` (or drop a static file in `public/` instead).
195
+ The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless).
196
+
197
+ **`icon` and `apple-icon` are LINKED for you.** An app that declares no `metadata.icons` gets `<link rel="icon" href="/icon">` and `<link rel="apple-touch-icon" href="/apple-icon">` in the head automatically, for whichever of the two routes it defines (base-path prefixed, since that is where the route answers). No `type` or `sizes` is asserted, because the route picks its content type at request time and the browser sniffs the served one.
198
+
199
+ Declaring `metadata.icons` **suppresses** the routes rather than merging with them, which is what Next does with its static icon files. So an app that outgrows a placeholder `app/icon.ts` names its real icons and the route stops being linked without having to be deleted:
200
+
201
+ ```ts
202
+ // app/layout.ts -> these win; /icon and /apple-icon are no longer linked
203
+ export const metadata = {
204
+ icons: {
205
+ icon: [
206
+ { url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
207
+ { url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
208
+ ],
209
+ apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
210
+ },
211
+ };
212
+ ```
213
+
214
+ Declare a favicon through `metadata.icons` (or a metadata route), never as a hand-written `<link rel="icon">`: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. A `public/favicon.ico` needs no declaration either way, since the framework serves it at the origin root for crawlers that read no markup.
215
+
216
+ `opengraph-image` and `twitter-image` are NOT auto-linked (a preview image is a per-page editorial choice, not a site-wide default). Point `metadata` at those via `openGraph.images` / `twitter.images`.
196
217
 
197
218
  ```ts
198
219
  // app/opengraph-image.ts (OG is 1200x630; apple-icon 180x180)
@@ -109,6 +109,19 @@ The default stack is a static compiled Tailwind stylesheet (`css:build` compiles
109
109
 
110
110
  **Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
111
111
 
112
+ **`@theme` and `@theme inline` differ in whether the token reaches `:root`, and the difference is silent.** Measured on `tailwindcss@4.3.0`, a token mapped in a theme block is emitted as a real `:root` custom property when:
113
+
114
+ | block | token used only through a utility (`border-border`) | token written as a raw `var(--color-x)` in any SCANNED file | token unused |
115
+ |---|---|---|---|
116
+ | `@theme` | emitted | emitted | not emitted |
117
+ | `@theme inline` | NOT emitted (the value is substituted into the utility) | emitted | not emitted |
118
+
119
+ The one cell that bites is `inline` plus utility-only usage. Nothing on the page can then inherit `--color-x`, so a raw `var(--color-x)` written somewhere Tailwind never scanned resolves to nothing and the declaration falls back to its initial value (a border or outline silently becomes `currentColor`).
120
+
121
+ "Scanned" is wider than it looks, and this is the part worth knowing: Tailwind scans source files as raw text, so a `var(--color-ring)` inside a component's `static styles` template DOES count and forces emission, exactly like one in the stylesheet. That is why the `@webjsdev/ui` kit theme works despite using `inline`. So the rule is not "shadow components need a plain `@theme`". It is: **if a token is only ever used through utilities, and something outside the scanned source needs to inherit it, map that token with a plain `@theme`.** Anything under a configured `@source` is scanned and needs no special handling.
122
+
123
+ Whichever form you use, a token nothing references is dropped in both, so an unused mapping is dead configuration rather than a safety net.
124
+
112
125
  **Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
113
126
 
114
127
  ```html
@@ -72,5 +72,20 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
72
72
  the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
73
73
  - Custom elements are display-only-safe at SSR and hydrate in the browser, the
74
74
  standard WebJs component model (`references/components.md`).
75
+ - A registry module should do no work at module scope, because the elision
76
+ analyser reads a module-scope call or a `document` reference as client work
77
+ and then the page that imports it ships whole (#1320). `cn` itself is clean,
78
+ so importing it never pins a page. Six modules still trip the analyser and DO
79
+ pin an importing page: `checkbox`, `radio-group`, `pagination`, `progress`,
80
+ `sonner`, `tabs`. The first two inject a stylesheet for real; the other four
81
+ are an analyser precision gap (an arrow with an expression body puts its call
82
+ at brace depth 0). Either way the page ships, so treat the list as fact rather
83
+ than as a technicality. Keep your own copies clean when you edit them, and run
84
+ `npx webjsdev elision`, which names the blocker whenever a page ships.
85
+ - `native-select`'s `<option>` colours ride the design tokens, not the module.
86
+ An app with no theme block gets the browser default `<option>` colours along
87
+ with everything else unstyled, fixed the same way (re-run `init`, or let `add`
88
+ plant the block). An app whose block predates the rule keeps the default until
89
+ the rule is added by hand, because `init` never rewrites an existing block.
75
90
 
76
91
  Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.
package/src/mcp-docs.js CHANGED
@@ -14,11 +14,15 @@
14
14
  * given its injected `{ docsDir, agentsPath, readFile }` deps, so it is testable
15
15
  * in-process without booting a server or touching the real filesystem.
16
16
  *
17
- * Docs resolution (so `npx @webjsdev/cli mcp` is self-contained): a published
18
- * install reads the corpus bundled under `<cli>/resources/references` (copied
19
- * at `prepack`, see `scripts/copy-mcp-resources.js`); a monorepo dev run falls
20
- * back to the repo-root skill. {@link resolveDocsLocation} encodes that
21
- * two-path lookup so source stays single (no committed duplicate docs).
17
+ * Docs resolution (so `npx @webjsdev/mcp` is self-contained): the APP's own
18
+ * installed `@webjsdev/mcp` corpus wins, then the running server's snapshot
19
+ * bundled under `<pkg>/resources/references` (copied at `prepack`, see
20
+ * `scripts/copy-mcp-resources.js`), then a repo-root-relative fallback, which is
21
+ * the skill on a monorepo dev run. {@link resolveDocsLocation} encodes that
22
+ * lookup so source stays single
23
+ * (no committed duplicate docs). `init` reports which of the three it served,
24
+ * from the `corpus.json` build stamp, and warns when the app's installed mcp is
25
+ * newer than the running server (#1319).
22
26
  *
23
27
  * @module mcp-docs
24
28
  */
@@ -30,38 +34,230 @@ import { fileURLToPath } from 'node:url';
30
34
  /** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
31
35
  const DOCS_SCHEME = 'webjs-docs://';
32
36
 
37
+ /**
38
+ * The paths of one bundled corpus root (`<something>/resources`), tagged with
39
+ * which rung it is so a caller can say WHOSE docs it ended up serving.
40
+ *
41
+ * @param {string} root the `resources` directory
42
+ * @param {'app' | 'bundled'} corpusSource which rung this root is
43
+ * @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string, corpusSource: 'app' | 'bundled' }}
44
+ */
45
+ function bundleAt(root, corpusSource) {
46
+ return {
47
+ docsDir: join(root, 'references'),
48
+ agentsPath: join(root, 'AGENTS.md'),
49
+ skillPath: join(root, 'SKILL.md'),
50
+ corpusPath: join(root, 'corpus.json'),
51
+ corpusSource,
52
+ };
53
+ }
54
+
33
55
  /**
34
56
  * Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
35
- * path. Tries the BUNDLED location first (a published `@webjsdev/mcp` ships
36
- * `resources/references/` + `resources/SKILL.md` + `resources/AGENTS.md` via `prepack`), then falls
37
- * back to the monorepo-root skill layout used in dev and
38
- * tests. Returns `{ docsDir, agentsPath }`; either path may not exist, callers
39
- * fail soft (an empty corpus is valid, never a crash).
57
+ * and build-stamp paths. Three rungs, highest first:
58
+ *
59
+ * 1. `<appDir>/node_modules/@webjsdev/mcp/resources`, the APP's own installed
60
+ * copy. It wins because it is version-matched to the framework the agent is
61
+ * editing, and so is the only corpus that can be correct about that app. A
62
+ * globally installed server otherwise serves a snapshot frozen at whatever
63
+ * it was published from, which taught reverted guidance for months (#1319).
64
+ * 2. `<pkgRoot>/resources`, the running server's own bundled snapshot (copied
65
+ * at `prepack`, see `scripts/copy-mcp-resources.js`).
66
+ * 3. A repo-root-relative path, which is the monorepo skill on a dev/test run.
67
+ * This rung is an unconditional FALLBACK, not a checkout probe, so a
68
+ * published install whose `resources/` bundle is missing lands here too,
69
+ * with `repoRoot` inside `node_modules` and an empty corpus. `corpusPath` is
70
+ * `null` because no bundle was found, which is why nothing downstream may
71
+ * assert a checkout on this rung.
72
+ *
73
+ * The rung-1 probe is a plain `existsSync` on the references directory rather
74
+ * than `require.resolve('@webjsdev/mcp')` from `appDir`, because what this needs
75
+ * is "a directory of markdown at a known path" and nothing more. Resolving the
76
+ * package would go through its `exports` map, and in this monorepo that lands on
77
+ * the workspace symlink back to `packages/mcp`, whose `resources/` does not
78
+ * exist in a checkout, so the resolve would fall to rung 3 anyway.
79
+ *
80
+ * Any returned path may not exist; callers fail soft (an empty corpus is valid,
81
+ * never a crash).
40
82
  *
41
83
  * @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
42
- * @returns {{ docsDir: string, agentsPath: string }}
84
+ * @param {string} [appDir] the app being asked about; enables the rung-1 probe
85
+ * @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string | null, corpusSource: 'app' | 'bundled' | 'repo' }}
43
86
  */
44
- export function resolveDocsLocation(moduleUrl) {
87
+ export function resolveDocsLocation(moduleUrl, appDir) {
45
88
  const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
46
89
  const pkgRoot = resolve(here, '..'); // packages/mcp/src -> packages/mcp
47
90
  const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
48
91
 
49
- const bundledRefs = join(pkgRoot, 'resources', 'references');
50
- if (existsSync(bundledRefs)) {
51
- return {
52
- docsDir: bundledRefs,
53
- agentsPath: join(pkgRoot, 'resources', 'AGENTS.md'),
54
- skillPath: join(pkgRoot, 'resources', 'SKILL.md'),
55
- };
92
+ if (typeof appDir === 'string' && appDir) {
93
+ const appRoot = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'resources');
94
+ if (existsSync(join(appRoot, 'references'))) return bundleAt(appRoot, 'app');
56
95
  }
96
+ const bundledRoot = join(pkgRoot, 'resources');
97
+ if (existsSync(join(bundledRoot, 'references'))) return bundleAt(bundledRoot, 'bundled');
98
+
57
99
  const skill = join(repoRoot, '.agents', 'skills', 'webjs');
58
100
  return {
59
101
  docsDir: join(skill, 'references'),
60
102
  agentsPath: join(repoRoot, 'AGENTS.md'),
61
103
  skillPath: join(skill, 'SKILL.md'),
104
+ corpusPath: null,
105
+ corpusSource: 'repo',
62
106
  };
63
107
  }
64
108
 
109
+ /**
110
+ * Compare two `major.minor.patch` versions, returning `-1` / `0` / `1`.
111
+ *
112
+ * Dependency-free by design (this package ships zero dependencies), and
113
+ * deliberately coarse: any prerelease or build suffix is stripped before
114
+ * comparing, a missing segment reads as `0`, and a non-numeric segment reads as
115
+ * `0`. Its only consumer is the staleness warning, where the question is "is the
116
+ * app's copy meaningfully newer", so ranking two prereleases of one patch is not
117
+ * a case that needs an answer.
118
+ *
119
+ * @param {string} a
120
+ * @param {string} b
121
+ * @returns {-1 | 0 | 1}
122
+ */
123
+ export function compareVersions(a, b) {
124
+ const parts = (v) => String(v == null ? '' : v).trim().split(/[-+]/)[0].split('.');
125
+ const pa = parts(a);
126
+ const pb = parts(b);
127
+ for (let i = 0; i < 3; i++) {
128
+ const x = Number(pa[i]);
129
+ const y = Number(pb[i]);
130
+ const nx = Number.isFinite(x) ? x : 0;
131
+ const ny = Number.isFinite(y) ? y : 0;
132
+ if (nx !== ny) return nx < ny ? -1 : 1;
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ /**
138
+ * The `@webjsdev/mcp` version installed in `appDir`, or `null` when that cannot
139
+ * be known (no app dir, no install, an unreadable or unparseable manifest, no
140
+ * `version` field). Only mcp's own version is read: the corpus ships INSIDE this
141
+ * package, so mcp's version is the only one that bounds the docs, and reading
142
+ * the other `@webjsdev/*` manifests would multiply the failure modes without
143
+ * adding signal.
144
+ *
145
+ * @param {string} [appDir]
146
+ * @param {{ exists?: (p: string) => boolean, readFile: (p: string, enc: string) => Promise<string> }} deps
147
+ * @returns {Promise<string | null>}
148
+ */
149
+ export async function readAppMcpVersion(appDir, deps) {
150
+ if (typeof appDir !== 'string' || !appDir) return null;
151
+ const manifest = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'package.json');
152
+ try {
153
+ if (deps.exists && !deps.exists(manifest)) return null;
154
+ const version = JSON.parse(await deps.readFile(manifest, 'utf8')).version;
155
+ return typeof version === 'string' && version ? version : null;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The one line naming WHICH docs corpus `init` is serving, so an agent reading a
163
+ * stale global install can see that it is stale (#1319). Never throws: a
164
+ * missing, unreadable, or malformed stamp degrades to the unstamped wording,
165
+ * because refusing to answer is strictly worse than answering with a caveat.
166
+ *
167
+ * @param {object} deps
168
+ * @returns {Promise<string>}
169
+ */
170
+ async function corpusLine(deps) {
171
+ // Rung 3 has no stamp, and it is a FALLBACK rather than a checkout probe, so
172
+ // this must not claim a checkout: a published install whose `resources/` is
173
+ // missing lands here too, with an empty corpus and no checkout to name.
174
+ if (!deps.corpusPath) return 'Docs corpus: no bundled snapshot; serving whatever docs this server resolved locally.';
175
+ const unstamped = 'Docs corpus: an unstamped @webjsdev/mcp bundled snapshot.';
176
+ try {
177
+ const stamp = JSON.parse(await deps.readFile(deps.corpusPath, 'utf8'));
178
+ if (!stamp || !stamp.version) return unstamped;
179
+ const pkg = stamp.package || '@webjsdev/mcp';
180
+ const date = typeof stamp.copiedAt === 'string' && stamp.copiedAt ? stamp.copiedAt.slice(0, 10) : 'an unknown date';
181
+ // A SHA the stamp could not capture drops the clause rather than printing a
182
+ // placeholder: the point of a provenance line is that no answer reads as no
183
+ // answer. Same reason the shape is checked, since only a real commit id
184
+ // resolves to a diff.
185
+ const sha = typeof stamp.sha === 'string' && /^[0-9a-f]{40}$/.test(stamp.sha) ? stamp.sha.slice(0, 7) : null;
186
+ return sha
187
+ ? `Docs corpus: ${pkg}@${stamp.version}, copied from webjsdev/webjs ${sha} on ${date}.`
188
+ : `Docs corpus: ${pkg}@${stamp.version}, copied on ${date}.`;
189
+ } catch {
190
+ return unstamped;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * What the warning says about WHOSE docs it just served, one clause per rung.
196
+ *
197
+ * A `Map` rather than an object literal, for the reason `docsDepsCache` is one:
198
+ * an object literal inherits `Object.prototype`, so a lookup keyed on an
199
+ * unexpected value like `constructor` or `toString` returns a truthy FUNCTION,
200
+ * the `||` fallback never fires, and the warning splices native-code source into
201
+ * its own text. A `Map` has no prototype keys, so the fallback covers every value
202
+ * that is not one of these three.
203
+ *
204
+ * The `repo` clause deliberately does NOT claim a live checkout. Rung 3 is an
205
+ * unconditional fallback rather than a checkout probe (see
206
+ * {@link resolveDocsLocation}), so it is also where a published install with no
207
+ * bundled `resources/` lands, and there the "checkout" does not exist and the
208
+ * corpus is empty.
209
+ */
210
+ const SERVED_CLAUSE = new Map([
211
+ ['app', "The docs below come from this app's own copy, so they match it; update the server so its TOOLS match too."],
212
+ ['bundled', "The docs below are the server's own older snapshot, not this app's copy."],
213
+ ['repo', "The docs below are whatever this server resolved locally, not this app's copy."],
214
+ ]);
215
+
216
+ /**
217
+ * The staleness warning, or `null` when there is nothing to warn about.
218
+ *
219
+ * Fires only when the app's installed `@webjsdev/mcp` is strictly NEWER than the
220
+ * running server's. Equal or lower is silent: a newer server reading an older app
221
+ * is the ordinary shape and is not a defect. It warns rather than failing,
222
+ * because a read-only knowledge tool that refuses to answer sends the agent back
223
+ * to its training data, which is the thing the caveat exists to correct.
224
+ *
225
+ * Two things the wording is careful about, because the obvious phrasings are both
226
+ * wrong:
227
+ *
228
+ * - It does NOT name a global install as the thing to update. Nothing here can
229
+ * observe how the server was started, and the shipped configurations are an
230
+ * `npx @webjsdev/mcp` (whose staleness is a package cache) and `webjs mcp`
231
+ * (whose staleness is `@webjsdev/cli`'s own dependency), neither of which a
232
+ * `npm i -g` would fix.
233
+ * - It does NOT promise the docs below are the app's. The warning's probe is the
234
+ * app's `package.json`, while the corpus rung's probe is that install's
235
+ * `resources/references`, and the two can disagree: a workspace-linked or
236
+ * otherwise resources-less install has a manifest to read but no corpus to
237
+ * serve, so the corpus falls through to the server's older snapshot. That case
238
+ * gets an extra clause naming what was actually served, rather than a claim
239
+ * that contradicts the corpus line printed directly beneath it.
240
+ *
241
+ * @param {object} deps
242
+ * @returns {Promise<string | null>}
243
+ */
244
+ async function staleWarning(deps) {
245
+ const server = deps.serverVersion;
246
+ // '0.0.0' is the sentinel both entry points fall back to when they cannot read
247
+ // their own manifest, so it means "unknown", not "older than everything".
248
+ if (!server || server === '0.0.0') return null;
249
+ const app = await readAppMcpVersion(deps.appDir, deps);
250
+ if (!app || compareVersions(app, server) <= 0) return null;
251
+ const served = SERVED_CLAUSE.get(deps.corpusSource) || "The docs below may not be this app's copy.";
252
+ return (
253
+ `Warning: this MCP server is @webjsdev/mcp@${server}, but this app has @webjsdev/mcp@${app}, ` +
254
+ `so the server may be stale. ${served} ` +
255
+ 'Update whichever copy runs this server: a global install (npm i -g @webjsdev/mcp@latest, ' +
256
+ 'or bun add -g @webjsdev/mcp), the package cache behind npx @webjsdev/mcp, ' +
257
+ '@webjsdev/cli when the server is started as webjs mcp, or the checkout it runs from.'
258
+ );
259
+ }
260
+
65
261
  /**
66
262
  * A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
67
263
  * `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
@@ -191,6 +387,8 @@ export function sectionByHeading(md, headingRe) {
191
387
  export async function initText(deps) {
192
388
  let agents = '';
193
389
  try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
390
+ const corpus = await corpusLine(deps);
391
+ const warning = await staleWarning(deps);
194
392
  const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
195
393
  const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
196
394
 
@@ -217,6 +415,11 @@ export async function initText(deps) {
217
415
  const parts = [
218
416
  '# webjs: read first',
219
417
  '',
418
+ // Provenance first, so an agent reading a months-old global install sees
419
+ // which docs it is holding before it reads a word of them (#1319).
420
+ ...(warning ? [warning, ''] : []),
421
+ corpus,
422
+ '',
220
423
  router,
221
424
  '',
222
425
  execModel || '(execution-model section unavailable)',
package/src/mcp.js CHANGED
@@ -50,25 +50,46 @@ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'vali
50
50
  * into an agent-friendly shape. Descriptions are crisp so a model picks the
51
51
  * right tool without reading source.
52
52
  */
53
+ /**
54
+ * The `appDir` property, shared by every tool that is scoped to one app.
55
+ *
56
+ * `init` and `docs` carry it too, not just the introspection tools: the docs
57
+ * corpus is resolved from the app being asked about (#1319), so a tool that did
58
+ * not ADVERTISE `appDir` could never receive one from a conforming client, and
59
+ * the app-corpus rung would be reachable only from hand-written JSON-RPC.
60
+ */
61
+ const APPDIR_PROP = {
62
+ type: 'string',
63
+ description: 'App directory to introspect. Defaults to the server cwd.',
64
+ };
65
+
53
66
  /** The shared input schema for the introspection tools: an optional appDir override. */
54
67
  const APPDIR_SCHEMA = {
68
+ type: 'object',
69
+ properties: { appDir: { ...APPDIR_PROP } },
70
+ required: [],
71
+ };
72
+
73
+ /** `init` takes only the optional appDir, which selects whose docs corpus it reports. */
74
+ const INIT_SCHEMA = {
55
75
  type: 'object',
56
76
  properties: {
57
77
  appDir: {
58
- type: 'string',
59
- description: 'App directory to introspect. Defaults to the server cwd.',
78
+ ...APPDIR_PROP,
79
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
60
80
  },
61
81
  },
62
82
  required: [],
63
83
  };
64
84
 
65
- /** `init` takes no input. */
66
- const INIT_SCHEMA = { type: 'object', properties: {}, required: [] };
67
-
68
- /** `docs` takes an optional topic OR a free-text query. */
85
+ /** `docs` takes an optional topic OR a free-text query, plus the optional appDir. */
69
86
  const DOCS_SCHEMA = {
70
87
  type: 'object',
71
88
  properties: {
89
+ appDir: {
90
+ ...APPDIR_PROP,
91
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
92
+ },
72
93
  topic: {
73
94
  type: 'string',
74
95
  description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
@@ -500,22 +521,46 @@ export async function runMcpServer(opts) {
500
521
  const runners = makeToolRunners(deps);
501
522
 
502
523
  // The docs corpus deps for the knowledge layer (#376): resources / prompts /
503
- // init / docs. Injectable for tests; otherwise resolved from the bundled
504
- // (published) or repo-root (dev) docs and node fs.
505
- let docsDeps = opts.docsDeps;
506
- if (!docsDeps) {
507
- const loc = resolveDocsLocation(import.meta.url);
524
+ // init / docs. Injectable for tests; otherwise resolved from the app's own
525
+ // installed corpus, the bundled (published) snapshot, or the repo-root (dev)
526
+ // docs, plus node fs.
527
+ //
528
+ // Resolved PER CALL and memoized by appDir (#1319), not once at boot. `appDir`
529
+ // is a per-call tool argument, so a boot-time resolution would pin the corpus
530
+ // to the launch directory forever and defeat the app-corpus rung for any host
531
+ // that overrides it. `resources/list` and `resources/read` carry no appDir in
532
+ // the MCP protocol, so they resolve from `cwd`, which is the same value a
533
+ // `tools/call` with no `appDir` argument defaults to; the surfaces can only
534
+ // diverge when a caller deliberately asks about a different app.
535
+ let docsFs = null;
536
+ if (!opts.docsDeps) {
508
537
  const { readFile } = await import('node:fs/promises');
509
538
  const { readdirSync, existsSync } = await import('node:fs');
510
- docsDeps = {
511
- docsDir: loc.docsDir,
512
- agentsPath: loc.agentsPath,
513
- skillPath: loc.skillPath,
514
- listDir: readdirSync,
515
- exists: existsSync,
516
- readFile,
517
- };
539
+ docsFs = { listDir: readdirSync, exists: existsSync, readFile };
518
540
  }
541
+ /** @type {Map<string, object>} */
542
+ const docsDepsCache = new Map();
543
+ /** The docs deps for one appDir. An injected `opts.docsDeps` wins for every appDir. */
544
+ const docsDepsFor = (dir) => {
545
+ if (opts.docsDeps) return opts.docsDeps;
546
+ const key = typeof dir === 'string' ? dir : '';
547
+ let built = docsDepsCache.get(key);
548
+ if (!built) {
549
+ const loc = resolveDocsLocation(import.meta.url, key);
550
+ built = {
551
+ docsDir: loc.docsDir,
552
+ agentsPath: loc.agentsPath,
553
+ skillPath: loc.skillPath,
554
+ corpusPath: loc.corpusPath,
555
+ corpusSource: loc.corpusSource,
556
+ appDir: key,
557
+ serverVersion: version,
558
+ ...docsFs,
559
+ };
560
+ docsDepsCache.set(key, built);
561
+ }
562
+ return built;
563
+ };
519
564
 
520
565
  // The `source` tool (#378): read the framework's own source from
521
566
  // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
@@ -581,12 +626,12 @@ export async function runMcpServer(opts) {
581
626
 
582
627
  // Knowledge layer (#376): the framework docs as MCP resources.
583
628
  if (method === 'resources/list') {
584
- return rpcResult(id, { resources: listResources(docsDeps) });
629
+ return rpcResult(id, { resources: listResources(docsDepsFor(cwd)) });
585
630
  }
586
631
  if (method === 'resources/read') {
587
632
  const uri = ((msg && msg.params) || {}).uri;
588
633
  try {
589
- const r = await readResource(docsDeps, uri);
634
+ const r = await readResource(docsDepsFor(cwd), uri);
590
635
  return rpcResult(id, { contents: [r] });
591
636
  } catch (e) {
592
637
  return rpcError(id, -32602, e && e.message ? e.message : String(e));
@@ -621,9 +666,9 @@ export async function runMcpServer(opts) {
621
666
  try {
622
667
  const result = isKnowledgeTool
623
668
  ? name === 'init'
624
- ? await initText(docsDeps)
669
+ ? await initText(docsDepsFor(appDir))
625
670
  : name === 'docs'
626
- ? await searchDocs(docsDeps, args)
671
+ ? await searchDocs(docsDepsFor(appDir), args)
627
672
  : await runSourceTool(sourceDeps, args)
628
673
  : isUiTool
629
674
  ? runUiTool(uiDeps, args)