@webjsdev/mcp 0.1.18 → 0.1.20

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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The `init` tool of `webjs mcp`: the one thing an agent reads before it writes
3
+ * WebJs code.
4
+ *
5
+ * It returns the WebJs brief (see `brief.js`) plus a map of the server's other
6
+ * tools. There is no docs corpus behind it any more. Lit and Next.js habits are
7
+ * meant to produce correct WebJs code, and where one cannot, `webjs check` and
8
+ * the runtime refuse it with the fix in the message, so the brief is the whole
9
+ * of the agent-facing documentation. For an exact contract the agent reads the
10
+ * framework source, which is no-build and therefore the code that runs (the
11
+ * `source` tool).
12
+ *
13
+ * Pure given its injected `{ appDir, serverVersion, readFile, exists }` deps, so
14
+ * it is testable in-process without touching the real filesystem.
15
+ *
16
+ * @module mcp-init
17
+ */
18
+
19
+ import { join } from 'node:path';
20
+
21
+ import { BRIEF, BRIEF_HEADING } from './brief.js';
22
+
23
+ /**
24
+ * Compare two `major.minor.patch` versions, returning `-1` / `0` / `1`.
25
+ *
26
+ * Dependency-free by design (this package ships zero dependencies), and
27
+ * deliberately coarse: any prerelease or build suffix is stripped before
28
+ * comparing, a missing segment reads as `0`, and a non-numeric segment reads as
29
+ * `0`. Its only consumer is the staleness warning, where the question is "is the
30
+ * app's copy meaningfully newer", so ranking two prereleases of one patch is not
31
+ * a case that needs an answer.
32
+ *
33
+ * @param {string} a
34
+ * @param {string} b
35
+ * @returns {-1 | 0 | 1}
36
+ */
37
+ export function compareVersions(a, b) {
38
+ const parts = (v) => String(v == null ? '' : v).trim().split(/[-+]/)[0].split('.');
39
+ const pa = parts(a);
40
+ const pb = parts(b);
41
+ for (let i = 0; i < 3; i++) {
42
+ const x = Number(pa[i]);
43
+ const y = Number(pb[i]);
44
+ const nx = Number.isFinite(x) ? x : 0;
45
+ const ny = Number.isFinite(y) ? y : 0;
46
+ if (nx !== ny) return nx < ny ? -1 : 1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ /**
52
+ * The `@webjsdev/mcp` version installed in `appDir`, or `null` when that cannot
53
+ * be known (no app dir, no install, an unreadable or unparseable manifest, no
54
+ * `version` field).
55
+ *
56
+ * @param {string} [appDir]
57
+ * @param {{ exists?: (p: string) => boolean, readFile: (p: string, enc: string) => Promise<string> }} deps
58
+ * @returns {Promise<string | null>}
59
+ */
60
+ export async function readAppMcpVersion(appDir, deps) {
61
+ if (typeof appDir !== 'string' || !appDir) return null;
62
+ const manifest = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'package.json');
63
+ try {
64
+ if (deps.exists && !deps.exists(manifest)) return null;
65
+ const version = JSON.parse(await deps.readFile(manifest, 'utf8')).version;
66
+ return typeof version === 'string' && version ? version : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The staleness warning, or `null` when there is nothing to warn about.
74
+ *
75
+ * Fires only when the app's installed `@webjsdev/mcp` is strictly NEWER than the
76
+ * running server's (a globally installed server lags the app it is asked
77
+ * about, #1319). Equal or lower is silent. It warns rather than failing, since
78
+ * a stale brief and stale tools still beat no answer.
79
+ *
80
+ * It names every way the server may have been started rather than guessing one,
81
+ * because nothing here can observe it: a global install, the package cache
82
+ * behind `npx @webjsdev/mcp`, `@webjsdev/cli` when started as `webjs mcp`, or a
83
+ * checkout.
84
+ *
85
+ * @param {{ appDir?: string, serverVersion?: string, readFile: Function, exists?: Function }} deps
86
+ * @returns {Promise<string | null>}
87
+ */
88
+ export async function staleWarning(deps) {
89
+ const server = deps.serverVersion;
90
+ // '0.0.0' is the sentinel both entry points fall back to when they cannot read
91
+ // their own manifest, so it means "unknown", not "older than everything".
92
+ if (!server || server === '0.0.0') return null;
93
+ const app = await readAppMcpVersion(deps.appDir, deps);
94
+ if (!app || compareVersions(app, server) <= 0) return null;
95
+ return (
96
+ `Warning: this MCP server is @webjsdev/mcp@${server}, but this app has @webjsdev/mcp@${app}, ` +
97
+ 'so the brief and the tools below may be stale. ' +
98
+ 'Update whichever copy runs this server: a global install (npm i -g @webjsdev/mcp@latest, ' +
99
+ 'or bun add -g @webjsdev/mcp), the package cache behind npx @webjsdev/mcp, ' +
100
+ '@webjsdev/cli when the server is started as webjs mcp, or the checkout it runs from.'
101
+ );
102
+ }
103
+
104
+ /** What the server's other tools are for, so the agent reaches for the right one. */
105
+ const TOOL_MAP = [
106
+ '## The tools on this server',
107
+ '',
108
+ '- Before hand-writing auth, sessions, caching, rate limits, uploads, realtime',
109
+ ' or SEO, call `source` with no arguments: it lists what WebJs provides.',
110
+ '- For an exact contract, call `source` with `export` set to one export name',
111
+ ' (its signature plus doc comment), `path` to read a framework file, or',
112
+ ' `query` to grep the `@webjsdev/*` source.',
113
+ '- To see the live app, call `list_routes`, `list_actions` and',
114
+ ' `list_components`.',
115
+ '- To validate an edit, call `check`. Every violation carries its fix.',
116
+ '- Before hand-writing a page, a form, a dashboard or a chart, call `ui` to',
117
+ ' find a ready-made block in the `@webjsdev/ui` kit.',
118
+ ].join('\n');
119
+
120
+ /**
121
+ * The `init` tool output: an optional staleness warning, the brief, and the
122
+ * tool map.
123
+ *
124
+ * @param {{ appDir?: string, serverVersion?: string, readFile: Function, exists?: Function }} deps
125
+ * @returns {Promise<string>}
126
+ */
127
+ export async function initText(deps) {
128
+ const warning = await staleWarning(deps);
129
+ return [
130
+ '# WebJs: read first',
131
+ '',
132
+ ...(warning ? [warning, ''] : []),
133
+ BRIEF_HEADING,
134
+ '',
135
+ BRIEF,
136
+ '',
137
+ TOOL_MAP,
138
+ ].join('\n');
139
+ }
package/src/mcp-source.js CHANGED
@@ -27,6 +27,9 @@
27
27
 
28
28
  import { createRequire } from 'node:module';
29
29
  import { join, resolve, sep, relative } from 'node:path';
30
+ import { readdirSync, statSync } from 'node:fs';
31
+ import { featureCatalog } from './catalog.js';
32
+ import { guide, conventionPointer } from './guides.js';
30
33
 
31
34
  /** The published framework packages whose source an agent may want to read. */
32
35
  export const FRAMEWORK_PACKAGES = ['core', 'server', 'cli', 'intellisense', 'ui'];
@@ -91,6 +94,25 @@ export function resolveFrameworkRoots(cwd, fsDeps) {
91
94
  * @param {{ readdir: (d: string) => Array<{ name: string, isDir: boolean }> }} deps
92
95
  * @returns {string[]}
93
96
  */
97
+ /**
98
+ * The real directory listing `walkSource` and the source tool read, following
99
+ * a symlinked directory the way the module resolver does.
100
+ * @param {string} dir
101
+ * @returns {Array<{ name: string, isDir: boolean }>}
102
+ */
103
+ export function listDir(dir) {
104
+ return readdirSync(dir, { withFileTypes: true }).map((e) => {
105
+ let isDir = e.isDirectory();
106
+ // A symlinked directory is a directory: pnpm and workspace installs link
107
+ // a package (or its `src`) rather than copying it, and a Dirent of a link
108
+ // answers isDirectory() false, which skipped everything under it (#1686).
109
+ if (!isDir && e.isSymbolicLink()) {
110
+ try { isDir = statSync(join(dir, e.name)).isDirectory(); } catch { /* dangling */ }
111
+ }
112
+ return { name: e.name, isDir };
113
+ });
114
+ }
115
+
94
116
  export function walkSource(dir, deps) {
95
117
  /** @type {string[]} */
96
118
  const files = [];
@@ -114,21 +136,25 @@ export function walkSource(dir, deps) {
114
136
  }
115
137
 
116
138
  /**
117
- * No-args / `package` mode: list the resolved packages and their `src/`
118
- * top-level files (the entry points), so the agent has a map to grep or read.
139
+ * `package` mode: list the resolved packages and their `src/` top-level files
140
+ * (the entry points), so the agent has a map to grep or read. `readHint` is
141
+ * the next step the caller offers, since the MCP tool and the CLI read the
142
+ * listing with different commands.
119
143
  *
120
144
  * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readdir: Function }} deps
121
145
  * @param {string} [pkgFilter]
146
+ * @param {{ readHint?: string }} [opts]
122
147
  * @returns {string}
123
148
  */
124
- export function listSources(deps, pkgFilter) {
149
+ export function listSources(deps, pkgFilter, opts = {}) {
150
+ const readHint = opts.readHint ?? 'Read with `source({ path })` or search with `source({ query })`.';
125
151
  const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
126
152
  if (!roots.length) {
127
153
  return pkgFilter
128
154
  ? `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}`
129
155
  : 'No @webjsdev/* packages resolvable from here (run inside a webjs app or the monorepo).';
130
156
  }
131
- const lines = ['webjs framework authored source (buildless; server-side this runs directly, and core ships a built browser dist/ that is excluded here). Read with `source({ path })` or search with `source({ query })`.', ''];
157
+ const lines = [`webjs framework authored source (buildless; server-side this runs directly, and core ships a built browser dist/ that is excluded here). ${readHint}`, ''];
132
158
  for (const r of roots) {
133
159
  const dirName = r.src.split(sep).pop(); // 'src' for most, 'lib' for cli
134
160
  let entries = [];
@@ -325,9 +351,10 @@ function declarationFrom(lines, line, isTypes) {
325
351
  * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps
326
352
  * @param {string} name an export name, e.g. `createAuth`
327
353
  * @param {string} [pkgFilter] one of the framework packages, e.g. `server`
354
+ * @param {boolean} [caseRetried] internal: the case-swapped retry is already this call
328
355
  * @returns {Promise<string>}
329
356
  */
330
- export async function lookupExport(deps, name, pkgFilter) {
357
+ export async function lookupExport(deps, name, pkgFilter, caseRetried = false) {
331
358
  const n = String(name || '').trim();
332
359
  if (!/^[A-Za-z_$][\w$]*$/.test(n)) return 'Pass an export name (letters, digits, _ or $), e.g. `createAuth`.';
333
360
  const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
@@ -360,6 +387,15 @@ export async function lookupExport(deps, name, pkgFilter) {
360
387
  }
361
388
  const searched = deps.roots.map((r) => r.pkg).join(', ');
362
389
  if (!hits.length) {
390
+ // The name as written with another case: a convention is written lower
391
+ // camel (`generateMetadata`) and its type upper (`GenerateMetadata`).
392
+ const other = n[0] === n[0].toLowerCase() ? n[0].toUpperCase() + n.slice(1) : n[0].toLowerCase() + n.slice(1);
393
+ if (other !== n && !caseRetried) {
394
+ const cased = await lookupExport(deps, other, pkgFilter, true);
395
+ if (cased.startsWith('@webjsdev/')) return `"${n}" is not an export; its type is ${other}:\n${cased}`;
396
+ }
397
+ const members = await lookupMember(deps, roots, n);
398
+ if (members) return members;
363
399
  return `No export named "${n}" in the searched @webjsdev/* packages (${roots.map((r) => r.pkg).join(', ')}${pkgFilter ? `; resolvable: ${searched}` : ''}). Try \`query\` for a substring search, or \`path\` to read a file.`;
364
400
  }
365
401
  // The typed declarations are the answer; the JS doc is added only when the
@@ -377,10 +413,61 @@ export async function lookupExport(deps, name, pkgFilter) {
377
413
  return out.join('\n').trimEnd();
378
414
  }
379
415
 
416
+ /**
417
+ * A name that is no export but a MEMBER of an exported interface or class
418
+ * (`actionData` on `PageProps`, `fieldErrors` on `FormActionData`). Agents look
419
+ * those up by the name they write (#1686: `actionData` was the most grepped
420
+ * term across the eval builds), so the member's doc and declaration print,
421
+ * headed by the type that owns it.
422
+ * @param {{ readFile: Function, readdir: Function }} deps
423
+ * @param {Array<{ pkg: string, root: string }>} roots
424
+ * @param {string} n
425
+ * @returns {Promise<string | null>}
426
+ */
427
+ async function lookupMember(deps, roots, n) {
428
+ const memberRe = new RegExp(`^\\s+(?:readonly\\s+)?(?:static\\s+)?${n.replace(/\$/g, '\\$')}\\??\\s*[:(<]`);
429
+ const ownerRe = /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:interface|class|type)\s+([A-Za-z_$][\w$]*)/;
430
+ const out = [];
431
+ let count = 0;
432
+ for (const r of roots) {
433
+ for (const file of walkSource(r.root, deps).filter((f) => /\.d\.(?:ts|mts|cts)$/.test(f))) {
434
+ let text = '';
435
+ try { text = await deps.readFile(file, 'utf8'); } catch { continue; }
436
+ if (!text.includes(n)) continue;
437
+ const lines = text.split('\n');
438
+ const rel = relative(r.root, file).split(sep).join('/');
439
+ for (let i = 0; i < lines.length; i++) {
440
+ if (!memberRe.test(lines[i])) continue;
441
+ let owner = null;
442
+ for (let j = i - 1; j >= 0; j--) {
443
+ const m = ownerRe.exec(lines[j]);
444
+ if (m) { owner = m[1]; break; }
445
+ if (/^\S/.test(lines[j]) && !/^\s*(?:\*|\/\/|\/\*)/.test(lines[j]) && /^(?:}|export)/.test(lines[j])) break;
446
+ }
447
+ if (!owner) continue;
448
+ if (++count > MAX_EXPORT_HITS) continue;
449
+ const decl = [];
450
+ for (let k = i, depth = 0; k < lines.length && decl.length < MAX_DECL_LINES; k++) {
451
+ decl.push(lines[k]);
452
+ for (const ch of lines[k]) { if (ch === '{' || ch === '(') depth++; else if (ch === '}' || ch === ')') depth--; }
453
+ if (depth <= 0 && /[;,]\s*$/.test(lines[k])) break;
454
+ }
455
+ out.push(`"${n}" is a member of ${owner} (@webjsdev/${r.pkg} ${rel}:${i + 1}); \`source ${owner}\` prints the whole type.`);
456
+ out.push(...docAbove(lines, i), ...decl, '');
457
+ }
458
+ }
459
+ }
460
+ if (!out.length) return null;
461
+ if (count > MAX_EXPORT_HITS) out.push(`... (${count - MAX_EXPORT_HITS} more members named "${n}"; narrow with \`package\`)`);
462
+ return out.join('\n').trimEnd();
463
+ }
464
+
380
465
  /**
381
466
  * The `source` tool entry point. Dispatches on the args: `export` looks up one
382
- * export's signature and doc, `path` reads a file, `query` greps, otherwise
383
- * (or with `package`) lists the packages. PURE given `deps`
467
+ * export's signature and doc, `path` reads a file, `query` greps, `package`
468
+ * lists that package's entry points, and no args prints the feature catalogue
469
+ * (what WebJs provides, by need). An `export` that names a pattern guide
470
+ * (`modules`, `optimistic-ui`, ...) returns the guide. PURE given `deps`
384
471
  * (`{ roots, readFile, readdir }`).
385
472
  *
386
473
  * @param {object} deps
@@ -389,8 +476,16 @@ export async function lookupExport(deps, name, pkgFilter) {
389
476
  */
390
477
  export async function runSourceTool(deps, args) {
391
478
  const a = args || {};
392
- if (a.export) return lookupExport(deps, a.export, a.package);
479
+ // A guide name (`optimistic-ui`, `modules`) is a slug no package exports.
480
+ if (a.export) {
481
+ const g = a.package ? null : guide(a.export);
482
+ if (g) return g;
483
+ const out = await lookupExport(deps, a.export, a.package);
484
+ const pointer = conventionPointer(a.export);
485
+ return pointer ? `${out}\n\n${pointer}` : out;
486
+ }
393
487
  if (a.path) return readSource(deps, a.path);
394
488
  if (a.query) return grepSources(deps, a.query);
395
- return listSources(deps, a.package);
489
+ if (a.package) return listSources(deps, a.package);
490
+ return featureCatalog();
396
491
  }
package/src/mcp.js CHANGED
@@ -23,19 +23,13 @@
23
23
  import { createInterface } from 'node:readline';
24
24
  import { relative } from 'node:path';
25
25
 
26
- import {
27
- resolveDocsLocation,
28
- listResources,
29
- readResource,
30
- initText,
31
- searchDocs,
32
- PROMPTS,
33
- getPrompt,
34
- } from './mcp-docs.js';
35
- import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
26
+ import { initText } from './mcp-init.js';
27
+ import { resolveFrameworkRoots, runSourceTool, listDir } from './mcp-source.js';
36
28
  // The export lookup behind the `source` tool's `export` arg, re-exported for
37
29
  // `webjs source <Export>` in the CLI (#1623), which shells the same text.
38
- export { lookupExport, resolveFrameworkRoots } from './mcp-source.js';
30
+ export { lookupExport, resolveFrameworkRoots, listDir, listSources } from './mcp-source.js';
31
+ export { featureCatalog, FEATURE_CATALOG } from './catalog.js';
32
+ export { guide, GUIDES, GUIDE_ALIASES, conventionPointer } from './guides.js';
39
33
  import { projectRoutes } from './routes-report.js';
40
34
 
41
35
  const PROTOCOL_VERSION = '2024-11-05';
@@ -56,10 +50,9 @@ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'vali
56
50
  /**
57
51
  * The `appDir` property, shared by every tool that is scoped to one app.
58
52
  *
59
- * `init` and `docs` carry it too, not just the introspection tools: the docs
60
- * corpus is resolved from the app being asked about (#1319), so a tool that did
61
- * not ADVERTISE `appDir` could never receive one from a conforming client, and
62
- * the app-corpus rung would be reachable only from hand-written JSON-RPC.
53
+ * `init` carries it too: it compares the app's installed `@webjsdev/mcp` with
54
+ * the running server's to warn about a stale server (#1319), and a tool that did
55
+ * not ADVERTISE `appDir` could never receive one from a conforming client.
63
56
  */
64
57
  const APPDIR_PROP = {
65
58
  type: 'string',
@@ -73,33 +66,13 @@ const APPDIR_SCHEMA = {
73
66
  required: [],
74
67
  };
75
68
 
76
- /** `init` takes only the optional appDir, which selects whose docs corpus it reports. */
69
+ /** `init` takes only the optional appDir, whose installed server version it compares. */
77
70
  const INIT_SCHEMA = {
78
71
  type: 'object',
79
72
  properties: {
80
73
  appDir: {
81
74
  ...APPDIR_PROP,
82
- description: "App directory whose docs corpus to read. Defaults to the server cwd.",
83
- },
84
- },
85
- required: [],
86
- };
87
-
88
- /** `docs` takes an optional topic OR a free-text query, plus the optional appDir. */
89
- const DOCS_SCHEMA = {
90
- type: 'object',
91
- properties: {
92
- appDir: {
93
- ...APPDIR_PROP,
94
- description: "App directory whose docs corpus to read. Defaults to the server cwd.",
95
- },
96
- topic: {
97
- type: 'string',
98
- description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
99
- },
100
- query: {
101
- type: 'string',
102
- description: 'Free-text search across all webjs docs. Returns matching lines with their source.',
75
+ description: 'App directory the agent is editing, checked for a newer @webjsdev/mcp than this server. Defaults to the server cwd.',
103
76
  },
104
77
  },
105
78
  required: [],
@@ -148,36 +121,29 @@ const SOURCE_SCHEMA = {
148
121
  },
149
122
  package: {
150
123
  type: 'string',
151
- description: 'Limit a no-args listing to one package (core, server, cli, intellisense, ui).',
124
+ description: 'List one package\'s source entry points (core, server, cli, intellisense, ui).',
152
125
  },
153
126
  },
154
127
  required: [],
155
128
  };
156
129
 
157
130
  /**
158
- * The tools. The four introspection tools project an EXISTING @webjsdev/server
159
- * function (read-only, appDir-scoped). `init` + `docs` (#376) surface the
160
- * framework knowledge: `init` is the "read first" mental-model primer, `docs`
161
- * retrieves a doc by topic or searches the corpus. Descriptions are crisp so a
162
- * model picks the right tool without reading source.
131
+ * The tools. The introspection tools project an EXISTING @webjsdev/server
132
+ * function (read-only, appDir-scoped). `init` returns the WebJs brief, `source`
133
+ * reads the framework's own source, and `ui` reads the @webjsdev/ui kit.
134
+ * Descriptions are crisp so a model picks the right tool without reading source.
163
135
  */
164
136
  const TOOL_DEFS = [
165
137
  {
166
138
  name: 'init',
167
139
  description:
168
- 'READ THIS FIRST before writing or editing a webjs app. Returns the webjs mental model (NOT React/Next: no RSC, components hydrate but pages do not, signals-default state, the .server boundary) plus the invariants and the doc index. Read-only.',
140
+ 'READ THIS FIRST before writing or editing a WebJs app. Returns the WebJs brief (Next.js App Router routing plus Lit component APIs, imported from @webjsdev/*, with the few differences that matter) and what each other tool is for. Read-only.',
169
141
  inputSchema: INIT_SCHEMA,
170
142
  },
171
- {
172
- name: 'docs',
173
- description:
174
- 'Retrieve webjs framework docs: pass `topic` for a full doc (components, recipes, styling, built-ins, configuration, advanced, metadata, typescript, testing, lit-muscle-memory-gotchas, AGENTS, ...) or `query` to search the corpus. No args returns the topic index. Read-only.',
175
- inputSchema: DOCS_SCHEMA,
176
- },
177
143
  {
178
144
  name: 'source',
179
145
  description:
180
- 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `export` for the signature plus doc comment of ONE export (the cheap way to check a contract: `export: "createAuth"`), `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
146
+ 'Read the FRAMEWORK authored source (WebJs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `export` for the signature plus doc comment of ONE export (the cheap way to check a contract: `export: "createAuth"`), `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, `package` to list one package\'s entry points, or NO ARGS for the feature catalogue: what WebJs provides by need (auth, sessions, caching, rate limits, uploads, realtime, SEO, test helpers), each name one `export` call away from its signature and example. A file-convention name as `export` (such as `not-found` or `middleware.ts`) returns a short guide on how WebJs wants that built. Read-only.',
181
147
  inputSchema: SOURCE_SCHEMA,
182
148
  },
183
149
  {
@@ -204,12 +170,6 @@ const TOOL_DEFS = [
204
170
  'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
205
171
  inputSchema: APPDIR_SCHEMA,
206
172
  },
207
- {
208
- name: 'list_elision',
209
- description:
210
- 'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it always loses its elision verdict, its tag-to-module registry entry, and its preload hint (and never upgrades at all with no registration call, or when a computed-tag class has no importer that ships whole). Identical to `webjs elision --json`. Read-only.',
211
- inputSchema: APPDIR_SCHEMA,
212
- },
213
173
  {
214
174
  name: 'check',
215
175
  description:
@@ -345,7 +305,7 @@ export function extractActionConfig(src) {
345
305
  * JSON-serialisable projection of an existing server data function. All are
346
306
  * read-only.
347
307
  *
348
- * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, analyzeAppElision: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
308
+ * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
349
309
  */
350
310
  export function makeToolRunners(deps) {
351
311
  const {
@@ -353,7 +313,6 @@ export function makeToolRunners(deps) {
353
313
  buildActionIndex,
354
314
  hashFile,
355
315
  scanComponents,
356
- analyzeAppElision,
357
316
  checkConventions,
358
317
  projectCheck,
359
318
  readFile,
@@ -417,15 +376,6 @@ export function makeToolRunners(deps) {
417
376
  .sort((a, b) => a.tag.localeCompare(b.tag));
418
377
  },
419
378
 
420
- async list_elision(appDir) {
421
- // The whole report IS the contract: `analyzeAppElision` already returns
422
- // an app-relative, sorted, JSON-serializable object, so unlike
423
- // `list_routes` there is no projector leaf here and nothing to keep in
424
- // sync. `webjs elision --json` prints this same object, and a drift test
425
- // asserts the two are equal.
426
- return analyzeAppElision(appDir);
427
- },
428
-
429
379
  async check(appDir) {
430
380
  const violations = await checkConventions(appDir);
431
381
  return projectCheck(violations);
@@ -563,7 +513,6 @@ export async function runMcpServer(opts) {
563
513
  buildActionIndex: server.buildActionIndex,
564
514
  hashFile: server.hashFile,
565
515
  scanComponents: server.scanComponents,
566
- analyzeAppElision: server.analyzeAppElision,
567
516
  checkConventions: check.checkConventions,
568
517
  projectCheck,
569
518
  readFile,
@@ -571,47 +520,16 @@ export async function runMcpServer(opts) {
571
520
  }
572
521
  const runners = makeToolRunners(deps);
573
522
 
574
- // The docs corpus deps for the knowledge layer (#376): resources / prompts /
575
- // init / docs. Injectable for tests; otherwise resolved from the app's own
576
- // installed corpus, the bundled (published) snapshot, or the repo-root (dev)
577
- // docs, plus node fs.
578
- //
579
- // Resolved PER CALL and memoized by appDir (#1319), not once at boot. `appDir`
580
- // is a per-call tool argument, so a boot-time resolution would pin the corpus
581
- // to the launch directory forever and defeat the app-corpus rung for any host
582
- // that overrides it. `resources/list` and `resources/read` carry no appDir in
583
- // the MCP protocol, so they resolve from `cwd`, which is the same value a
584
- // `tools/call` with no `appDir` argument defaults to; the surfaces can only
585
- // diverge when a caller deliberately asks about a different app.
586
- let docsFs = null;
587
- if (!opts.docsDeps) {
523
+ // The `init` deps: fs readers for the stale-server check, which compares the
524
+ // app's installed @webjsdev/mcp with this server's version (#1319). Resolved
525
+ // per call from the tool's `appDir`. Injectable for tests via `initFs`.
526
+ let initFs = opts.initFs;
527
+ if (!initFs) {
588
528
  const { readFile } = await import('node:fs/promises');
589
- const { readdirSync, existsSync } = await import('node:fs');
590
- docsFs = { listDir: readdirSync, exists: existsSync, readFile };
529
+ const { existsSync } = await import('node:fs');
530
+ initFs = { readFile, exists: existsSync };
591
531
  }
592
- /** @type {Map<string, object>} */
593
- const docsDepsCache = new Map();
594
- /** The docs deps for one appDir. An injected `opts.docsDeps` wins for every appDir. */
595
- const docsDepsFor = (dir) => {
596
- if (opts.docsDeps) return opts.docsDeps;
597
- const key = typeof dir === 'string' ? dir : '';
598
- let built = docsDepsCache.get(key);
599
- if (!built) {
600
- const loc = resolveDocsLocation(import.meta.url, key);
601
- built = {
602
- docsDir: loc.docsDir,
603
- agentsPath: loc.agentsPath,
604
- skillPath: loc.skillPath,
605
- corpusPath: loc.corpusPath,
606
- corpusSource: loc.corpusSource,
607
- appDir: key,
608
- serverVersion: version,
609
- ...docsFs,
610
- };
611
- docsDepsCache.set(key, built);
612
- }
613
- return built;
614
- };
532
+ const initDepsFor = (dir) => ({ ...initFs, appDir: dir, serverVersion: version });
615
533
 
616
534
  // The `source` tool (#378): read the framework's own source from
617
535
  // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
@@ -619,8 +537,8 @@ export async function runMcpServer(opts) {
619
537
  let sourceDeps = opts.sourceDeps;
620
538
  if (!sourceDeps) {
621
539
  const { readFile } = await import('node:fs/promises');
622
- const { readdirSync, existsSync, realpathSync } = await import('node:fs');
623
- const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
540
+ const { existsSync, realpathSync } = await import('node:fs');
541
+ const readdir = listDir;
624
542
  sourceDeps = {
625
543
  roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
626
544
  readFile,
@@ -657,7 +575,7 @@ export async function runMcpServer(opts) {
657
575
  if (method === 'initialize') {
658
576
  return rpcResult(id, {
659
577
  protocolVersion: PROTOCOL_VERSION,
660
- capabilities: { tools: {}, resources: {}, prompts: {} },
578
+ capabilities: { tools: {} },
661
579
  serverInfo: { name: 'webjs', version },
662
580
  });
663
581
  }
@@ -675,39 +593,12 @@ export async function runMcpServer(opts) {
675
593
  });
676
594
  }
677
595
 
678
- // Knowledge layer (#376): the framework docs as MCP resources.
679
- if (method === 'resources/list') {
680
- return rpcResult(id, { resources: listResources(docsDepsFor(cwd)) });
681
- }
682
- if (method === 'resources/read') {
683
- const uri = ((msg && msg.params) || {}).uri;
684
- try {
685
- const r = await readResource(docsDepsFor(cwd), uri);
686
- return rpcResult(id, { contents: [r] });
687
- } catch (e) {
688
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
689
- }
690
- }
691
-
692
- // Knowledge layer (#376): the recipes as guided-workflow prompts.
693
- if (method === 'prompts/list') {
694
- return rpcResult(id, { prompts: PROMPTS });
695
- }
696
- if (method === 'prompts/get') {
697
- const params = (msg && msg.params) || {};
698
- try {
699
- return rpcResult(id, getPrompt(params.name, params.arguments));
700
- } catch (e) {
701
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
702
- }
703
- }
704
-
705
596
  if (method === 'tools/call') {
706
597
  const params = (msg && msg.params) || {};
707
598
  const name = params.name;
708
599
  const args = params.arguments || {};
709
- // The knowledge tools route to the docs / source layer; they return text.
710
- const isKnowledgeTool = name === 'init' || name === 'docs' || name === 'source';
600
+ // The knowledge tools (the brief, the framework source) return text.
601
+ const isKnowledgeTool = name === 'init' || name === 'source';
711
602
  // `ui` is kit-scoped (not app-scoped), so it is dispatched separately.
712
603
  const isUiTool = name === 'ui';
713
604
  if (!isKnowledgeTool && !isUiTool && !runners[name]) {
@@ -717,10 +608,8 @@ export async function runMcpServer(opts) {
717
608
  try {
718
609
  const result = isKnowledgeTool
719
610
  ? name === 'init'
720
- ? await initText(docsDepsFor(appDir))
721
- : name === 'docs'
722
- ? await searchDocs(docsDepsFor(appDir), args)
723
- : await runSourceTool(sourceDeps, args)
611
+ ? await initText(initDepsFor(appDir))
612
+ : await runSourceTool(sourceDeps, args)
724
613
  : isUiTool
725
614
  ? runUiTool(uiDeps, args)
726
615
  : await runners[name](appDir);