@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.
- package/README.md +18 -99
- package/package.json +5 -10
- package/src/brief.js +52 -0
- package/src/catalog.js +82 -0
- package/src/guides.js +319 -0
- package/src/mcp-init.js +139 -0
- package/src/mcp-source.js +104 -9
- package/src/mcp.js +33 -144
- package/resources/AGENTS.md +0 -704
- package/resources/SKILL.md +0 -287
- package/resources/corpus.json +0 -6
- package/resources/references/auth-and-sessions.md +0 -300
- package/resources/references/built-ins.md +0 -346
- package/resources/references/client-router-and-streaming.md +0 -317
- package/resources/references/components.md +0 -505
- package/resources/references/data-and-actions.md +0 -325
- package/resources/references/module-structure.md +0 -229
- package/resources/references/muscle-memory-gotchas.md +0 -362
- package/resources/references/optimistic-ui.md +0 -179
- package/resources/references/routing-and-pages.md +0 -323
- package/resources/references/runtime.md +0 -113
- package/resources/references/service-worker.md +0 -80
- package/resources/references/styling.md +0 -312
- package/resources/references/testing.md +0 -223
- package/resources/references/typescript.md +0 -231
- package/resources/references/ui-kit.md +0 -161
- package/src/mcp-docs.js +0 -643
package/src/mcp-init.js
ADDED
|
@@ -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
|
-
*
|
|
118
|
-
*
|
|
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 = [
|
|
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,
|
|
383
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
60
|
-
*
|
|
61
|
-
* not ADVERTISE `appDir` could never receive one from a conforming client
|
|
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,
|
|
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:
|
|
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: '
|
|
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
|
|
159
|
-
* function (read-only, appDir-scoped). `init`
|
|
160
|
-
* framework
|
|
161
|
-
*
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
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 {
|
|
590
|
-
|
|
529
|
+
const { existsSync } = await import('node:fs');
|
|
530
|
+
initFs = { readFile, exists: existsSync };
|
|
591
531
|
}
|
|
592
|
-
|
|
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 {
|
|
623
|
-
const readdir =
|
|
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: {}
|
|
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
|
|
710
|
-
const isKnowledgeTool = name === 'init' || name === '
|
|
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(
|
|
721
|
-
:
|
|
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);
|