@webjsdev/mcp 0.1.18 → 0.1.19

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 DELETED
@@ -1,643 +0,0 @@
1
- /**
2
- * The knowledge + authoring layer for `webjs mcp` (#376).
3
- *
4
- * `webjs mcp` (lib/mcp.js) started as four READ-ONLY introspection tools. This
5
- * module adds the second layer the Next.js MCP (`next-devtools-mcp`) showed is
6
- * the high-leverage one: the framework knowledge an agent needs to author
7
- * idiomatic WebJs code, surfaced as MCP RESOURCES (the WebJs skill references
8
- * plus the `AGENTS.md` contract), an `init` "read first" primer that fights the
9
- * React/RSC mental model, a `docs` retrieval tool, and guided-workflow PROMPTS
10
- * built from the recipes.
11
- *
12
- * It stays hand-rolled and ZERO-dependency (no `@modelcontextprotocol/sdk`),
13
- * consistent with WebJs being buildless + minimal-deps. Everything here is PURE
14
- * given its injected `{ docsDir, agentsPath, readFile }` deps, so it is testable
15
- * in-process without booting a server or touching the real filesystem.
16
- *
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).
26
- *
27
- * @module mcp-docs
28
- */
29
-
30
- import { existsSync } from 'node:fs';
31
- import { dirname, join, resolve } from 'node:path';
32
- import { fileURLToPath } from 'node:url';
33
-
34
- /** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
35
- const DOCS_SCHEME = 'webjs-docs://';
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
-
55
- /**
56
- * Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
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).
82
- *
83
- * @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
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' }}
86
- */
87
- export function resolveDocsLocation(moduleUrl, appDir) {
88
- const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
89
- const pkgRoot = resolve(here, '..'); // packages/mcp/src -> packages/mcp
90
- const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
91
-
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');
95
- }
96
- const bundledRoot = join(pkgRoot, 'resources');
97
- if (existsSync(join(bundledRoot, 'references'))) return bundleAt(bundledRoot, 'bundled');
98
-
99
- const skill = join(repoRoot, '.agents', 'skills', 'webjs');
100
- return {
101
- docsDir: join(skill, 'references'),
102
- agentsPath: join(repoRoot, 'AGENTS.md'),
103
- skillPath: join(skill, 'SKILL.md'),
104
- corpusPath: null,
105
- corpusSource: 'repo',
106
- };
107
- }
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
-
261
- /**
262
- * A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
263
- * `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
264
- *
265
- * @param {string} file
266
- * @returns {string}
267
- */
268
- function docName(file) {
269
- return file.replace(/\.md$/i, '');
270
- }
271
-
272
- /**
273
- * A human title for a doc name: `lit-muscle-memory-gotchas` ->
274
- * `Lit Muscle Memory Gotchas`. Used in the resource listing so a model picks
275
- * the right doc without reading it.
276
- *
277
- * @param {string} name
278
- * @returns {string}
279
- */
280
- function titleFor(name) {
281
- if (name === 'AGENTS') return 'AGENTS.md (the framework contract + invariants)';
282
- if (name === 'SKILL') return 'SKILL.md (the WebJs agent skill: how to build)';
283
- return name
284
- .split('-')
285
- .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
286
- .join(' ');
287
- }
288
-
289
- /**
290
- * The corpus catalogue: every servable doc as `{ name, file, uri, title }`.
291
- * Reads the docs dir listing (so it tracks the shipped set) plus the `AGENTS.md`
292
- * contract. PURE given `deps`.
293
- *
294
- * @param {{ docsDir: string, agentsPath: string, listDir: (d: string) => string[], exists: (p: string) => boolean }} deps
295
- * @returns {Array<{ name: string, file: string, uri: string, title: string }>}
296
- */
297
- export function catalogue(deps) {
298
- const { docsDir, agentsPath, skillPath, listDir, exists } = deps;
299
- /** @type {Array<{ name: string, file: string, uri: string, title: string }>} */
300
- const out = [];
301
- if (exists(agentsPath)) {
302
- out.push({ name: 'AGENTS', file: agentsPath, uri: `${DOCS_SCHEME}AGENTS`, title: titleFor('AGENTS') });
303
- }
304
- if (skillPath && exists(skillPath)) {
305
- out.push({ name: 'SKILL', file: skillPath, uri: `${DOCS_SCHEME}SKILL`, title: titleFor('SKILL') });
306
- }
307
- let files = [];
308
- try { files = listDir(docsDir).filter((f) => /\.md$/i.test(f)).sort(); } catch { files = []; }
309
- for (const f of files) {
310
- const name = docName(f);
311
- out.push({ name, file: join(docsDir, f), uri: `${DOCS_SCHEME}${name}`, title: titleFor(name) });
312
- }
313
- return out;
314
- }
315
-
316
- /**
317
- * MCP `resources/list`: the corpus as resource descriptors.
318
- *
319
- * @param {object} deps
320
- * @returns {Array<{ uri: string, name: string, title: string, mimeType: string }>}
321
- */
322
- export function listResources(deps) {
323
- return catalogue(deps).map((d) => ({
324
- uri: d.uri,
325
- name: d.name,
326
- title: d.title,
327
- mimeType: 'text/markdown',
328
- }));
329
- }
330
-
331
- /**
332
- * MCP `resources/read`: the markdown text for a `webjs-docs://<name>` URI.
333
- * Throws a clear Error for an unknown URI (the dispatcher maps it to a JSON-RPC
334
- * error, never a crash).
335
- *
336
- * @param {object} deps
337
- * @param {string} uri
338
- * @returns {Promise<{ uri: string, mimeType: string, text: string }>}
339
- */
340
- export async function readResource(deps, uri) {
341
- const entry = catalogue(deps).find((d) => d.uri === uri);
342
- if (!entry) throw new Error(`Unknown resource: ${uri}`);
343
- const text = await deps.readFile(entry.file, 'utf8');
344
- return { uri, mimeType: 'text/markdown', text };
345
- }
346
-
347
- /**
348
- * Extract a `## <heading>` section (heading line through the line before the
349
- * next same-or-higher-level heading) from a markdown doc. Used to source the
350
- * `init` primer from `AGENTS.md` rather than hand-duplicating it (so it cannot
351
- * drift). Returns '' when the heading is absent.
352
- *
353
- * @param {string} md
354
- * @param {RegExp} headingRe matches the section's heading LINE (e.g. /^##\s+Execution model/m)
355
- * @returns {string}
356
- */
357
- export function sectionByHeading(md, headingRe) {
358
- const lines = md.split('\n');
359
- let start = -1;
360
- let level = 0;
361
- for (let i = 0; i < lines.length; i++) {
362
- if (headingRe.test(lines[i])) {
363
- start = i;
364
- level = (lines[i].match(/^#+/) || ['##'])[0].length;
365
- break;
366
- }
367
- }
368
- if (start === -1) return '';
369
- let end = lines.length;
370
- for (let i = start + 1; i < lines.length; i++) {
371
- const m = lines[i].match(/^(#+)\s/);
372
- if (m && m[1].length <= level) { end = i; break; }
373
- }
374
- return lines.slice(start, end).join('\n').trim();
375
- }
376
-
377
- /**
378
- * The `init` tool output: the "read first" orientation that fights the
379
- * React/RSC mental model. Sources the EXECUTION MODEL + INVARIANTS sections
380
- * from the shipped `AGENTS.md` (no hand-duplication), prepends a short router
381
- * to the highest-value resources, and lists the corpus so the agent knows what
382
- * else it can pull. PURE given `deps`.
383
- *
384
- * @param {object} deps
385
- * @returns {Promise<string>}
386
- */
387
- export async function initText(deps) {
388
- let agents = '';
389
- try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
390
- const corpus = await corpusLine(deps);
391
- const warning = await staleWarning(deps);
392
- const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
393
- const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
394
-
395
- const cat = catalogue(deps);
396
- const resourceList = cat.map((d) => `- \`${d.uri}\` (${d.title})`).join('\n');
397
-
398
- const router = [
399
- 'You are about to write or edit a webjs app. Read this orientation FIRST, then',
400
- 'pull the specific docs you need via the `docs` tool or the `webjs-docs://*`',
401
- 'resources. webjs is web-components-first and looks like Lit + Rails, NOT React/Next:',
402
- 'there is NO RSC, no server/client component split, no `use client`. Components',
403
- 'hydrate (islands); pages and layouts do NOT hydrate. State works as in Lit',
404
- '(`state: true` props) alongside signals. Server-only code lives behind the',
405
- '`.server.{js,ts}` boundary.',
406
- 'When writing a component, read `webjs-docs://lit-muscle-memory-gotchas` first:',
407
- 'the Lit habits that break webjs SSR/reactivity each have a webjs-shaped fix there.',
408
- '',
409
- 'webjs is buildless: the authored framework source is readable JSDoc in',
410
- '`node_modules/@webjsdev/<pkg>/src`, and server-side that source runs directly.',
411
- '(The one built artifact is the `@webjsdev/core` BROWSER bundle in `dist/`;',
412
- 'its authored source is still in `src/`.) When the docs do not answer something,',
413
- 'use the `source` tool to grep or read that real `src/` source (it skips `dist/`).',
414
- ].join('\n');
415
-
416
- const parts = [
417
- '# webjs: read first',
418
- '',
419
- // Provenance first, so an agent reading a months-old global install sees
420
- // which docs it is holding before it reads a word of them (#1319).
421
- ...(warning ? [warning, ''] : []),
422
- corpus,
423
- '',
424
- router,
425
- '',
426
- execModel || '(execution-model section unavailable)',
427
- '',
428
- invariants || '(invariants section unavailable)',
429
- '',
430
- '## Available docs (read via the `docs` tool or these resources)',
431
- '',
432
- resourceList || '(no docs bundled)',
433
- ];
434
- return parts.join('\n');
435
- }
436
-
437
- /**
438
- * The `docs` tool. With `topic` matching a corpus name, returns that doc's full
439
- * text. With `query`, keyword-searches every doc and returns the matching lines
440
- * (each tagged with its source URI and nearest heading). With neither, returns
441
- * the topic index (the catalogue). PURE given `deps`.
442
- *
443
- * @param {object} deps
444
- * @param {{ topic?: string, query?: string }} [args]
445
- * @returns {Promise<string>}
446
- */
447
- export async function searchDocs(deps, args) {
448
- const { topic, query } = args || {};
449
- const cat = catalogue(deps);
450
-
451
- if (topic) {
452
- const entry = cat.find((d) => d.name.toLowerCase() === String(topic).toLowerCase());
453
- if (!entry) {
454
- const names = cat.map((d) => d.name).join(', ');
455
- return `Unknown topic "${topic}". Available topics: ${names}`;
456
- }
457
- return await deps.readFile(entry.file, 'utf8');
458
- }
459
-
460
- if (query) {
461
- const q = String(query).toLowerCase();
462
- const MAX_HITS = 40;
463
- /** @type {string[]} */
464
- const hits = [];
465
- let capped = false;
466
- outer: for (const entry of cat) {
467
- let text = '';
468
- try { text = await deps.readFile(entry.file, 'utf8'); } catch { continue; }
469
- const lines = text.split('\n');
470
- for (let i = 0; i < lines.length; i++) {
471
- if (!lines[i].toLowerCase().includes(q)) continue;
472
- // Check the cap BEFORE pushing, so `capped` means a match BEYOND the cap
473
- // exists (exactly MAX_HITS matches is NOT a truncation, nothing dropped).
474
- if (hits.length >= MAX_HITS) { capped = true; break outer; }
475
- // Nearest preceding heading for context.
476
- let heading = '';
477
- for (let j = i; j >= 0; j--) {
478
- if (/^#+\s/.test(lines[j])) { heading = lines[j].replace(/^#+\s/, ''); break; }
479
- }
480
- hits.push(`[${entry.uri}] ${heading ? heading + ': ' : ''}${lines[i].trim()}`);
481
- }
482
- }
483
- if (!hits.length) return `No matches for "${query}" in the webjs docs. Topics: ${cat.map((d) => d.name).join(', ')}`;
484
- // Disclose truncation rather than silently capping (no silent caps).
485
- if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; refine the query or open a doc with \`topic\`)`);
486
- return hits.join('\n');
487
- }
488
-
489
- // No args: the topic index.
490
- return ['webjs docs topics (pass one as `topic`, or `query` to search):', '', ...cat.map((d) => `- ${d.name}: ${d.title}`)].join('\n');
491
- }
492
-
493
- /**
494
- * The guided-workflow PROMPTS, built from the recipes. Each is a single-message
495
- * prompt that hands the agent the canonical WebJs recipe plus the invariants it
496
- * must not break, then tells it to pull `webjs-docs://recipes` for the full set.
497
- * Static metadata; {@link getPrompt} fills the message text.
498
- */
499
- export const PROMPTS = [
500
- { name: 'add_page', description: 'Scaffold a webjs page (app/<segment>/page.ts), the idiomatic way.', arguments: [{ name: 'route', description: 'URL path, e.g. /about', required: false }] },
501
- { name: 'add_dynamic_route', description: 'Scaffold a dynamic page reading params, e.g. app/users/[id]/page.ts.', arguments: [{ name: 'route', description: 'URL path with a [param], e.g. /users/[id]', required: false }] },
502
- { name: 'add_server_action', description: 'Scaffold a server action (.server.ts + use server) called from a component.', arguments: [{ name: 'feature', description: 'Feature/module name', required: false }] },
503
- { name: 'add_component', description: 'Scaffold an interactive WebComponent (signals, light DOM, register).', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. my-thing', required: false }] },
504
- { name: 'fetch_data_in_component', description: 'Fetch server data IN a component with async render() (co-located, first-paint data), plus webjs-suspense streaming and renderFallback.', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. user-profile', required: false }] },
505
- { name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
506
- ];
507
-
508
- /**
509
- * The canonical recipe snippet + invariant reminders for each prompt. Kept
510
- * compact on purpose: the prompt orients + shows the shape, then points at the
511
- * full `webjs-docs://recipes` resource. The shapes mirror `.agents/skills/webjs/references/data-and-actions.md`.
512
- *
513
- * @type {Record<string, string>}
514
- */
515
- const PROMPT_BODIES = {
516
- add_page: [
517
- 'Add a webjs page. A page is `app/<segment>/page.ts` whose DEFAULT export is a',
518
- '(possibly async) function returning a `TemplateResult`; it runs ONLY on the server.',
519
- '',
520
- '```ts',
521
- "import { html } from '@webjsdev/core';",
522
- 'export default function About() {',
523
- ' return html`<h1>About</h1>`;',
524
- '}',
525
- '```',
526
- '',
527
- 'Rules: the default export is a FUNCTION (invariant 6), it does NOT call render().',
528
- 'For interactivity, render a component tag inside it (pages do not hydrate).',
529
- 'Name metadata via a `metadata` / `generateMetadata` named export.',
530
- ].join('\n'),
531
- add_dynamic_route: [
532
- 'Add a dynamic page. `app/users/[id]/page.ts` receives `{ params }`; fetch data',
533
- 'through a server action or query, never by importing the DB directly.',
534
- '',
535
- '```ts',
536
- 'export default async function User({ params }: { params: { id: string } }) {',
537
- ' const user = await getUser(params.id); // a `use server` action / query',
538
- ' return html`<h1>${user.name}</h1>`;',
539
- '}',
540
- '```',
541
- '',
542
- 'Catch-all is `[...rest]`, optional catch-all `[[...rest]]`. Server-only data',
543
- 'access goes through `.server.{js,ts}`, never a direct import into the page.',
544
- ].join('\n'),
545
- add_server_action: [
546
- "Add a server action. A `*.server.ts` file with `'use server'` exports async",
547
- 'functions that round-trip serializer-safe values; a client import is rewritten',
548
- 'to a typed RPC stub (never hand-write fetch).',
549
- '',
550
- '```ts',
551
- '// modules/<feature>/actions/<name>.server.ts',
552
- "'use server';",
553
- "import { db } from '../../../db/connection.server.ts';",
554
- "import { things } from '../../../db/schema.server.ts';",
555
- 'export async function doThing(input: { name: string }) {',
556
- " const name = String(input?.name || '').trim();",
557
- " if (!name) return { success: false, error: 'name required', status: 400 };",
558
- ' const [row] = await db.insert(things).values({ name }).returning();',
559
- ' return { success: true, data: row };',
560
- '}',
561
- '```',
562
- '',
563
- 'Return the `ActionResult<T>` envelope. Server-only code MUST stay in `.server.*`',
564
- '(invariant 1). Call it from a component via a normal import.',
565
- ].join('\n'),
566
- add_component: [
567
- 'Add an interactive WebComponent. One custom element per file; register at module',
568
- 'top level. Hold state in a `state: true` prop (reassign it) or a signal (read with',
569
- '`.get()`, write with `.set()`).',
570
- '',
571
- '```ts',
572
- "import { WebComponent, html, signal } from '@webjsdev/core';",
573
- 'export class MyThing extends WebComponent {',
574
- ' count = signal(0);',
575
- ' render() {',
576
- ' return html`<button @click=${() => this.count.set(this.count.get() + 1)}>',
577
- ' ${this.count.get()}</button>`;',
578
- ' }',
579
- '}',
580
- "MyThing.register('my-thing');",
581
- '```',
582
- '',
583
- 'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
584
- 'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
585
- 'a class-field initializer that overwrites a reactive accessor breaks reactivity',
586
- '(declare reactive props via the `extends WebComponent({ … })` factory).',
587
- ].join('\n'),
588
- fetch_data_in_component: [
589
- 'Fetch server data IN the component that needs it, with an async render() (#469).',
590
- 'No prop-drilling: SSR awaits the render, so the DATA is in the first paint.',
591
- '',
592
- '```ts',
593
- 'class UserProfile extends WebComponent({ uid: String }) {',
594
- ' async render() {',
595
- ' const u = await getUser(this.uid); // a `use server` action: real fn at SSR, RPC stub on the client',
596
- ' return html`<h3>${u.name}</h3>`;',
597
- ' }',
598
- '}',
599
- "UserProfile.register('user-profile');",
600
- '```',
601
- '',
602
- 'Decoupled model: SSR BLOCKS by default (real data, no fallback). The client',
603
- 're-fetch default is stale-while-revalidate. `renderFallback()` is the OPTIONAL',
604
- 're-fetch loading state (re-fetch only, NEVER first paint). Errors are isolated',
605
- 'per component by default (add `renderError()` only to customize). For SLOW data,',
606
- 'wrap in `<webjs-suspense .fallback=${html`...`}>` to STREAM it (fallback first).',
607
- 'Keep `Task` / signals for client-only data (it shows pending at SSR). Read',
608
- '`webjs-docs://components` for the full decision guide and anti-patterns.',
609
- ].join('\n'),
610
- add_module: [
611
- 'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
612
- '`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
613
- '`types.ts`. One function per action/query file. Routes stay thin: extract anything',
614
- 'over ~20 lines into a module action. Shared presentational primitives go in the',
615
- 'top-level `components/`, cross-cutting infra in `lib/*.server.ts`.',
616
- ].join('\n'),
617
- };
618
-
619
- /**
620
- * MCP `prompts/get`: the messages for a guided-workflow prompt. Throws for an
621
- * unknown name (mapped to a JSON-RPC error).
622
- *
623
- * @param {string} name
624
- * @param {Record<string, string>} [args]
625
- * @returns {{ description: string, messages: Array<{ role: string, content: { type: string, text: string } }> }}
626
- */
627
- export function getPrompt(name, args) {
628
- const meta = PROMPTS.find((p) => p.name === name);
629
- const body = PROMPT_BODIES[name];
630
- if (!meta || !body) throw new Error(`Unknown prompt: ${name}`);
631
-
632
- // Fold any provided argument values in as a one-line hint at the top.
633
- const provided = Object.entries(args || {}).filter(([, v]) => v != null && v !== '');
634
- const argLine = provided.length
635
- ? `Context: ${provided.map(([k, v]) => `${k}=${v}`).join(', ')}.\n\n`
636
- : '';
637
-
638
- const text = `${argLine}${body}\n\nSee \`webjs-docs://SKILL\` for the full guide (it routes to the reference for each task) and \`webjs-docs://AGENTS\` for the invariants.`;
639
- return {
640
- description: meta.description,
641
- messages: [{ role: 'user', content: { type: 'text', text } }],
642
- };
643
- }