@webjsdev/mcp 0.1.13 → 0.1.15

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/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-source.js CHANGED
@@ -13,7 +13,8 @@
13
13
  * `src/` entry-point files.
14
14
  * - `query`: grep the framework `src/` trees, returning bounded `file:line`
15
15
  * hits (with a disclosed cap, no silent truncation).
16
- * - `path`: read one source file (e.g. `server/src/ssr.js`), traversal-guarded
16
+ * - `path`: read one source file (e.g. `server/src/ssr/head.js`; the bare
17
+ * `server/src/ssr.js` is a barrel over the sibling directory), traversal-guarded
17
18
  * to stay inside a resolved framework package root.
18
19
  *
19
20
  * READ-ONLY and side-effect-free: it only reads files, loads no module, and
@@ -209,7 +210,7 @@ export async function readSource(deps, path) {
209
210
  const pkg = segs[0];
210
211
  const entry = deps.roots.find((r) => r.pkg === pkg);
211
212
  if (!entry) {
212
- return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr.js.`;
213
+ return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr/head.js.`;
213
214
  }
214
215
  const abs = resolve(entry.root, segs.slice(1).join('/'));
215
216
  const srcLabel = entry.src.split(sep).pop();
@@ -237,17 +238,158 @@ export async function readSource(deps, path) {
237
238
  }
238
239
  }
239
240
 
241
+ /** Bound one export's rendered declaration (a long interface body is cut here). */
242
+ const MAX_DECL_LINES = 40;
243
+ /** Bound the hits one lookup returns (overloads plus a JS fallback stay well under this). */
244
+ const MAX_EXPORT_HITS = 8;
245
+
246
+ /**
247
+ * Collect the JSDoc block (or the `//` run) that ends on the line right above
248
+ * `line`, as the lines themselves, or `[]` when the declaration has no comment.
249
+ * @param {string[]} lines
250
+ * @param {number} line index of the declaration line
251
+ * @returns {string[]}
252
+ */
253
+ function docAbove(lines, line) {
254
+ let i = line - 1;
255
+ if (i < 0) return [];
256
+ if (lines[i].trim().endsWith('*/')) {
257
+ const end = i;
258
+ while (i >= 0 && !lines[i].trim().startsWith('/**')) {
259
+ if (lines[i].trim().startsWith('/*') && !lines[i].trim().startsWith('/**')) return [];
260
+ i--;
261
+ }
262
+ return i < 0 ? [] : lines.slice(i, end + 1);
263
+ }
264
+ const end = i;
265
+ while (i >= 0 && lines[i].trim().startsWith('//')) i--;
266
+ return i === end ? [] : lines.slice(i + 1, end + 1);
267
+ }
268
+
269
+ /**
270
+ * The declaration that starts on `line`: a `function` / `const` / `type`
271
+ * statement runs to its terminating `;` (a `.js` function stops at its opening
272
+ * brace, so the body is never printed); an `interface` / `class` runs to the
273
+ * brace that closes it. Capped at {@link MAX_DECL_LINES}.
274
+ * @param {string[]} lines
275
+ * @param {number} line
276
+ * @param {boolean} isTypes true for a `.d.ts` file
277
+ * @returns {string[]}
278
+ */
279
+ function declarationFrom(lines, line, isTypes) {
280
+ const first = lines[line];
281
+ const braced = /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:interface|class|enum)\b/.test(first)
282
+ || (/^export\s+(?:declare\s+)?(?:const|let|var|type)\b/.test(first) && /[{(]\s*$/.test(first) && !/;\s*$/.test(first));
283
+ const out = [];
284
+ let depth = 0;
285
+ // An authored `export const x = (...) => {` is its first line: the value is
286
+ // the implementation, and the doc above it is the contract.
287
+ if (!isTypes && /^export\s+(?:const|let|var)\b/.test(first)) return [first];
288
+ for (let i = line; i < lines.length && out.length < MAX_DECL_LINES; i++) {
289
+ const l = lines[i];
290
+ if (!isTypes && /^export\s+(?:async\s+)?function\b/.test(first)) {
291
+ // A JS function: the signature only, cut at the body's opening brace.
292
+ const cut = l.indexOf('{');
293
+ if (cut >= 0 && (i > line || !/^\s*\/\//.test(l))) { out.push(l.slice(0, cut).trimEnd()); return out; }
294
+ out.push(l);
295
+ continue;
296
+ }
297
+ out.push(l);
298
+ if (braced) {
299
+ for (const ch of l) { if (ch === '{') depth++; else if (ch === '}') depth--; }
300
+ if (depth <= 0 && /}/.test(l) && i > line) return out;
301
+ if (depth <= 0 && i === line && /}\s*;?\s*$/.test(l)) return out;
302
+ } else {
303
+ // Balanced parens and braces matter for a multi-line signature; angle
304
+ // brackets are not counted (an arrow's `=>` would unbalance them).
305
+ for (const ch of l) { if (ch === '(' || ch === '{') depth++; else if (ch === ')' || ch === '}') depth--; }
306
+ if (depth <= 0 && /;\s*$/.test(l)) return out;
307
+ if (depth <= 0 && !isTypes && /=\s*\(.*\)\s*=>/.test(first) && i === line) return out;
308
+ }
309
+ }
310
+ if (out.length >= MAX_DECL_LINES) out.push(` ... (cut at ${MAX_DECL_LINES} lines; read the file with \`path\` for the rest)`);
311
+ return out;
312
+ }
313
+
314
+ /**
315
+ * `export` mode: ONE export's signature plus its doc comment, so an agent
316
+ * checking a contract pays for the declaration instead of the whole file. The
317
+ * typed declarations win (`index.d.ts` and `src/**\/*.d.ts` under each package
318
+ * root, where `@webjsdev/server` keeps every export with its doc and
319
+ * `@webjsdev/core` keeps them beside the source); the authored `.js` is the
320
+ * fallback when no typed hit exists or none of them carries a doc, since the
321
+ * JSDoc there is the behaviour contract. Overloads are consecutive hits and
322
+ * all print. A miss discloses the searched packages (the #837 rule): nothing
323
+ * resolvable means NOTHING was searched, never "not found".
324
+ *
325
+ * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps
326
+ * @param {string} name an export name, e.g. `createAuth`
327
+ * @param {string} [pkgFilter] one of the framework packages, e.g. `server`
328
+ * @returns {Promise<string>}
329
+ */
330
+ export async function lookupExport(deps, name, pkgFilter) {
331
+ const n = String(name || '').trim();
332
+ if (!/^[A-Za-z_$][\w$]*$/.test(n)) return 'Pass an export name (letters, digits, _ or $), e.g. `createAuth`.';
333
+ const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
334
+ if (!deps.roots.length) {
335
+ return 'No @webjsdev/* source is resolvable here, so NOTHING was searched (run inside a webjs app or the monorepo). This is not the same as "not found".';
336
+ }
337
+ if (!roots.length) {
338
+ return `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ')}.`;
339
+ }
340
+ const typesRe = new RegExp(`^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:async\\s+)?(?:function\\*?|const|let|var|class|interface|type|enum)\\s+${n.replace(/\$/g, '\\$')}\\b`);
341
+ const jsRe = new RegExp(`^export\\s+(?:async\\s+)?(?:function\\*?|const|let|var|class)\\s+${n.replace(/\$/g, '\\$')}\\b`);
342
+ /** @type {Array<{ pkg: string, rel: string, line: number, doc: string[], decl: string[], types: boolean }>} */
343
+ const hits = [];
344
+ for (const r of roots) {
345
+ const typeFiles = walkSource(r.root, deps).filter((f) => /\.d\.(?:ts|mts|cts)$/.test(f));
346
+ const jsFiles = walkSource(r.src, deps).filter((f) => /\.(?:js|mjs)$/.test(f));
347
+ for (const [files, isTypes] of [[typeFiles, true], [jsFiles, false]]) {
348
+ for (const file of files) {
349
+ let text = '';
350
+ try { text = await deps.readFile(file, 'utf8'); } catch { continue; }
351
+ if (!text.includes(n)) continue;
352
+ const lines = text.split('\n');
353
+ const rel = relative(r.root, file).split(sep).join('/');
354
+ for (let i = 0; i < lines.length; i++) {
355
+ if (!(isTypes ? typesRe : jsRe).test(lines[i])) continue;
356
+ hits.push({ pkg: r.pkg, rel, line: i + 1, doc: docAbove(lines, i), decl: declarationFrom(lines, i, isTypes), types: isTypes });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ const searched = deps.roots.map((r) => r.pkg).join(', ');
362
+ if (!hits.length) {
363
+ 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
+ }
365
+ // The typed declarations are the answer; the JS doc is added only when the
366
+ // typed hits say nothing about behaviour (no doc on any of them), or there
367
+ // is no typed hit at all.
368
+ const typed = hits.filter((h) => h.types);
369
+ const typedHasDoc = typed.some((h) => h.doc.length);
370
+ const shown = typed.length && typedHasDoc ? typed : typed.length ? [...typed, ...hits.filter((h) => !h.types && h.doc.length)] : hits;
371
+ const out = [];
372
+ for (const h of shown.slice(0, MAX_EXPORT_HITS)) {
373
+ out.push(`@webjsdev/${h.pkg} ${h.rel}:${h.line}`);
374
+ out.push(...h.doc, ...h.decl, '');
375
+ }
376
+ if (shown.length > MAX_EXPORT_HITS) out.push(`... (${shown.length - MAX_EXPORT_HITS} more declarations; narrow with \`package\`)`);
377
+ return out.join('\n').trimEnd();
378
+ }
379
+
240
380
  /**
241
- * The `source` tool entry point. Dispatches on the args: `path` reads a file,
242
- * `query` greps, otherwise (or with `package`) lists the packages. PURE given
243
- * `deps` (`{ roots, readFile, readdir }`).
381
+ * 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`
384
+ * (`{ roots, readFile, readdir }`).
244
385
  *
245
386
  * @param {object} deps
246
- * @param {{ query?: string, path?: string, package?: string }} [args]
387
+ * @param {{ export?: string, query?: string, path?: string, package?: string }} [args]
247
388
  * @returns {Promise<string>}
248
389
  */
249
390
  export async function runSourceTool(deps, args) {
250
391
  const a = args || {};
392
+ if (a.export) return lookupExport(deps, a.export, a.package);
251
393
  if (a.path) return readSource(deps, a.path);
252
394
  if (a.query) return grepSources(deps, a.query);
253
395
  return listSources(deps, a.package);
package/src/mcp.js CHANGED
@@ -33,6 +33,9 @@ import {
33
33
  getPrompt,
34
34
  } from './mcp-docs.js';
35
35
  import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
36
+ // The export lookup behind the `source` tool's `export` arg, re-exported for
37
+ // `webjs source <Export>` in the CLI (#1623), which shells the same text.
38
+ export { lookupExport, resolveFrameworkRoots } from './mcp-source.js';
36
39
  import { projectRoutes } from './routes-report.js';
37
40
 
38
41
  const PROTOCOL_VERSION = '2024-11-05';
@@ -50,25 +53,46 @@ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'vali
50
53
  * into an agent-friendly shape. Descriptions are crisp so a model picks the
51
54
  * right tool without reading source.
52
55
  */
56
+ /**
57
+ * The `appDir` property, shared by every tool that is scoped to one app.
58
+ *
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.
63
+ */
64
+ const APPDIR_PROP = {
65
+ type: 'string',
66
+ description: 'App directory to introspect. Defaults to the server cwd.',
67
+ };
68
+
53
69
  /** The shared input schema for the introspection tools: an optional appDir override. */
54
70
  const APPDIR_SCHEMA = {
71
+ type: 'object',
72
+ properties: { appDir: { ...APPDIR_PROP } },
73
+ required: [],
74
+ };
75
+
76
+ /** `init` takes only the optional appDir, which selects whose docs corpus it reports. */
77
+ const INIT_SCHEMA = {
55
78
  type: 'object',
56
79
  properties: {
57
80
  appDir: {
58
- type: 'string',
59
- description: 'App directory to introspect. Defaults to the server cwd.',
81
+ ...APPDIR_PROP,
82
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
60
83
  },
61
84
  },
62
85
  required: [],
63
86
  };
64
87
 
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. */
88
+ /** `docs` takes an optional topic OR a free-text query, plus the optional appDir. */
69
89
  const DOCS_SCHEMA = {
70
90
  type: 'object',
71
91
  properties: {
92
+ appDir: {
93
+ ...APPDIR_PROP,
94
+ description: "App directory whose docs corpus to read. Defaults to the server cwd.",
95
+ },
72
96
  topic: {
73
97
  type: 'string',
74
98
  description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
@@ -97,9 +121,13 @@ const UI_SCHEMA = {
97
121
  const SOURCE_SCHEMA = {
98
122
  type: 'object',
99
123
  properties: {
124
+ export: {
125
+ type: 'string',
126
+ description: 'One export name, e.g. createAuth or optimistic: returns its signature plus the doc comment above it (from the typed declarations, with the authored JSDoc as the fallback), not the whole file. The cheapest way to check a contract.',
127
+ },
100
128
  path: {
101
129
  type: 'string',
102
- description: 'A framework source file to read, e.g. server/src/ssr.js or @webjsdev/core/src/render-client.js.',
130
+ description: 'A framework source file to read, e.g. server/src/ssr/head.js or @webjsdev/core/src/render-client/parts.js. The top-level ssr.js / render-client.js paths are barrels; the code is in the sibling directory.',
103
131
  },
104
132
  query: {
105
133
  type: 'string',
@@ -136,7 +164,7 @@ const TOOL_DEFS = [
136
164
  {
137
165
  name: 'source',
138
166
  description:
139
- '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 `path` to read a file (e.g. server/src/ssr.js), `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.',
167
+ '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.',
140
168
  inputSchema: SOURCE_SCHEMA,
141
169
  },
142
170
  {
@@ -500,22 +528,46 @@ export async function runMcpServer(opts) {
500
528
  const runners = makeToolRunners(deps);
501
529
 
502
530
  // 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);
531
+ // init / docs. Injectable for tests; otherwise resolved from the app's own
532
+ // installed corpus, the bundled (published) snapshot, or the repo-root (dev)
533
+ // docs, plus node fs.
534
+ //
535
+ // Resolved PER CALL and memoized by appDir (#1319), not once at boot. `appDir`
536
+ // is a per-call tool argument, so a boot-time resolution would pin the corpus
537
+ // to the launch directory forever and defeat the app-corpus rung for any host
538
+ // that overrides it. `resources/list` and `resources/read` carry no appDir in
539
+ // the MCP protocol, so they resolve from `cwd`, which is the same value a
540
+ // `tools/call` with no `appDir` argument defaults to; the surfaces can only
541
+ // diverge when a caller deliberately asks about a different app.
542
+ let docsFs = null;
543
+ if (!opts.docsDeps) {
508
544
  const { readFile } = await import('node:fs/promises');
509
545
  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
- };
546
+ docsFs = { listDir: readdirSync, exists: existsSync, readFile };
518
547
  }
548
+ /** @type {Map<string, object>} */
549
+ const docsDepsCache = new Map();
550
+ /** The docs deps for one appDir. An injected `opts.docsDeps` wins for every appDir. */
551
+ const docsDepsFor = (dir) => {
552
+ if (opts.docsDeps) return opts.docsDeps;
553
+ const key = typeof dir === 'string' ? dir : '';
554
+ let built = docsDepsCache.get(key);
555
+ if (!built) {
556
+ const loc = resolveDocsLocation(import.meta.url, key);
557
+ built = {
558
+ docsDir: loc.docsDir,
559
+ agentsPath: loc.agentsPath,
560
+ skillPath: loc.skillPath,
561
+ corpusPath: loc.corpusPath,
562
+ corpusSource: loc.corpusSource,
563
+ appDir: key,
564
+ serverVersion: version,
565
+ ...docsFs,
566
+ };
567
+ docsDepsCache.set(key, built);
568
+ }
569
+ return built;
570
+ };
519
571
 
520
572
  // The `source` tool (#378): read the framework's own source from
521
573
  // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
@@ -581,12 +633,12 @@ export async function runMcpServer(opts) {
581
633
 
582
634
  // Knowledge layer (#376): the framework docs as MCP resources.
583
635
  if (method === 'resources/list') {
584
- return rpcResult(id, { resources: listResources(docsDeps) });
636
+ return rpcResult(id, { resources: listResources(docsDepsFor(cwd)) });
585
637
  }
586
638
  if (method === 'resources/read') {
587
639
  const uri = ((msg && msg.params) || {}).uri;
588
640
  try {
589
- const r = await readResource(docsDeps, uri);
641
+ const r = await readResource(docsDepsFor(cwd), uri);
590
642
  return rpcResult(id, { contents: [r] });
591
643
  } catch (e) {
592
644
  return rpcError(id, -32602, e && e.message ? e.message : String(e));
@@ -621,9 +673,9 @@ export async function runMcpServer(opts) {
621
673
  try {
622
674
  const result = isKnowledgeTool
623
675
  ? name === 'init'
624
- ? await initText(docsDeps)
676
+ ? await initText(docsDepsFor(appDir))
625
677
  : name === 'docs'
626
- ? await searchDocs(docsDeps, args)
678
+ ? await searchDocs(docsDepsFor(appDir), args)
627
679
  : await runSourceTool(sourceDeps, args)
628
680
  : isUiTool
629
681
  ? runUiTool(uiDeps, args)