@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/README.md +63 -6
- package/package.json +1 -1
- package/resources/AGENTS.md +100 -36
- package/resources/SKILL.md +59 -20
- package/resources/corpus.json +3 -3
- package/resources/references/auth-and-sessions.md +15 -0
- package/resources/references/built-ins.md +96 -4
- package/resources/references/client-router-and-streaming.md +54 -5
- package/resources/references/components.md +62 -6
- package/resources/references/data-and-actions.md +32 -3
- package/resources/references/module-structure.md +229 -0
- package/resources/references/muscle-memory-gotchas.md +55 -5
- package/resources/references/optimistic-ui.md +23 -11
- package/resources/references/routing-and-pages.md +124 -5
- package/resources/references/runtime.md +24 -5
- package/resources/references/styling.md +62 -4
- package/resources/references/testing.md +13 -0
- package/resources/references/ui-kit.md +54 -3
- package/src/mcp-docs.js +222 -19
- package/src/mcp-source.js +148 -6
- package/src/mcp.js +77 -25
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/
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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: `
|
|
242
|
-
*
|
|
243
|
-
* `
|
|
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
|
-
|
|
59
|
-
description:
|
|
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
|
-
/** `
|
|
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
|
|
504
|
-
// (published) or repo-root (dev)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
676
|
+
? await initText(docsDepsFor(appDir))
|
|
625
677
|
: name === 'docs'
|
|
626
|
-
? await searchDocs(
|
|
678
|
+
? await searchDocs(docsDepsFor(appDir), args)
|
|
627
679
|
: await runSourceTool(sourceDeps, args)
|
|
628
680
|
: isUiTool
|
|
629
681
|
? runUiTool(uiDeps, args)
|