@webjsdev/cli 0.10.11 → 0.10.12
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/lib/mcp-docs.js +400 -0
- package/lib/mcp-source.js +244 -0
- package/lib/mcp.js +167 -18
- package/package.json +7 -2
- package/resources/AGENTS.md +404 -0
- package/resources/agent-docs/advanced.md +1090 -0
- package/resources/agent-docs/built-ins.md +367 -0
- package/resources/agent-docs/components.md +486 -0
- package/resources/agent-docs/configuration.md +207 -0
- package/resources/agent-docs/framework-dev.md +65 -0
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +456 -0
- package/resources/agent-docs/metadata.md +334 -0
- package/resources/agent-docs/recipes.md +440 -0
- package/resources/agent-docs/service-worker.md +100 -0
- package/resources/agent-docs/ssr-partial-nav-design.md +214 -0
- package/resources/agent-docs/styling.md +235 -0
- package/resources/agent-docs/testing.md +372 -0
- package/resources/agent-docs/typescript.md +334 -0
package/lib/mcp-docs.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
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 `agent-docs/*.md` corpus
|
|
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/cli mcp` is self-contained): a published
|
|
18
|
+
* install reads the corpus bundled under `<cli>/resources/agent-docs` (copied
|
|
19
|
+
* at `prepack`, see `scripts/copy-mcp-resources.js`); a monorepo dev run falls
|
|
20
|
+
* back to the repo-root `agent-docs/`. {@link resolveDocsLocation} encodes that
|
|
21
|
+
* two-path lookup so source stays single (no committed duplicate docs).
|
|
22
|
+
*
|
|
23
|
+
* @module mcp-docs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
/** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
|
|
31
|
+
const DOCS_SCHEME = 'webjs-docs://';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
|
|
35
|
+
* path. Tries the BUNDLED location first (a published `@webjsdev/cli` ships
|
|
36
|
+
* `resources/agent-docs/` + `resources/AGENTS.md` via `prepack`), then falls
|
|
37
|
+
* back to the monorepo-root layout (`agent-docs/` + `AGENTS.md`) used in dev and
|
|
38
|
+
* tests. Returns `{ docsDir, agentsPath }`; either path may not exist, callers
|
|
39
|
+
* fail soft (an empty corpus is valid, never a crash).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
|
|
42
|
+
* @returns {{ docsDir: string, agentsPath: string }}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveDocsLocation(moduleUrl) {
|
|
45
|
+
const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
|
|
46
|
+
const cliRoot = resolve(here, '..'); // packages/cli/lib -> packages/cli
|
|
47
|
+
const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
|
|
48
|
+
|
|
49
|
+
const bundledDocs = join(cliRoot, 'resources', 'agent-docs');
|
|
50
|
+
if (existsSync(bundledDocs)) {
|
|
51
|
+
return { docsDir: bundledDocs, agentsPath: join(cliRoot, 'resources', 'AGENTS.md') };
|
|
52
|
+
}
|
|
53
|
+
return { docsDir: join(repoRoot, 'agent-docs'), agentsPath: join(repoRoot, 'AGENTS.md') };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
|
|
58
|
+
* `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} file
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function docName(file) {
|
|
64
|
+
return file.replace(/\.md$/i, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A human title for a doc name: `lit-muscle-memory-gotchas` ->
|
|
69
|
+
* `Lit Muscle Memory Gotchas`. Used in the resource listing so a model picks
|
|
70
|
+
* the right doc without reading it.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} name
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function titleFor(name) {
|
|
76
|
+
if (name === 'AGENTS') return 'AGENTS.md (the framework contract + invariants)';
|
|
77
|
+
return name
|
|
78
|
+
.split('-')
|
|
79
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The corpus catalogue: every servable doc as `{ name, file, uri, title }`.
|
|
85
|
+
* Reads the docs dir listing (so it tracks the shipped set) plus the `AGENTS.md`
|
|
86
|
+
* contract. PURE given `deps`.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ docsDir: string, agentsPath: string, listDir: (d: string) => string[], exists: (p: string) => boolean }} deps
|
|
89
|
+
* @returns {Array<{ name: string, file: string, uri: string, title: string }>}
|
|
90
|
+
*/
|
|
91
|
+
export function catalogue(deps) {
|
|
92
|
+
const { docsDir, agentsPath, listDir, exists } = deps;
|
|
93
|
+
/** @type {Array<{ name: string, file: string, uri: string, title: string }>} */
|
|
94
|
+
const out = [];
|
|
95
|
+
if (exists(agentsPath)) {
|
|
96
|
+
out.push({ name: 'AGENTS', file: agentsPath, uri: `${DOCS_SCHEME}AGENTS`, title: titleFor('AGENTS') });
|
|
97
|
+
}
|
|
98
|
+
let files = [];
|
|
99
|
+
try { files = listDir(docsDir).filter((f) => /\.md$/i.test(f)).sort(); } catch { files = []; }
|
|
100
|
+
for (const f of files) {
|
|
101
|
+
const name = docName(f);
|
|
102
|
+
out.push({ name, file: join(docsDir, f), uri: `${DOCS_SCHEME}${name}`, title: titleFor(name) });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* MCP `resources/list`: the corpus as resource descriptors.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} deps
|
|
111
|
+
* @returns {Array<{ uri: string, name: string, title: string, mimeType: string }>}
|
|
112
|
+
*/
|
|
113
|
+
export function listResources(deps) {
|
|
114
|
+
return catalogue(deps).map((d) => ({
|
|
115
|
+
uri: d.uri,
|
|
116
|
+
name: d.name,
|
|
117
|
+
title: d.title,
|
|
118
|
+
mimeType: 'text/markdown',
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* MCP `resources/read`: the markdown text for a `webjs-docs://<name>` URI.
|
|
124
|
+
* Throws a clear Error for an unknown URI (the dispatcher maps it to a JSON-RPC
|
|
125
|
+
* error, never a crash).
|
|
126
|
+
*
|
|
127
|
+
* @param {object} deps
|
|
128
|
+
* @param {string} uri
|
|
129
|
+
* @returns {Promise<{ uri: string, mimeType: string, text: string }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function readResource(deps, uri) {
|
|
132
|
+
const entry = catalogue(deps).find((d) => d.uri === uri);
|
|
133
|
+
if (!entry) throw new Error(`Unknown resource: ${uri}`);
|
|
134
|
+
const text = await deps.readFile(entry.file, 'utf8');
|
|
135
|
+
return { uri, mimeType: 'text/markdown', text };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract a `## <heading>` section (heading line through the line before the
|
|
140
|
+
* next same-or-higher-level heading) from a markdown doc. Used to source the
|
|
141
|
+
* `init` primer from `AGENTS.md` rather than hand-duplicating it (so it cannot
|
|
142
|
+
* drift). Returns '' when the heading is absent.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} md
|
|
145
|
+
* @param {RegExp} headingRe matches the section's heading LINE (e.g. /^##\s+Execution model/m)
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function sectionByHeading(md, headingRe) {
|
|
149
|
+
const lines = md.split('\n');
|
|
150
|
+
let start = -1;
|
|
151
|
+
let level = 0;
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
if (headingRe.test(lines[i])) {
|
|
154
|
+
start = i;
|
|
155
|
+
level = (lines[i].match(/^#+/) || ['##'])[0].length;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (start === -1) return '';
|
|
160
|
+
let end = lines.length;
|
|
161
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
162
|
+
const m = lines[i].match(/^(#+)\s/);
|
|
163
|
+
if (m && m[1].length <= level) { end = i; break; }
|
|
164
|
+
}
|
|
165
|
+
return lines.slice(start, end).join('\n').trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The `init` tool output: the "read first" orientation that fights the
|
|
170
|
+
* React/RSC mental model. Sources the EXECUTION MODEL + INVARIANTS sections
|
|
171
|
+
* from the shipped `AGENTS.md` (no hand-duplication), prepends a short router
|
|
172
|
+
* to the highest-value resources, and lists the corpus so the agent knows what
|
|
173
|
+
* else it can pull. PURE given `deps`.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} deps
|
|
176
|
+
* @returns {Promise<string>}
|
|
177
|
+
*/
|
|
178
|
+
export async function initText(deps) {
|
|
179
|
+
let agents = '';
|
|
180
|
+
try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
|
|
181
|
+
const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
|
|
182
|
+
const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
|
|
183
|
+
|
|
184
|
+
const cat = catalogue(deps);
|
|
185
|
+
const resourceList = cat.map((d) => `- \`${d.uri}\` (${d.title})`).join('\n');
|
|
186
|
+
|
|
187
|
+
const router = [
|
|
188
|
+
'You are about to write or edit a webjs app. Read this orientation FIRST, then',
|
|
189
|
+
'pull the specific docs you need via the `docs` tool or the `webjs-docs://*`',
|
|
190
|
+
'resources. webjs is web-components-first and looks like Lit + Rails, NOT React/Next:',
|
|
191
|
+
'there is NO RSC, no server/client component split, no `use client`. Components',
|
|
192
|
+
'hydrate (islands); pages and layouts do NOT hydrate. Signals are the default',
|
|
193
|
+
'state primitive. Server-only code lives behind the `.server.{js,ts}` boundary.',
|
|
194
|
+
'When writing a component, read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
195
|
+
'the Lit habits that break webjs SSR/reactivity each have a webjs-shaped fix there.',
|
|
196
|
+
'',
|
|
197
|
+
'webjs is buildless: the authored framework source is readable JSDoc in',
|
|
198
|
+
'`node_modules/@webjsdev/<pkg>/src`, and server-side that source runs directly.',
|
|
199
|
+
'(The one built artifact is the `@webjsdev/core` BROWSER bundle in `dist/`;',
|
|
200
|
+
'its authored source is still in `src/`.) When the docs do not answer something,',
|
|
201
|
+
'use the `source` tool to grep or read that real `src/` source (it skips `dist/`).',
|
|
202
|
+
].join('\n');
|
|
203
|
+
|
|
204
|
+
const parts = [
|
|
205
|
+
'# webjs: read first',
|
|
206
|
+
'',
|
|
207
|
+
router,
|
|
208
|
+
'',
|
|
209
|
+
execModel || '(execution-model section unavailable)',
|
|
210
|
+
'',
|
|
211
|
+
invariants || '(invariants section unavailable)',
|
|
212
|
+
'',
|
|
213
|
+
'## Available docs (read via the `docs` tool or these resources)',
|
|
214
|
+
'',
|
|
215
|
+
resourceList || '(no docs bundled)',
|
|
216
|
+
];
|
|
217
|
+
return parts.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The `docs` tool. With `topic` matching a corpus name, returns that doc's full
|
|
222
|
+
* text. With `query`, keyword-searches every doc and returns the matching lines
|
|
223
|
+
* (each tagged with its source URI and nearest heading). With neither, returns
|
|
224
|
+
* the topic index (the catalogue). PURE given `deps`.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} deps
|
|
227
|
+
* @param {{ topic?: string, query?: string }} [args]
|
|
228
|
+
* @returns {Promise<string>}
|
|
229
|
+
*/
|
|
230
|
+
export async function searchDocs(deps, args) {
|
|
231
|
+
const { topic, query } = args || {};
|
|
232
|
+
const cat = catalogue(deps);
|
|
233
|
+
|
|
234
|
+
if (topic) {
|
|
235
|
+
const entry = cat.find((d) => d.name.toLowerCase() === String(topic).toLowerCase());
|
|
236
|
+
if (!entry) {
|
|
237
|
+
const names = cat.map((d) => d.name).join(', ');
|
|
238
|
+
return `Unknown topic "${topic}". Available topics: ${names}`;
|
|
239
|
+
}
|
|
240
|
+
return await deps.readFile(entry.file, 'utf8');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (query) {
|
|
244
|
+
const q = String(query).toLowerCase();
|
|
245
|
+
const MAX_HITS = 40;
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
const hits = [];
|
|
248
|
+
let capped = false;
|
|
249
|
+
outer: for (const entry of cat) {
|
|
250
|
+
let text = '';
|
|
251
|
+
try { text = await deps.readFile(entry.file, 'utf8'); } catch { continue; }
|
|
252
|
+
const lines = text.split('\n');
|
|
253
|
+
for (let i = 0; i < lines.length; i++) {
|
|
254
|
+
if (!lines[i].toLowerCase().includes(q)) continue;
|
|
255
|
+
// Check the cap BEFORE pushing, so `capped` means a match BEYOND the cap
|
|
256
|
+
// exists (exactly MAX_HITS matches is NOT a truncation, nothing dropped).
|
|
257
|
+
if (hits.length >= MAX_HITS) { capped = true; break outer; }
|
|
258
|
+
// Nearest preceding heading for context.
|
|
259
|
+
let heading = '';
|
|
260
|
+
for (let j = i; j >= 0; j--) {
|
|
261
|
+
if (/^#+\s/.test(lines[j])) { heading = lines[j].replace(/^#+\s/, ''); break; }
|
|
262
|
+
}
|
|
263
|
+
hits.push(`[${entry.uri}] ${heading ? heading + ': ' : ''}${lines[i].trim()}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!hits.length) return `No matches for "${query}" in the webjs docs. Topics: ${cat.map((d) => d.name).join(', ')}`;
|
|
267
|
+
// Disclose truncation rather than silently capping (no silent caps).
|
|
268
|
+
if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; refine the query or open a doc with \`topic\`)`);
|
|
269
|
+
return hits.join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No args: the topic index.
|
|
273
|
+
return ['webjs docs topics (pass one as `topic`, or `query` to search):', '', ...cat.map((d) => `- ${d.name}: ${d.title}`)].join('\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The guided-workflow PROMPTS, built from the recipes. Each is a single-message
|
|
278
|
+
* prompt that hands the agent the canonical webjs recipe plus the invariants it
|
|
279
|
+
* must not break, then tells it to pull `webjs-docs://recipes` for the full set.
|
|
280
|
+
* Static metadata; {@link getPrompt} fills the message text.
|
|
281
|
+
*/
|
|
282
|
+
export const PROMPTS = [
|
|
283
|
+
{ 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 }] },
|
|
284
|
+
{ 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 }] },
|
|
285
|
+
{ 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 }] },
|
|
286
|
+
{ 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 }] },
|
|
287
|
+
{ name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The canonical recipe snippet + invariant reminders for each prompt. Kept
|
|
292
|
+
* compact on purpose: the prompt orients + shows the shape, then points at the
|
|
293
|
+
* full `webjs-docs://recipes` resource. The shapes mirror `agent-docs/recipes.md`.
|
|
294
|
+
*
|
|
295
|
+
* @type {Record<string, string>}
|
|
296
|
+
*/
|
|
297
|
+
const PROMPT_BODIES = {
|
|
298
|
+
add_page: [
|
|
299
|
+
'Add a webjs page. A page is `app/<segment>/page.ts` whose DEFAULT export is a',
|
|
300
|
+
'(possibly async) function returning a `TemplateResult`; it runs ONLY on the server.',
|
|
301
|
+
'',
|
|
302
|
+
'```ts',
|
|
303
|
+
"import { html } from '@webjsdev/core';",
|
|
304
|
+
'export default function About() {',
|
|
305
|
+
' return html`<h1>About</h1>`;',
|
|
306
|
+
'}',
|
|
307
|
+
'```',
|
|
308
|
+
'',
|
|
309
|
+
'Rules: the default export is a FUNCTION (invariant 6), it does NOT call render().',
|
|
310
|
+
'For interactivity, render a component tag inside it (pages do not hydrate).',
|
|
311
|
+
'Name metadata via a `metadata` / `generateMetadata` named export.',
|
|
312
|
+
].join('\n'),
|
|
313
|
+
add_dynamic_route: [
|
|
314
|
+
'Add a dynamic page. `app/users/[id]/page.ts` receives `{ params }`; fetch data',
|
|
315
|
+
'through a server action or query, never by importing the DB directly.',
|
|
316
|
+
'',
|
|
317
|
+
'```ts',
|
|
318
|
+
'export default async function User({ params }: { params: { id: string } }) {',
|
|
319
|
+
' const user = await getUser(params.id); // a `use server` action / query',
|
|
320
|
+
' return html`<h1>${user.name}</h1>`;',
|
|
321
|
+
'}',
|
|
322
|
+
'```',
|
|
323
|
+
'',
|
|
324
|
+
'Catch-all is `[...rest]`, optional catch-all `[[...rest]]`. Server-only data',
|
|
325
|
+
'access goes through `.server.{js,ts}`, never a direct import into the page.',
|
|
326
|
+
].join('\n'),
|
|
327
|
+
add_server_action: [
|
|
328
|
+
"Add a server action. A `*.server.ts` file with `'use server'` exports async",
|
|
329
|
+
'functions that round-trip serializer-safe values; a client import is rewritten',
|
|
330
|
+
'to a typed RPC stub (never hand-write fetch).',
|
|
331
|
+
'',
|
|
332
|
+
'```ts',
|
|
333
|
+
'// modules/<feature>/actions/<name>.server.ts',
|
|
334
|
+
"'use server';",
|
|
335
|
+
"import { prisma } from '../../../lib/prisma.server.ts';",
|
|
336
|
+
'export async function doThing(input: { name: string }) {',
|
|
337
|
+
" const name = String(input?.name || '').trim();",
|
|
338
|
+
" if (!name) return { success: false, error: 'name required', status: 400 };",
|
|
339
|
+
' return { success: true, data: await prisma.thing.create({ data: { name } }) };',
|
|
340
|
+
'}',
|
|
341
|
+
'```',
|
|
342
|
+
'',
|
|
343
|
+
'Return the `ActionResult<T>` envelope. Server-only code MUST stay in `.server.*`',
|
|
344
|
+
'(invariant 1). Call it from a component via a normal import.',
|
|
345
|
+
].join('\n'),
|
|
346
|
+
add_component: [
|
|
347
|
+
'Add an interactive WebComponent. One custom element per file; register at module',
|
|
348
|
+
'top level. Signals are the default state; read with `.get()` inside render().',
|
|
349
|
+
'',
|
|
350
|
+
'```ts',
|
|
351
|
+
"import { WebComponent, html, signal } from '@webjsdev/core';",
|
|
352
|
+
'export class MyThing extends WebComponent {',
|
|
353
|
+
' count = signal(0);',
|
|
354
|
+
' render() {',
|
|
355
|
+
' return html`<button @click=${() => this.count.set(this.count.get() + 1)}>',
|
|
356
|
+
' ${this.count.get()}</button>`;',
|
|
357
|
+
' }',
|
|
358
|
+
'}',
|
|
359
|
+
"MyThing.register('my-thing');",
|
|
360
|
+
'```',
|
|
361
|
+
'',
|
|
362
|
+
'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
|
|
363
|
+
'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
364
|
+
'a class-field initializer that overwrites a reactive accessor breaks reactivity',
|
|
365
|
+
'(use `declare` + `static properties`).',
|
|
366
|
+
].join('\n'),
|
|
367
|
+
add_module: [
|
|
368
|
+
'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
|
|
369
|
+
'`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
|
|
370
|
+
'`types.ts`. One function per action/query file. Routes stay thin: extract anything',
|
|
371
|
+
'over ~20 lines into a module action. Shared presentational primitives go in the',
|
|
372
|
+
'top-level `components/`, cross-cutting infra in `lib/*.server.ts`.',
|
|
373
|
+
].join('\n'),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* MCP `prompts/get`: the messages for a guided-workflow prompt. Throws for an
|
|
378
|
+
* unknown name (mapped to a JSON-RPC error).
|
|
379
|
+
*
|
|
380
|
+
* @param {string} name
|
|
381
|
+
* @param {Record<string, string>} [args]
|
|
382
|
+
* @returns {{ description: string, messages: Array<{ role: string, content: { type: string, text: string } }> }}
|
|
383
|
+
*/
|
|
384
|
+
export function getPrompt(name, args) {
|
|
385
|
+
const meta = PROMPTS.find((p) => p.name === name);
|
|
386
|
+
const body = PROMPT_BODIES[name];
|
|
387
|
+
if (!meta || !body) throw new Error(`Unknown prompt: ${name}`);
|
|
388
|
+
|
|
389
|
+
// Fold any provided argument values in as a one-line hint at the top.
|
|
390
|
+
const provided = Object.entries(args || {}).filter(([, v]) => v != null && v !== '');
|
|
391
|
+
const argLine = provided.length
|
|
392
|
+
? `Context: ${provided.map(([k, v]) => `${k}=${v}`).join(', ')}.\n\n`
|
|
393
|
+
: '';
|
|
394
|
+
|
|
395
|
+
const text = `${argLine}${body}\n\nSee \`webjs-docs://recipes\` for the full recipe set and \`webjs-docs://AGENTS\` for the invariants.`;
|
|
396
|
+
return {
|
|
397
|
+
description: meta.description,
|
|
398
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `source` tool for `webjs mcp` (#378): read-only access to the FRAMEWORK
|
|
3
|
+
* source itself.
|
|
4
|
+
*
|
|
5
|
+
* webjs is buildless, so every app's `node_modules/@webjsdev/<pkg>/src` holds the
|
|
6
|
+
* authored JSDoc `.js`, and server-side that source runs directly. (The one built
|
|
7
|
+
* artifact is the `@webjsdev/core` browser bundle in `dist/`, which this tool
|
|
8
|
+
* deliberately skips: it surfaces only the authored `src/`.) That is a real
|
|
9
|
+
* advantage: when the docs do not answer a question, an agent can read the real
|
|
10
|
+
* authored source. This tool makes that first-class and
|
|
11
|
+
* discoverable (and reachable for an MCP-only client with no filesystem tools):
|
|
12
|
+
* - no args (or `package`): list the resolved `@webjsdev/*` packages + their
|
|
13
|
+
* `src/` entry-point files.
|
|
14
|
+
* - `query`: grep the framework `src/` trees, returning bounded `file:line`
|
|
15
|
+
* hits (with a disclosed cap, no silent truncation).
|
|
16
|
+
* - `path`: read one source file (e.g. `server/src/ssr.js`), traversal-guarded
|
|
17
|
+
* to stay inside a resolved framework package root.
|
|
18
|
+
*
|
|
19
|
+
* READ-ONLY and side-effect-free: it only reads files, loads no module, and
|
|
20
|
+
* cannot read outside the resolved `@webjsdev/*` package roots. Zero-dependency,
|
|
21
|
+
* consistent with the rest of the server. PURE given injected `deps`
|
|
22
|
+
* (`{ roots, readFile, readdir }`), so it is testable against a fake tree.
|
|
23
|
+
*
|
|
24
|
+
* @module mcp-source
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { join, resolve, sep, relative } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** The published framework packages whose source an agent may want to read. */
|
|
31
|
+
export const FRAMEWORK_PACKAGES = ['core', 'server', 'cli', 'ts-plugin', 'ui'];
|
|
32
|
+
|
|
33
|
+
/** Source file extensions worth grepping / reading (text, not assets). */
|
|
34
|
+
const TEXT_EXT = /\.(?:js|ts|mjs|mts|cjs|cts|json|md)$/i;
|
|
35
|
+
|
|
36
|
+
/** Bound the grep output (disclosed when hit) and the walk (defensive). */
|
|
37
|
+
const MAX_HITS = 60;
|
|
38
|
+
const MAX_FILES = 4000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve each `@webjsdev/*` package's root + source dir from `cwd`. Locates the
|
|
42
|
+
* root by checking each `require.resolve.paths` node_modules dir on disk for
|
|
43
|
+
* `@webjsdev/<pkg>/package.json`, so it works for a real `node_modules` install
|
|
44
|
+
* AND the monorepo workspace (where the dir is a symlink to `packages/<pkg>`),
|
|
45
|
+
* and honours hoisting. This fs check is deliberate: `<pkg>/package.json` is
|
|
46
|
+
* blocked by `exports` for server/cli/ui, and the bin-only cli has no main
|
|
47
|
+
* entry, so neither `resolve('<pkg>/package.json')` nor `resolve('<pkg>')` is
|
|
48
|
+
* reliable. The source dir is `src/`, or `lib/` for the cli. A package that is
|
|
49
|
+
* not installed is skipped (not every app depends on every `@webjsdev/*`).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
* @param {{ exists: (p: string) => boolean }} fsDeps
|
|
53
|
+
* @returns {Array<{ pkg: string, root: string, src: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveFrameworkRoots(cwd, fsDeps) {
|
|
56
|
+
const req = createRequire(join(cwd, '__webjs_mcp_source__.js'));
|
|
57
|
+
/** @type {Array<{ pkg: string, root: string, src: string }>} */
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const pkg of FRAMEWORK_PACKAGES) {
|
|
60
|
+
// Find the package ROOT by checking each node_modules search path on disk,
|
|
61
|
+
// NOT via `require.resolve('<pkg>')` or `<pkg>/package.json`: a package whose
|
|
62
|
+
// `exports` omits `./package.json` (server/cli/ui) or that has no main entry
|
|
63
|
+
// (cli is a bin-only package) would otherwise be unreachable. The fs check
|
|
64
|
+
// bypasses both and still honours hoisting (the search paths include every
|
|
65
|
+
// parent `node_modules`).
|
|
66
|
+
const bases = req.resolve.paths(`@webjsdev/${pkg}`) || [];
|
|
67
|
+
let root = '';
|
|
68
|
+
for (const base of bases) {
|
|
69
|
+
const cand = join(base, '@webjsdev', pkg);
|
|
70
|
+
if (fsDeps.exists(join(cand, 'package.json'))) { root = cand; break; }
|
|
71
|
+
}
|
|
72
|
+
if (!root) continue;
|
|
73
|
+
// Most packages keep source in `src/`; the cli keeps it in `lib/`. Use
|
|
74
|
+
// whichever exists so every framework package's source is reachable.
|
|
75
|
+
const src = fsDeps.exists(join(root, 'src'))
|
|
76
|
+
? join(root, 'src')
|
|
77
|
+
: fsDeps.exists(join(root, 'lib'))
|
|
78
|
+
? join(root, 'lib')
|
|
79
|
+
: '';
|
|
80
|
+
if (src) out.push({ pkg, root, src });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recursively list text-source files under `dir` (absolute paths), skipping
|
|
87
|
+
* `node_modules` / `dist` and bounded by {@link MAX_FILES}.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} dir
|
|
90
|
+
* @param {{ readdir: (d: string) => Array<{ name: string, isDir: boolean }> }} deps
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
export function walkSource(dir, deps) {
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
const files = [];
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
const stack = [dir];
|
|
98
|
+
while (stack.length && files.length < MAX_FILES) {
|
|
99
|
+
const d = stack.pop();
|
|
100
|
+
let entries = [];
|
|
101
|
+
try { entries = deps.readdir(d); } catch { continue; }
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (e.isDir) {
|
|
104
|
+
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
|
|
105
|
+
stack.push(join(d, e.name));
|
|
106
|
+
} else if (TEXT_EXT.test(e.name)) {
|
|
107
|
+
files.push(join(d, e.name));
|
|
108
|
+
if (files.length >= MAX_FILES) break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return files.sort();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* No-args / `package` mode: list the resolved packages and their `src/`
|
|
117
|
+
* top-level files (the entry points), so the agent has a map to grep or read.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readdir: Function }} deps
|
|
120
|
+
* @param {string} [pkgFilter]
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function listSources(deps, pkgFilter) {
|
|
124
|
+
const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
|
|
125
|
+
if (!roots.length) {
|
|
126
|
+
return pkgFilter
|
|
127
|
+
? `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}`
|
|
128
|
+
: 'No @webjsdev/* packages resolvable from here (run inside a webjs app or the monorepo).';
|
|
129
|
+
}
|
|
130
|
+
const lines = ['webjs framework authored source (buildless; server-side this runs directly, and core ships a built browser dist/ that is excluded here). Read with `source({ path })` or search with `source({ query })`.', ''];
|
|
131
|
+
for (const r of roots) {
|
|
132
|
+
const dirName = r.src.split(sep).pop(); // 'src' for most, 'lib' for cli
|
|
133
|
+
let entries = [];
|
|
134
|
+
try { entries = deps.readdir(r.src); } catch { entries = []; }
|
|
135
|
+
const top = entries.filter((e) => !e.isDir && TEXT_EXT.test(e.name)).map((e) => e.name).sort();
|
|
136
|
+
const dirs = entries.filter((e) => e.isDir).map((e) => e.name).sort();
|
|
137
|
+
lines.push(`@webjsdev/${r.pkg}/${dirName}:`);
|
|
138
|
+
if (top.length) lines.push(` files: ${top.map((f) => `${r.pkg}/${dirName}/${f}`).join(', ')}`);
|
|
139
|
+
if (dirs.length) lines.push(` subdirs: ${dirs.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `query` mode: grep every resolved `src/` tree for the (case-insensitive)
|
|
146
|
+
* substring, returning bounded `[<pkg>/src/<rel>:<line>] <text>` hits. Discloses
|
|
147
|
+
* truncation rather than silently capping.
|
|
148
|
+
*
|
|
149
|
+
* @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps
|
|
150
|
+
* @param {string} query
|
|
151
|
+
* @returns {Promise<string>}
|
|
152
|
+
*/
|
|
153
|
+
export async function grepSources(deps, query) {
|
|
154
|
+
const q = String(query).toLowerCase();
|
|
155
|
+
if (!q) return 'Provide a non-empty `query`.';
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const hits = [];
|
|
158
|
+
let capped = false;
|
|
159
|
+
outer: for (const r of deps.roots) {
|
|
160
|
+
for (const file of walkSource(r.src, deps)) {
|
|
161
|
+
let text = '';
|
|
162
|
+
try { text = await deps.readFile(file, 'utf8'); } catch { continue; }
|
|
163
|
+
if (!text.toLowerCase().includes(q)) continue; // fast skip whole file
|
|
164
|
+
const lines = text.split('\n');
|
|
165
|
+
const rel = relative(r.root, file).split(sep).join('/');
|
|
166
|
+
for (let i = 0; i < lines.length; i++) {
|
|
167
|
+
if (!lines[i].toLowerCase().includes(q)) continue;
|
|
168
|
+
if (hits.length >= MAX_HITS) { capped = true; break outer; }
|
|
169
|
+
hits.push(`[@webjsdev/${r.pkg}/${rel}:${i + 1}] ${lines[i].trim()}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!hits.length) return `No matches for "${query}" in the @webjsdev/* source.`;
|
|
174
|
+
if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; narrow the query or read a file with \`path\`)`);
|
|
175
|
+
return hits.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** True when `p` is `base` itself or a descendant of it. */
|
|
179
|
+
function within(base, p) {
|
|
180
|
+
return p === base || p.startsWith(base + sep);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `path` mode: read one AUTHORED-source file. Accepts `<pkg>/...` or
|
|
185
|
+
* `@webjsdev/<pkg>/...`. Scoped to the package's SOURCE dir (`src/`, or `lib/`
|
|
186
|
+
* for cli), so it serves only the authored source and NOT the built `dist/`
|
|
187
|
+
* browser bundle, `node_modules`, etc. Refuses any path that escapes the source
|
|
188
|
+
* dir lexically (`..`/absolute), and (when `deps.realpath` is provided)
|
|
189
|
+
* re-checks the symlink-resolved path so a symlink inside `src/` cannot reach
|
|
190
|
+
* outside. Read-only.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, realpath?: Function }} deps
|
|
193
|
+
* @param {string} path
|
|
194
|
+
* @returns {Promise<string>}
|
|
195
|
+
*/
|
|
196
|
+
export async function readSource(deps, path) {
|
|
197
|
+
const cleaned = String(path).replace(/^@webjsdev\//, '');
|
|
198
|
+
const segs = cleaned.split('/').filter(Boolean);
|
|
199
|
+
const pkg = segs[0];
|
|
200
|
+
const entry = deps.roots.find((r) => r.pkg === pkg);
|
|
201
|
+
if (!entry) {
|
|
202
|
+
return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr.js.`;
|
|
203
|
+
}
|
|
204
|
+
const abs = resolve(entry.root, segs.slice(1).join('/'));
|
|
205
|
+
const srcLabel = entry.src.split(sep).pop();
|
|
206
|
+
// Scope to the authored source dir, so dist/ (the built core browser bundle),
|
|
207
|
+
// package.json, node_modules, etc. are not readable; only `src/` (or cli `lib/`).
|
|
208
|
+
if (!within(entry.src, abs)) {
|
|
209
|
+
return `Refusing to read outside the @webjsdev/${pkg} authored source (only ${srcLabel}/ is exposed; the built dist/ is not).`;
|
|
210
|
+
}
|
|
211
|
+
// Defense in depth: a symlink inside the source dir must not resolve outside it.
|
|
212
|
+
if (deps.realpath) {
|
|
213
|
+
try {
|
|
214
|
+
if (!within(deps.realpath(entry.src), deps.realpath(abs))) {
|
|
215
|
+
return `Refusing to read outside the @webjsdev/${pkg} authored source (a symlink escapes ${srcLabel}/).`;
|
|
216
|
+
}
|
|
217
|
+
} catch { /* abs does not exist; the readFile below returns the not-a-file message */ }
|
|
218
|
+
}
|
|
219
|
+
// Legacy guard kept as a belt-and-suspenders against a root escape too.
|
|
220
|
+
if (abs !== entry.root && !abs.startsWith(entry.root + sep)) {
|
|
221
|
+
return `Refusing to read outside @webjsdev/${pkg} (path escapes the package root).`;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return await deps.readFile(abs, 'utf8');
|
|
225
|
+
} catch {
|
|
226
|
+
return `Could not read ${path} (not a file under @webjsdev/${pkg}).`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The `source` tool entry point. Dispatches on the args: `path` reads a file,
|
|
232
|
+
* `query` greps, otherwise (or with `package`) lists the packages. PURE given
|
|
233
|
+
* `deps` (`{ roots, readFile, readdir }`).
|
|
234
|
+
*
|
|
235
|
+
* @param {object} deps
|
|
236
|
+
* @param {{ query?: string, path?: string, package?: string }} [args]
|
|
237
|
+
* @returns {Promise<string>}
|
|
238
|
+
*/
|
|
239
|
+
export async function runSourceTool(deps, args) {
|
|
240
|
+
const a = args || {};
|
|
241
|
+
if (a.path) return readSource(deps, a.path);
|
|
242
|
+
if (a.query) return grepSources(deps, a.query);
|
|
243
|
+
return listSources(deps, a.package);
|
|
244
|
+
}
|