@webjsdev/cli 0.10.12 → 0.10.14

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.js DELETED
@@ -1,557 +0,0 @@
1
- /**
2
- * `webjs mcp`: a minimal, READ-ONLY Model Context Protocol server (#262).
3
- *
4
- * Exposes the live introspection surface an AI agent needs while editing a
5
- * webjs app (the route table, registered server actions with their RPC
6
- * endpoints, registered custom-element tags, and the structured `webjs check`
7
- * violations) over MCP's stdio transport. Every tool REUSES an existing
8
- * `@webjsdev/server` data function and MUTATES NOTHING. The prior art is
9
- * Next.js's `next-devtools-mcp` (get_routes / get_server_action_by_id /
10
- * get_errors); this is deliberately tiny.
11
- *
12
- * Transport: MCP stdio is newline-delimited JSON-RPC 2.0. One JSON object per
13
- * line arrives on stdin; exactly one JSON response line per request is written
14
- * to stdout. STDOUT IS THE PROTOCOL CHANNEL, so this module writes ONLY
15
- * JSON-RPC frames there and routes every diagnostic to stderr. A malformed
16
- * input line is answered with a JSON-RPC parse error and never crashes the
17
- * loop. Hand-rolled with zero new dependency (webjs is buildless +
18
- * minimal-deps).
19
- *
20
- * @module mcp
21
- */
22
-
23
- import { createInterface } from 'node:readline';
24
- import { relative } from 'node:path';
25
-
26
- import {
27
- resolveDocsLocation,
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';
36
-
37
- const PROTOCOL_VERSION = '2024-11-05';
38
-
39
- /**
40
- * The four read-only tools. Each takes an optional `{ appDir }` (default the
41
- * server's cwd) and projects an EXISTING `@webjsdev/server` function's output
42
- * into an agent-friendly shape. Descriptions are crisp so a model picks the
43
- * right tool without reading source.
44
- */
45
- /** The shared input schema for the introspection tools: an optional appDir override. */
46
- const APPDIR_SCHEMA = {
47
- type: 'object',
48
- properties: {
49
- appDir: {
50
- type: 'string',
51
- description: 'App directory to introspect. Defaults to the server cwd.',
52
- },
53
- },
54
- required: [],
55
- };
56
-
57
- /** `init` takes no input. */
58
- const INIT_SCHEMA = { type: 'object', properties: {}, required: [] };
59
-
60
- /** `docs` takes an optional topic OR a free-text query. */
61
- const DOCS_SCHEMA = {
62
- type: 'object',
63
- properties: {
64
- topic: {
65
- type: 'string',
66
- description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
67
- },
68
- query: {
69
- type: 'string',
70
- description: 'Free-text search across all webjs docs. Returns matching lines with their source.',
71
- },
72
- },
73
- required: [],
74
- };
75
-
76
- /** `source` reads the framework source: a `path` to read, a `query` to grep, or a `package` to list. */
77
- const SOURCE_SCHEMA = {
78
- type: 'object',
79
- properties: {
80
- path: {
81
- type: 'string',
82
- description: 'A framework source file to read, e.g. server/src/ssr.js or @webjsdev/core/src/render-client.js.',
83
- },
84
- query: {
85
- type: 'string',
86
- description: 'Grep the @webjsdev/* src trees for this substring. Returns file:line hits.',
87
- },
88
- package: {
89
- type: 'string',
90
- description: 'Limit a no-args listing to one package (core, server, cli, ts-plugin, ui).',
91
- },
92
- },
93
- required: [],
94
- };
95
-
96
- /**
97
- * The tools. The four introspection tools project an EXISTING @webjsdev/server
98
- * function (read-only, appDir-scoped). `init` + `docs` (#376) surface the
99
- * framework knowledge: `init` is the "read first" mental-model primer, `docs`
100
- * retrieves a doc by topic or searches the corpus. Descriptions are crisp so a
101
- * model picks the right tool without reading source.
102
- */
103
- const TOOL_DEFS = [
104
- {
105
- name: 'init',
106
- description:
107
- 'READ THIS FIRST before writing or editing a webjs app. Returns the webjs mental model (NOT React/Next: no RSC, components hydrate but pages do not, signals-default state, the .server boundary) plus the invariants and the doc index. Read-only.',
108
- inputSchema: INIT_SCHEMA,
109
- },
110
- {
111
- name: 'docs',
112
- description:
113
- '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.',
114
- inputSchema: DOCS_SCHEMA,
115
- },
116
- {
117
- name: 'source',
118
- description:
119
- 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `path` to read a file (e.g. server/src/ssr.js), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
120
- inputSchema: SOURCE_SCHEMA,
121
- },
122
- {
123
- name: 'list_routes',
124
- description:
125
- 'List the app route table: SSR pages (path, file, dynamic flag, param names) and route.{js,ts} API handlers (path, file, HTTP methods). Read-only.',
126
- inputSchema: APPDIR_SCHEMA,
127
- },
128
- {
129
- name: 'list_actions',
130
- description:
131
- 'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, and the /__webjs/action/<hash>/<fn> RPC endpoint. Read-only.',
132
- inputSchema: APPDIR_SCHEMA,
133
- },
134
- {
135
- name: 'list_components',
136
- description:
137
- 'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
138
- inputSchema: APPDIR_SCHEMA,
139
- },
140
- {
141
- name: 'check',
142
- description:
143
- 'Run webjs check (correctness rules) and return the structured violations { rule, file, message, fix } plus a summary count and per-rule breakdown. Read-only.',
144
- inputSchema: APPDIR_SCHEMA,
145
- },
146
- ];
147
-
148
- /**
149
- * Lexically extract the names exported from a module source. Recognises the
150
- * common forms a server-action / route file uses without LOADING the module
151
- * (loading would run its top-level side effects: Prisma init, DB connects).
152
- *
153
- * export async function foo() {} export function foo() {}
154
- * export const foo = ... export let/var foo = ...
155
- * export default ... (recorded as 'default')
156
- * export { a, b as c } (the EXPORTED name, so `c`)
157
- *
158
- * @param {string} src
159
- * @returns {string[]} unique export names, in source order
160
- */
161
- export function extractExportNames(src) {
162
- /** @type {string[]} */
163
- const names = [];
164
- const add = (n) => { if (n && !names.includes(n)) names.push(n); };
165
-
166
- // export [async] function NAME / export const|let|var NAME / export class NAME
167
- const declRe =
168
- /\bexport\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
169
- let m;
170
- while ((m = declRe.exec(src)) !== null) add(m[1]);
171
-
172
- // export default ...
173
- if (/\bexport\s+default\b/.test(src)) add('default');
174
-
175
- // export { a, b as c, default as d }
176
- const namedRe = /\bexport\s*\{([^}]*)\}/g;
177
- while ((m = namedRe.exec(src)) !== null) {
178
- for (const part of m[1].split(',')) {
179
- const seg = part.trim();
180
- if (!seg) continue;
181
- // `local as exported` -> the exported name is what callers import.
182
- const asMatch = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(seg);
183
- if (asMatch) add(asMatch[1]);
184
- else {
185
- const idMatch = /^([A-Za-z_$][\w$]*)$/.exec(seg);
186
- if (idMatch) add(idMatch[1]);
187
- }
188
- }
189
- }
190
- return names;
191
- }
192
-
193
- /**
194
- * Lexically extract the HTTP method exports of a route.{js,ts} file. The webjs
195
- * API router (`api.js`) dispatches the five standard verbs, plus `WS` for a
196
- * WebSocket upgrade. We report exactly those that are exported, NOT `HEAD` /
197
- * `OPTIONS` (the router does not dispatch a named handler for them, so listing
198
- * them would imply a route the framework ignores). Read-only: no module load.
199
- *
200
- * @param {string} src
201
- * @returns {string[]}
202
- */
203
- export function extractRouteMethods(src) {
204
- const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'WS'];
205
- const exported = new Set(extractExportNames(src));
206
- return METHODS.filter((m) => exported.has(m));
207
- }
208
-
209
- /**
210
- * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
211
- * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
212
- * same normalization `buildRouteTable` uses for matching.
213
- *
214
- * @param {string} routeDir POSIX-style, `.` for the app root.
215
- * @returns {string}
216
- */
217
- function routePathFromDir(routeDir) {
218
- if (!routeDir || routeDir === '.') return '/';
219
- const segs = routeDir
220
- .split('/')
221
- .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
222
- return segs.length ? '/' + segs.join('/') : '/';
223
- }
224
-
225
- /**
226
- * The tool runners. Each is async, takes `(appDir)`, and returns a plain
227
- * JSON-serialisable projection of an existing server data function. All are
228
- * read-only.
229
- *
230
- * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
231
- */
232
- export function makeToolRunners(deps) {
233
- const {
234
- buildRouteTable,
235
- buildActionIndex,
236
- hashFile,
237
- scanComponents,
238
- checkConventions,
239
- projectCheck,
240
- readFile,
241
- } = deps;
242
-
243
- return {
244
- async list_routes(appDir) {
245
- const table = await buildRouteTable(appDir);
246
- const pages = await Promise.all(
247
- table.pages.map(async (r) => {
248
- /** @type {{ path: string, file: string, dynamic?: boolean, params?: string[] }} */
249
- const out = {
250
- path: routePathFromDir(r.routeDir),
251
- file: relative(appDir, r.file),
252
- };
253
- if (r.paramNames && r.paramNames.length) {
254
- out.dynamic = true;
255
- out.params = r.paramNames;
256
- }
257
- return out;
258
- }),
259
- );
260
- const apis = await Promise.all(
261
- table.apis.map(async (r) => {
262
- let methods = [];
263
- try {
264
- methods = extractRouteMethods(await readFile(r.file, 'utf8'));
265
- } catch {}
266
- return {
267
- path: routePathFromDir(r.routeDir),
268
- file: relative(appDir, r.file),
269
- methods,
270
- };
271
- }),
272
- );
273
- return { pages, apis };
274
- },
275
-
276
- async list_actions(appDir) {
277
- // `skipExposeLoad` builds the file -> hash maps WITHOUT importing any
278
- // `expose()` module, so this stays truly read-only (no Prisma/DB init, and
279
- // no stray stdout from a loaded module corrupting the JSON-RPC channel).
280
- // The RPC hash is over the file path only, so no module load is needed.
281
- const idx = await buildActionIndex(appDir, false, { skipExposeLoad: true });
282
- /** @type {Array<{ file: string, fn: string, endpoint: string }>} */
283
- const actions = [];
284
- for (const [file, hash] of idx.fileToHash) {
285
- let names = [];
286
- try {
287
- names = extractExportNames(await readFile(file, 'utf8'));
288
- } catch {}
289
- for (const fn of names) {
290
- actions.push({
291
- file: relative(appDir, file),
292
- fn,
293
- endpoint: `/__webjs/action/${hash}/${fn}`,
294
- });
295
- }
296
- }
297
- // Stable order for deterministic output.
298
- actions.sort((a, b) =>
299
- a.file === b.file ? a.fn.localeCompare(b.fn) : a.file.localeCompare(b.file),
300
- );
301
- return actions;
302
- },
303
-
304
- async list_components(appDir) {
305
- const comps = await scanComponents(appDir);
306
- return comps
307
- .map((c) => ({
308
- tag: c.tag,
309
- file: relative(appDir, c.file),
310
- className: c.className,
311
- }))
312
- .sort((a, b) => a.tag.localeCompare(b.tag));
313
- },
314
-
315
- async check(appDir) {
316
- const violations = await checkConventions(appDir);
317
- return projectCheck(violations);
318
- },
319
- };
320
- }
321
-
322
- /**
323
- * A JSON-RPC 2.0 result frame.
324
- * @param {string|number|null} id
325
- * @param {any} result
326
- */
327
- function rpcResult(id, result) {
328
- return { jsonrpc: '2.0', id, result };
329
- }
330
-
331
- /**
332
- * A JSON-RPC 2.0 error frame.
333
- * @param {string|number|null} id
334
- * @param {number} code
335
- * @param {string} message
336
- */
337
- function rpcError(id, code, message) {
338
- return { jsonrpc: '2.0', id, error: { code, message } };
339
- }
340
-
341
- /**
342
- * Run the read-only webjs MCP server over the given streams. Reads
343
- * newline-delimited JSON-RPC from `stdin`, writes one response line per request
344
- * to `stdout`, and logs diagnostics to `stderr` ONLY (stdout is the protocol
345
- * channel). Resolves when stdin ends (clean shutdown).
346
- *
347
- * Injectable streams + `cwd` keep it testable in-process with PassThrough
348
- * streams; the bin passes the real `process.std*` + `process.cwd()`.
349
- *
350
- * @param {{
351
- * stdin: NodeJS.ReadableStream,
352
- * stdout: NodeJS.WritableStream,
353
- * stderr: NodeJS.WritableStream,
354
- * cwd: string,
355
- * version?: string,
356
- * deps?: object,
357
- * }} opts
358
- * @returns {Promise<void>}
359
- */
360
- export async function runMcpServer(opts) {
361
- const { stdin, stdout, stderr, cwd } = opts;
362
- const version = opts.version || '0.0.0';
363
-
364
- // Resolve the data functions from @webjsdev/server (reading them is
365
- // read-only). Injectable for tests so they need not boot the real server.
366
- let deps = opts.deps;
367
- if (!deps) {
368
- const server = await import('@webjsdev/server');
369
- const check = await import('@webjsdev/server/check');
370
- const { readFile } = await import('node:fs/promises');
371
- const { projectCheck } = await import('./check-json.js');
372
- deps = {
373
- buildRouteTable: server.buildRouteTable,
374
- buildActionIndex: server.buildActionIndex,
375
- hashFile: server.hashFile,
376
- scanComponents: server.scanComponents,
377
- checkConventions: check.checkConventions,
378
- projectCheck,
379
- readFile,
380
- };
381
- }
382
- const runners = makeToolRunners(deps);
383
-
384
- // The docs corpus deps for the knowledge layer (#376): resources / prompts /
385
- // init / docs. Injectable for tests; otherwise resolved from the bundled
386
- // (published) or repo-root (dev) docs and node fs.
387
- let docsDeps = opts.docsDeps;
388
- if (!docsDeps) {
389
- const loc = resolveDocsLocation(import.meta.url);
390
- const { readFile } = await import('node:fs/promises');
391
- const { readdirSync, existsSync } = await import('node:fs');
392
- docsDeps = {
393
- docsDir: loc.docsDir,
394
- agentsPath: loc.agentsPath,
395
- listDir: readdirSync,
396
- exists: existsSync,
397
- readFile,
398
- };
399
- }
400
-
401
- // The `source` tool (#378): read the framework's own source from
402
- // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
403
- // resolved once from the server cwd. Injectable for tests.
404
- let sourceDeps = opts.sourceDeps;
405
- if (!sourceDeps) {
406
- const { readFile } = await import('node:fs/promises');
407
- const { readdirSync, existsSync, realpathSync } = await import('node:fs');
408
- const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
409
- sourceDeps = {
410
- roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
411
- readFile,
412
- readdir,
413
- realpath: realpathSync,
414
- };
415
- }
416
-
417
- /** Write one JSON-RPC frame as a single line to stdout. */
418
- const send = (frame) => {
419
- stdout.write(JSON.stringify(frame) + '\n');
420
- };
421
- /** Diagnostics go to stderr only, never stdout. */
422
- const logErr = (msg) => {
423
- try { stderr.write(`[webjs mcp] ${msg}\n`); } catch {}
424
- };
425
-
426
- /**
427
- * Dispatch one parsed JSON-RPC message. Returns a frame to send, or null
428
- * for a notification (no `id`) which gets no response.
429
- */
430
- const dispatch = async (msg) => {
431
- const id = msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
432
- const isNotification = id === null || id === undefined;
433
- const method = msg && msg.method;
434
-
435
- if (method === 'initialize') {
436
- return rpcResult(id, {
437
- protocolVersion: PROTOCOL_VERSION,
438
- capabilities: { tools: {}, resources: {}, prompts: {} },
439
- serverInfo: { name: 'webjs', version },
440
- });
441
- }
442
-
443
- // `notifications/initialized` (and any other notification) gets no reply.
444
- if (isNotification) return null;
445
-
446
- if (method === 'tools/list') {
447
- return rpcResult(id, {
448
- tools: TOOL_DEFS.map((t) => ({
449
- name: t.name,
450
- description: t.description,
451
- inputSchema: t.inputSchema,
452
- })),
453
- });
454
- }
455
-
456
- // Knowledge layer (#376): the framework docs as MCP resources.
457
- if (method === 'resources/list') {
458
- return rpcResult(id, { resources: listResources(docsDeps) });
459
- }
460
- if (method === 'resources/read') {
461
- const uri = ((msg && msg.params) || {}).uri;
462
- try {
463
- const r = await readResource(docsDeps, uri);
464
- return rpcResult(id, { contents: [r] });
465
- } catch (e) {
466
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
467
- }
468
- }
469
-
470
- // Knowledge layer (#376): the recipes as guided-workflow prompts.
471
- if (method === 'prompts/list') {
472
- return rpcResult(id, { prompts: PROMPTS });
473
- }
474
- if (method === 'prompts/get') {
475
- const params = (msg && msg.params) || {};
476
- try {
477
- return rpcResult(id, getPrompt(params.name, params.arguments));
478
- } catch (e) {
479
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
480
- }
481
- }
482
-
483
- if (method === 'tools/call') {
484
- const params = (msg && msg.params) || {};
485
- const name = params.name;
486
- const args = params.arguments || {};
487
- // The knowledge tools route to the docs / source layer; they return text.
488
- const isKnowledgeTool = name === 'init' || name === 'docs' || name === 'source';
489
- if (!isKnowledgeTool && !runners[name]) {
490
- return rpcError(id, -32602, `Unknown tool: ${String(name)}`);
491
- }
492
- const appDir = typeof args.appDir === 'string' && args.appDir ? args.appDir : cwd;
493
- try {
494
- const result = isKnowledgeTool
495
- ? name === 'init'
496
- ? await initText(docsDeps)
497
- : name === 'docs'
498
- ? await searchDocs(docsDeps, args)
499
- : await runSourceTool(sourceDeps, args)
500
- : await runners[name](appDir);
501
- // Knowledge tools return a markdown string; introspection tools return
502
- // a JSON-serialisable object.
503
- const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
504
- return rpcResult(id, {
505
- content: [{ type: 'text', text }],
506
- });
507
- } catch (e) {
508
- // A tool failure is an MCP tool-result error (isError), not a transport
509
- // error, so the agent sees the message in the content channel.
510
- logErr(`tool ${name} failed: ${e && e.message ? e.message : e}`);
511
- return rpcResult(id, {
512
- isError: true,
513
- content: [
514
- { type: 'text', text: `Error running ${name}: ${e && e.message ? e.message : String(e)}` },
515
- ],
516
- });
517
- }
518
- }
519
-
520
- return rpcError(id, -32601, `Method not found: ${String(method)}`);
521
- };
522
-
523
- const rl = createInterface({ input: stdin, crlfDelay: Infinity });
524
-
525
- await new Promise((resolveRun) => {
526
- // Serialise line handling so responses preserve request order even though
527
- // dispatch is async.
528
- let chain = Promise.resolve();
529
- rl.on('line', (line) => {
530
- const trimmed = line.trim();
531
- if (!trimmed) return;
532
- chain = chain.then(async () => {
533
- let msg;
534
- try {
535
- msg = JSON.parse(trimmed);
536
- } catch {
537
- // Malformed line: a JSON-RPC parse error, never a crash.
538
- send(rpcError(null, -32700, 'Parse error'));
539
- return;
540
- }
541
- try {
542
- const frame = await dispatch(msg);
543
- if (frame) send(frame);
544
- } catch (e) {
545
- logErr(`dispatch error: ${e && e.message ? e.message : e}`);
546
- const id =
547
- msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
548
- send(rpcError(id, -32603, 'Internal error'));
549
- }
550
- });
551
- });
552
- rl.on('close', () => {
553
- // Drain the in-flight chain, then resolve (clean shutdown on stdin end).
554
- chain.then(() => resolveRun()).catch(() => resolveRun());
555
- });
556
- });
557
- }