@webjsdev/mcp 0.1.17 → 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.js CHANGED
@@ -23,19 +23,12 @@
23
23
  import { createInterface } from 'node:readline';
24
24
  import { relative } from 'node:path';
25
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';
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';
39
32
  import { projectRoutes } from './routes-report.js';
40
33
 
41
34
  const PROTOCOL_VERSION = '2024-11-05';
@@ -56,10 +49,9 @@ const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'vali
56
49
  /**
57
50
  * The `appDir` property, shared by every tool that is scoped to one app.
58
51
  *
59
- * `init` and `docs` carry it too, not just the introspection tools: the docs
60
- * corpus is resolved from the app being asked about (#1319), so a tool that did
61
- * not ADVERTISE `appDir` could never receive one from a conforming client, and
62
- * the app-corpus rung would be reachable only from hand-written JSON-RPC.
52
+ * `init` carries it too: it compares the app's installed `@webjsdev/mcp` with
53
+ * the running server's to warn about a stale server (#1319), and a tool that did
54
+ * not ADVERTISE `appDir` could never receive one from a conforming client.
63
55
  */
64
56
  const APPDIR_PROP = {
65
57
  type: 'string',
@@ -73,45 +65,38 @@ const APPDIR_SCHEMA = {
73
65
  required: [],
74
66
  };
75
67
 
76
- /** `init` takes only the optional appDir, which selects whose docs corpus it reports. */
68
+ /** `init` takes only the optional appDir, whose installed server version it compares. */
77
69
  const INIT_SCHEMA = {
78
70
  type: 'object',
79
71
  properties: {
80
72
  appDir: {
81
73
  ...APPDIR_PROP,
82
- description: "App directory whose docs corpus to read. Defaults to the server cwd.",
74
+ description: 'App directory the agent is editing, checked for a newer @webjsdev/mcp than this server. Defaults to the server cwd.',
83
75
  },
84
76
  },
85
77
  required: [],
86
78
  };
87
79
 
88
- /** `docs` takes an optional topic OR a free-text query, plus the optional appDir. */
89
- const DOCS_SCHEMA = {
80
+ /** `ui` lists the @webjsdev/ui kit, or details one component or block by `name`. */
81
+ const UI_SCHEMA = {
90
82
  type: 'object',
91
83
  properties: {
92
- appDir: {
93
- ...APPDIR_PROP,
94
- description: "App directory whose docs corpus to read. Defaults to the server cwd.",
95
- },
96
- topic: {
84
+ name: {
97
85
  type: 'string',
98
- description: 'A doc name (e.g. components, recipes, lit-muscle-memory-gotchas, AGENTS). Returns the full doc.',
86
+ description: 'A component (button, dialog) or block (login-01) name. Omit for the inventory.',
99
87
  },
100
- query: {
88
+ type: {
101
89
  type: 'string',
102
- description: 'Free-text search across all webjs docs. Returns matching lines with their source.',
90
+ enum: ['ui', 'block', 'chart', 'all'],
91
+ description: 'List one kind with one-line descriptions: ui (components), block, chart, or all.',
103
92
  },
104
- },
105
- required: [],
106
- };
107
-
108
- /** `ui` lists the @webjsdev/ui kit, or details one component by `name`. */
109
- const UI_SCHEMA = {
110
- type: 'object',
111
- properties: {
112
- name: {
93
+ category: {
113
94
  type: 'string',
114
- description: 'A component name (e.g. button, accordion, dialog). Omit for the full kit inventory.',
95
+ description: 'List the blocks and charts in one category (login, signup, sidebar, dashboard, area, ...) with descriptions.',
96
+ },
97
+ source: {
98
+ type: 'boolean',
99
+ description: 'With a block `name`: include the file sources. Off by default, since installing the block and reading the files is cheaper.',
115
100
  },
116
101
  },
117
102
  required: [],
@@ -135,42 +120,35 @@ const SOURCE_SCHEMA = {
135
120
  },
136
121
  package: {
137
122
  type: 'string',
138
- description: 'Limit a no-args listing to one package (core, server, cli, intellisense, ui).',
123
+ description: 'List one package\'s source entry points (core, server, cli, intellisense, ui).',
139
124
  },
140
125
  },
141
126
  required: [],
142
127
  };
143
128
 
144
129
  /**
145
- * The tools. The four introspection tools project an EXISTING @webjsdev/server
146
- * function (read-only, appDir-scoped). `init` + `docs` (#376) surface the
147
- * framework knowledge: `init` is the "read first" mental-model primer, `docs`
148
- * retrieves a doc by topic or searches the corpus. Descriptions are crisp so a
149
- * model picks the right tool without reading source.
130
+ * The tools. The introspection tools project an EXISTING @webjsdev/server
131
+ * function (read-only, appDir-scoped). `init` returns the WebJs brief, `source`
132
+ * reads the framework's own source, and `ui` reads the @webjsdev/ui kit.
133
+ * Descriptions are crisp so a model picks the right tool without reading source.
150
134
  */
151
135
  const TOOL_DEFS = [
152
136
  {
153
137
  name: 'init',
154
138
  description:
155
- '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.',
139
+ '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.',
156
140
  inputSchema: INIT_SCHEMA,
157
141
  },
158
- {
159
- name: 'docs',
160
- description:
161
- '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.',
162
- inputSchema: DOCS_SCHEMA,
163
- },
164
142
  {
165
143
  name: 'source',
166
144
  description:
167
- 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `export` for the signature plus doc comment of ONE export (the cheap way to check a contract: `export: "createAuth"`), `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
145
+ '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. Call it before hand-writing any of those. Read-only.',
168
146
  inputSchema: SOURCE_SCHEMA,
169
147
  },
170
148
  {
171
149
  name: 'ui',
172
150
  description:
173
- 'The @webjsdev/ui component kit (shadcn-style, copied into your repo). No args returns the inventory (each component: tier, helper signatures, npm deps); pass `name` for one component: helper signatures, the paste-ready structural example, the a11y/description header, and deps. Use before hand-writing UI so you reach for a helper (e.g. buttonClass()) and the accessible structure instead of expanding Tailwind blind. Reads the kit, not your app. Read-only.',
151
+ 'The @webjsdev/ui kit (shadcn-style, copied into your repo): components, plus blocks and charts (a block is a ready-made view you install and then edit: a login or signup page, a sidebar shell, a dashboard, a chart). No args returns the component inventory (tier, helper signatures, npm deps) and the block and chart NAMES grouped by category; `type` / `category` list them with one-line descriptions. Pass `name` for one component (helper signatures, the paste-ready example, the a11y header, deps) or one block (description, dependencies, the install command, and the files it writes with their targets; `source: true` adds the sources). Before hand-writing a page, a dashboard, a sidebar shell, an auth form or a chart, look for a block and install it. Reads the kit, not your app. Read-only.',
174
152
  inputSchema: UI_SCHEMA,
175
153
  },
176
154
  {
@@ -191,12 +169,6 @@ const TOOL_DEFS = [
191
169
  'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
192
170
  inputSchema: APPDIR_SCHEMA,
193
171
  },
194
- {
195
- name: 'list_elision',
196
- description:
197
- '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.',
198
- inputSchema: APPDIR_SCHEMA,
199
- },
200
172
  {
201
173
  name: 'check',
202
174
  description:
@@ -332,7 +304,7 @@ export function extractActionConfig(src) {
332
304
  * JSON-serialisable projection of an existing server data function. All are
333
305
  * read-only.
334
306
  *
335
- * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, analyzeAppElision: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
307
+ * @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
336
308
  */
337
309
  export function makeToolRunners(deps) {
338
310
  const {
@@ -340,7 +312,6 @@ export function makeToolRunners(deps) {
340
312
  buildActionIndex,
341
313
  hashFile,
342
314
  scanComponents,
343
- analyzeAppElision,
344
315
  checkConventions,
345
316
  projectCheck,
346
317
  readFile,
@@ -404,15 +375,6 @@ export function makeToolRunners(deps) {
404
375
  .sort((a, b) => a.tag.localeCompare(b.tag));
405
376
  },
406
377
 
407
- async list_elision(appDir) {
408
- // The whole report IS the contract: `analyzeAppElision` already returns
409
- // an app-relative, sorted, JSON-serializable object, so unlike
410
- // `list_routes` there is no projector leaf here and nothing to keep in
411
- // sync. `webjs elision --json` prints this same object, and a drift test
412
- // asserts the two are equal.
413
- return analyzeAppElision(appDir);
414
- },
415
-
416
378
  async check(appDir) {
417
379
  const violations = await checkConventions(appDir);
418
380
  return projectCheck(violations);
@@ -420,16 +382,6 @@ export function makeToolRunners(deps) {
420
382
  };
421
383
  }
422
384
 
423
- /**
424
- * Run the `ui` tool: the @webjsdev/ui kit inventory (no args) or one component's
425
- * projection (helper signatures + paste-ready example + a11y/description header
426
- * + deps). Projects the SHARED `@webjsdev/ui/registry/extract` leaf, the same
427
- * one `webjsui view` renders, so the CLI and MCP cannot drift (#983, the #979
428
- * shared-projector pattern). `uiDeps` is injected so tests need no real package.
429
- *
430
- * @param {{ uiInventory: Function, uiComponent: Function }} uiDeps
431
- * @param {{ name?: string }} args
432
- */
433
385
  /**
434
386
  * Resolve the `ui` tool's deps from the shared `@webjsdev/ui/registry/extract`
435
387
  * leaf, GUARDED (#983): if `@webjsdev/ui` is absent or too old to carry the
@@ -439,29 +391,70 @@ export function makeToolRunners(deps) {
439
391
  * top-level import would instead reject during bootstrap and sink the whole
440
392
  * server. `importer` is injected so the failure path is unit-testable.
441
393
  *
442
- * @param {() => Promise<{ uiInventory: Function, uiComponent: Function }>} importer
443
- * @returns {Promise<{ uiInventory: Function, uiComponent: Function }>}
394
+ * A `@webjsdev/ui` that predates blocks (no `uiBlocks` export) still serves
395
+ * the components: the block deps then answer with nothing rather than throw.
396
+ *
397
+ * @param {() => Promise<any>} importer
398
+ * @returns {Promise<UiDeps>}
444
399
  */
445
400
  export async function loadUiDeps(importer) {
446
401
  try {
447
402
  const ui = await importer();
448
- return { uiInventory: ui.uiInventory, uiComponent: ui.uiComponent };
403
+ return {
404
+ uiInventory: ui.uiInventory,
405
+ uiComponent: ui.uiComponent,
406
+ uiBlocks: ui.uiBlocks ?? (() => []),
407
+ uiBlockIndex: ui.uiBlockIndex ?? (() => ({ blocks: {}, charts: {} })),
408
+ uiBlock: ui.uiBlock ?? (() => null),
409
+ };
449
410
  } catch {
450
411
  const unavailable = () => {
451
412
  throw new Error('@webjsdev/ui is not available (install/upgrade @webjsdev/ui to use the ui tool)');
452
413
  };
453
- return { uiInventory: unavailable, uiComponent: unavailable };
414
+ return { uiInventory: unavailable, uiComponent: unavailable, uiBlocks: unavailable, uiBlockIndex: unavailable, uiBlock: unavailable };
454
415
  }
455
416
  }
456
417
 
418
+ /**
419
+ * @typedef {{
420
+ * uiInventory: Function, uiComponent: Function,
421
+ * uiBlocks: Function, uiBlockIndex: Function, uiBlock: Function,
422
+ * }} UiDeps
423
+ */
424
+
425
+ /**
426
+ * Run the `ui` tool. No args: the component inventory plus the block and chart
427
+ * names grouped by category. `type` / `category`: one compact entry per match
428
+ * with its description. A component `name`: its projection (helper signatures,
429
+ * paste-ready example, a11y header, deps). A block `name`: its description,
430
+ * dependencies, install command and files with targets (sources with
431
+ * `source: true`). Projects the SHARED `@webjsdev/ui/registry/extract` leaf,
432
+ * the same one `webjsui view` renders, so the CLI and MCP cannot drift (#983,
433
+ * the #979 shared-projector pattern). `uiDeps` is injected so tests need no
434
+ * real package.
435
+ *
436
+ * @param {UiDeps} uiDeps
437
+ * @param {{ name?: string, type?: string, category?: string, source?: boolean }} args
438
+ */
457
439
  export function runUiTool(uiDeps, args) {
458
440
  const name = typeof args.name === 'string' && args.name ? args.name : null;
459
- if (!name) return { inventory: uiDeps.uiInventory() };
460
- const component = uiDeps.uiComponent(name);
461
- if (!component) {
462
- throw new Error(`Unknown component "${String(name)}". Call ui with no args for the inventory.`);
441
+ const type = typeof args.type === 'string' && args.type ? args.type : null;
442
+ if (type && !['ui', 'block', 'chart', 'all'].includes(type)) {
443
+ throw new Error(`Unknown type "${type}". Use ui, block, chart or all.`);
444
+ }
445
+ const category = typeof args.category === 'string' && args.category ? args.category : null;
446
+ if (!name) {
447
+ // A filter lists one kind with its one-line descriptions. The bare call
448
+ // stays cheap: the component inventory plus the block and chart NAMES by
449
+ // category (D18), and a test caps its size.
450
+ if (type || category) return { items: uiDeps.uiBlocks({ type: type ?? undefined, category: category ?? undefined }) };
451
+ return { inventory: uiDeps.uiInventory(), ...uiDeps.uiBlockIndex() };
463
452
  }
464
- return component;
453
+ const component = uiDeps.uiComponent(name);
454
+ if (component) return component;
455
+ const block = uiDeps.uiBlock(name, { source: args.source === true });
456
+ if (block) return block;
457
+ throw new Error(`Unknown component or block "${String(name)}". Call ui with no args for the inventory.`);
465
458
  }
466
459
 
467
460
  /**
@@ -519,7 +512,6 @@ export async function runMcpServer(opts) {
519
512
  buildActionIndex: server.buildActionIndex,
520
513
  hashFile: server.hashFile,
521
514
  scanComponents: server.scanComponents,
522
- analyzeAppElision: server.analyzeAppElision,
523
515
  checkConventions: check.checkConventions,
524
516
  projectCheck,
525
517
  readFile,
@@ -527,47 +519,16 @@ export async function runMcpServer(opts) {
527
519
  }
528
520
  const runners = makeToolRunners(deps);
529
521
 
530
- // The docs corpus deps for the knowledge layer (#376): resources / prompts /
531
- // init / docs. Injectable for tests; otherwise resolved from the app's own
532
- // installed corpus, the bundled (published) snapshot, or the repo-root (dev)
533
- // docs, plus node fs.
534
- //
535
- // Resolved PER CALL and memoized by appDir (#1319), not once at boot. `appDir`
536
- // is a per-call tool argument, so a boot-time resolution would pin the corpus
537
- // to the launch directory forever and defeat the app-corpus rung for any host
538
- // that overrides it. `resources/list` and `resources/read` carry no appDir in
539
- // the MCP protocol, so they resolve from `cwd`, which is the same value a
540
- // `tools/call` with no `appDir` argument defaults to; the surfaces can only
541
- // diverge when a caller deliberately asks about a different app.
542
- let docsFs = null;
543
- if (!opts.docsDeps) {
522
+ // The `init` deps: fs readers for the stale-server check, which compares the
523
+ // app's installed @webjsdev/mcp with this server's version (#1319). Resolved
524
+ // per call from the tool's `appDir`. Injectable for tests via `initFs`.
525
+ let initFs = opts.initFs;
526
+ if (!initFs) {
544
527
  const { readFile } = await import('node:fs/promises');
545
- const { readdirSync, existsSync } = await import('node:fs');
546
- docsFs = { listDir: readdirSync, exists: existsSync, readFile };
528
+ const { existsSync } = await import('node:fs');
529
+ initFs = { readFile, exists: existsSync };
547
530
  }
548
- /** @type {Map<string, object>} */
549
- const docsDepsCache = new Map();
550
- /** The docs deps for one appDir. An injected `opts.docsDeps` wins for every appDir. */
551
- const docsDepsFor = (dir) => {
552
- if (opts.docsDeps) return opts.docsDeps;
553
- const key = typeof dir === 'string' ? dir : '';
554
- let built = docsDepsCache.get(key);
555
- if (!built) {
556
- const loc = resolveDocsLocation(import.meta.url, key);
557
- built = {
558
- docsDir: loc.docsDir,
559
- agentsPath: loc.agentsPath,
560
- skillPath: loc.skillPath,
561
- corpusPath: loc.corpusPath,
562
- corpusSource: loc.corpusSource,
563
- appDir: key,
564
- serverVersion: version,
565
- ...docsFs,
566
- };
567
- docsDepsCache.set(key, built);
568
- }
569
- return built;
570
- };
531
+ const initDepsFor = (dir) => ({ ...initFs, appDir: dir, serverVersion: version });
571
532
 
572
533
  // The `source` tool (#378): read the framework's own source from
573
534
  // node_modules/@webjsdev/*/src (no-build, so it is the real JSDoc). Roots are
@@ -575,8 +536,8 @@ export async function runMcpServer(opts) {
575
536
  let sourceDeps = opts.sourceDeps;
576
537
  if (!sourceDeps) {
577
538
  const { readFile } = await import('node:fs/promises');
578
- const { readdirSync, existsSync, realpathSync } = await import('node:fs');
579
- const readdir = (d) => readdirSync(d, { withFileTypes: true }).map((e) => ({ name: e.name, isDir: e.isDirectory() }));
539
+ const { existsSync, realpathSync } = await import('node:fs');
540
+ const readdir = listDir;
580
541
  sourceDeps = {
581
542
  roots: resolveFrameworkRoots(cwd, { exists: existsSync }),
582
543
  readFile,
@@ -613,7 +574,7 @@ export async function runMcpServer(opts) {
613
574
  if (method === 'initialize') {
614
575
  return rpcResult(id, {
615
576
  protocolVersion: PROTOCOL_VERSION,
616
- capabilities: { tools: {}, resources: {}, prompts: {} },
577
+ capabilities: { tools: {} },
617
578
  serverInfo: { name: 'webjs', version },
618
579
  });
619
580
  }
@@ -631,39 +592,12 @@ export async function runMcpServer(opts) {
631
592
  });
632
593
  }
633
594
 
634
- // Knowledge layer (#376): the framework docs as MCP resources.
635
- if (method === 'resources/list') {
636
- return rpcResult(id, { resources: listResources(docsDepsFor(cwd)) });
637
- }
638
- if (method === 'resources/read') {
639
- const uri = ((msg && msg.params) || {}).uri;
640
- try {
641
- const r = await readResource(docsDepsFor(cwd), uri);
642
- return rpcResult(id, { contents: [r] });
643
- } catch (e) {
644
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
645
- }
646
- }
647
-
648
- // Knowledge layer (#376): the recipes as guided-workflow prompts.
649
- if (method === 'prompts/list') {
650
- return rpcResult(id, { prompts: PROMPTS });
651
- }
652
- if (method === 'prompts/get') {
653
- const params = (msg && msg.params) || {};
654
- try {
655
- return rpcResult(id, getPrompt(params.name, params.arguments));
656
- } catch (e) {
657
- return rpcError(id, -32602, e && e.message ? e.message : String(e));
658
- }
659
- }
660
-
661
595
  if (method === 'tools/call') {
662
596
  const params = (msg && msg.params) || {};
663
597
  const name = params.name;
664
598
  const args = params.arguments || {};
665
- // The knowledge tools route to the docs / source layer; they return text.
666
- const isKnowledgeTool = name === 'init' || name === 'docs' || name === 'source';
599
+ // The knowledge tools (the brief, the framework source) return text.
600
+ const isKnowledgeTool = name === 'init' || name === 'source';
667
601
  // `ui` is kit-scoped (not app-scoped), so it is dispatched separately.
668
602
  const isUiTool = name === 'ui';
669
603
  if (!isKnowledgeTool && !isUiTool && !runners[name]) {
@@ -673,10 +607,8 @@ export async function runMcpServer(opts) {
673
607
  try {
674
608
  const result = isKnowledgeTool
675
609
  ? name === 'init'
676
- ? await initText(docsDepsFor(appDir))
677
- : name === 'docs'
678
- ? await searchDocs(docsDepsFor(appDir), args)
679
- : await runSourceTool(sourceDeps, args)
610
+ ? await initText(initDepsFor(appDir))
611
+ : await runSourceTool(sourceDeps, args)
680
612
  : isUiTool
681
613
  ? runUiTool(uiDeps, args)
682
614
  : await runners[name](appDir);