varmory 1.0.3 → 1.0.5

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.
@@ -1,16 +1,191 @@
1
+ // ════════════════════════════════════════════════════════════════════════════
2
+ // varmory MCP — exposes showcase metadata, docs, and component APIs to AI
3
+ // agents over the Model Context Protocol.
4
+ //
5
+ // HIGH-LEVEL FLOW
6
+ // ───────────────
7
+ // 1. `attachShowcase(server, { rootDir, files })` is called once at boot.
8
+ // 2. We assemble a flat list of input file paths:
9
+ // • when `rootDir` is given, scan the varmory layout and collect the
10
+ // files we care about (README, docs/*.md, showcase categories,
11
+ // hand-written definitions, and Quasar's component src JSONs)
12
+ // • merge with any `files` the caller passed in explicitly
13
+ // 3. `loadFromFiles(paths)` routes each path by extension into three
14
+ // in-memory caches:
15
+ // • categories — .vue files grouped by their parent folder
16
+ // • docs — .md files (README.md + docs/*.md)
17
+ // • definitions — .json files (component API definitions)
18
+ // 4. Every definition is piped through `normalizeQuasarApi` to resolve
19
+ // `extends`, merge contributions from `mixins`, tag inherited entries, and
20
+ // sort (required → own → inherited). Definitions without mixins/extends
21
+ // pass through unchanged, so hand-written JSONs stay as-is.
22
+ // 5. MCP resources (listing endpoints) and tools (search / fetch endpoints)
23
+ // are registered on the server, reading from the caches.
24
+ //
25
+ // FILESYSTEM LAYOUT EXPECTED AT rootDir
26
+ // ─────────────────────────────────────
27
+ // README.md
28
+ // docs/*.md
29
+ // **/categories/<NN Name>/*.vue (anywhere under rootDir, recursive
30
+ // scan that skips node_modules / .git
31
+ // / dist / build)
32
+ // **/definitions/<Vendor>/*.json (same — any folder named
33
+ // `definitions` with vendor subfolders
34
+ // of JSON files)
35
+ // node_modules/quasar/src/… (optional; when
36
+ // present, Quasar
37
+ // components become
38
+ // queryable via
39
+ // get_api)
40
+ //
41
+ // Merge order matters: caller-supplied `files` are appended AFTER root-scanned
42
+ // files, so they override anything with the same definition/doc name.
43
+ // ════════════════════════════════════════════════════════════════════════════
44
+
1
45
  import fs from 'fs';
2
46
  import path from 'path';
3
47
  import { z } from 'zod';
4
48
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
49
+ import normalizeQuasarApi from '../src/varmory/includes/normalizeQuasarApi.js';
50
+
51
+ // ── Filesystem helpers ──────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Recursively list all files beneath `dir` whose filename passes `predicate`.
55
+ * Returns absolute paths. Safe if `dir` doesn't exist. `skip` is a set of
56
+ * directory names to skip (e.g. `node_modules`).
57
+ */
58
+ function walkFiles(dir, predicate, skip = new Set()) {
59
+ const out = [];
60
+ if (!fs.existsSync(dir)) return out;
61
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ const full = path.join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ if (skip.has(entry.name)) continue;
65
+ out.push(...walkFiles(full, predicate, skip));
66
+ } else if (entry.isFile() && predicate(entry.name)) out.push(full);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Recursively locate all directories with a given name under `dir`, skipping
73
+ * the entries in `skip` and giving up after `maxDepth` levels. Returns
74
+ * absolute paths in sorted order.
75
+ *
76
+ * Depth 0 is `dir` itself. A `maxDepth` of 5 means we look at `dir` and 5
77
+ * layers below it — deep enough for monorepo layouts but shallow enough to
78
+ * not wander into surprise directories.
79
+ */
80
+ function findDirsByName(dir, name, skip = new Set(), maxDepth = 5, depth = 0) {
81
+ const out = [];
82
+ if (!fs.existsSync(dir) || depth > maxDepth) return out;
83
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
84
+ if (!entry.isDirectory()) continue;
85
+ if (skip.has(entry.name)) continue;
86
+ const full = path.join(dir, entry.name);
87
+ if (entry.name === name) out.push(full);
88
+ out.push(...findDirsByName(full, name, skip, maxDepth, depth + 1));
89
+ }
90
+ return out.sort();
91
+ }
92
+
93
+ /**
94
+ * Collect all files we care about from a varmory root directory, returned as a
95
+ * flat array of absolute paths. The caller feeds this into `loadFromFiles`.
96
+ *
97
+ * Files are emitted in a deterministic order so the resulting categories
98
+ * and definitions lists are stable across runs:
99
+ * README.md
100
+ * docs/*.md (alphabetical)
101
+ * showcase/categories/<NN Name>/*.vue (folders by name, files a→z)
102
+ * showcase/definitions/<Vendor>/*.json (folders a→z, files a→z)
103
+ * node_modules/quasar/src/components (recursive) Q*.json (Quasar's own API,
104
+ * unless `quasar: false`)
105
+ *
106
+ * @param {string} rootDir
107
+ * @param {object} [opts]
108
+ * @param {boolean} [opts.quasar=true] - When false, skip Quasar's component JSONs.
109
+ * @param {number} [opts.maxDepth=5] - How deep to search for `categories/`
110
+ * and `definitions/` folders. Depth 0 is `rootDir` itself.
111
+ */
112
+ function collectFilesFromRoot(rootDir, { quasar = true, maxDepth = 5 } = {}) {
113
+ const files = [];
114
+
115
+ // Skip these folder names when walking rootDir — they're expensive and
116
+ // never contain user-authored showcase/definition folders.
117
+ const skip = new Set(['node_modules', '.git', 'dist', 'build']);
118
+
119
+ // README.md at the root
120
+ const readme = path.join(rootDir, 'README.md');
121
+ if (fs.existsSync(readme)) files.push(readme);
122
+
123
+ // Markdown docs
124
+ const docsDir = path.join(rootDir, 'docs');
125
+ if (fs.existsSync(docsDir)) {
126
+ const mdFiles = fs.readdirSync(docsDir).filter(n => n.endsWith('.md')).sort();
127
+ for (const name of mdFiles) files.push(path.join(docsDir, name));
128
+ }
129
+
130
+ // Showcase categories: any folder named `categories` under rootDir (not in
131
+ // node_modules etc.) contains `<NN Name>/*.vue` subfolders.
132
+ for (const catDir of findDirsByName(rootDir, 'categories', skip, maxDepth)) {
133
+ const folders = fs.readdirSync(catDir, { withFileTypes: true })
134
+ .filter(d => d.isDirectory())
135
+ .map(d => d.name)
136
+ .sort();
137
+ for (const folder of folders) {
138
+ const sub = path.join(catDir, folder);
139
+ const vueFiles = fs.readdirSync(sub).filter(n => n.endsWith('.vue')).sort();
140
+ for (const name of vueFiles) files.push(path.join(sub, name));
141
+ }
142
+ }
143
+
144
+ // Hand-written API definitions: any folder named `definitions` under
145
+ // rootDir contains `<Vendor>/*.json` subfolders.
146
+ for (const defDir of findDirsByName(rootDir, 'definitions', skip, maxDepth)) {
147
+ const folders = fs.readdirSync(defDir, { withFileTypes: true })
148
+ .filter(d => d.isDirectory())
149
+ .map(d => d.name)
150
+ .sort();
151
+ for (const folder of folders) {
152
+ const sub = path.join(defDir, folder);
153
+ const jsonFiles = fs.readdirSync(sub).filter(n => n.endsWith('.json')).sort();
154
+ for (const name of jsonFiles) files.push(path.join(sub, name));
155
+ }
156
+ }
157
+
158
+ // Quasar's authoritative component JSONs (contain mixins + extends).
159
+ // These are added AFTER hand-written defs so they override by key (QBtn,
160
+ // QInput, etc.). Skipped when `quasar: false`.
161
+ if (quasar) {
162
+ const quasarComp = path.join(rootDir, 'node_modules/quasar/src/components');
163
+ if (fs.existsSync(quasarComp)) {
164
+ for (const f of walkFiles(quasarComp, n => /^Q[A-Z].*\.json$/.test(n))) {
165
+ files.push(f);
166
+ }
167
+ }
168
+ }
169
+
170
+ return files;
171
+ }
172
+
173
+ // ── Vue file parsing ────────────────────────────────────────────────────────
5
174
 
6
175
  /**
7
- * Extract the inner content of the root <template> block from a raw .vue string.
176
+ * Extract the inner content of the root <template> block from a raw .vue
177
+ * string. Handles nested <template> tags (used for slot templates) and strips
178
+ * the common leading indentation so the returned snippet reads cleanly when
179
+ * embedded in a markdown code fence.
8
180
  */
9
181
  function extractTemplate(raw) {
10
182
  if (!raw) return '';
11
183
  const openTag = raw.indexOf('<template');
12
184
  if (openTag === -1) return '';
13
185
  const contentStart = raw.indexOf('>', openTag) + 1;
186
+
187
+ // Track nesting depth so a `<template #foo>` inside the root doesn't
188
+ // trick us into closing early on its matching `</template>`.
14
189
  let depth = 1;
15
190
  let i = contentStart;
16
191
  while (i < raw.length && depth > 0) {
@@ -19,75 +194,134 @@ function extractTemplate(raw) {
19
194
  if (nextClose === -1) break;
20
195
  if (nextOpen !== -1 && nextOpen < nextClose) {
21
196
  depth++;
22
- i = nextOpen + 9;
197
+ i = nextOpen + 9; // past "<template"
23
198
  } else {
24
199
  depth--;
25
200
  if (depth === 0) {
26
201
  const content = raw.slice(contentStart, nextClose);
27
202
  const lines = content.replace(/^\n|\n$/g, '').split('\n');
203
+ // Dedent by the minimum leading whitespace among non-blank lines.
28
204
  const indent = Math.min(
29
205
  ...lines.filter(l => l.trim()).map(l => l.match(/^(\s*)/)[1].length),
30
206
  );
31
207
  return lines.map(l => l.slice(indent)).join('\n').trim();
32
208
  }
33
- i = nextClose + 11;
209
+ i = nextClose + 11; // past "</template>"
34
210
  }
35
211
  }
36
212
  return '';
37
213
  }
38
214
 
39
215
  /**
40
- * Parse a .vue file into a showcase item.
216
+ * Locate the body of the `export default { ... }` block and return everything
217
+ * between its braces. Brace counter ignores braces inside strings, template
218
+ * literals, and `// ... ` / block comments so it survives realistic Vue files.
219
+ * Returns '' if no `export default` is found.
220
+ */
221
+ function findExportDefaultBody(raw) {
222
+ const m = raw.match(/export\s+default\s*\{/);
223
+ if (!m) return '';
224
+ const open = m.index + m[0].length - 1; // index of the opening `{`
225
+ let depth = 1;
226
+ let i = open + 1;
227
+ while (i < raw.length && depth > 0) {
228
+ const ch = raw[i];
229
+ // Skip string / template literal — match quote and skip past it.
230
+ if (ch === "'" || ch === '"' || ch === '`') {
231
+ const q = ch;
232
+ i++;
233
+ while (i < raw.length) {
234
+ if (raw[i] === '\\') { i += 2; continue; }
235
+ if (raw[i] === q) { i++; break; }
236
+ i++;
237
+ }
238
+ continue;
239
+ }
240
+ // Skip line comment.
241
+ if (ch === '/' && raw[i + 1] === '/') {
242
+ while (i < raw.length && raw[i] !== '\n') i++;
243
+ continue;
244
+ }
245
+ // Skip block comment.
246
+ if (ch === '/' && raw[i + 1] === '*') {
247
+ i += 2;
248
+ while (i < raw.length - 1 && !(raw[i] === '*' && raw[i + 1] === '/')) i++;
249
+ i += 2;
250
+ continue;
251
+ }
252
+ if (ch === '{') depth++;
253
+ else if (ch === '}') {
254
+ depth--;
255
+ if (depth === 0) return raw.slice(open + 1, i);
256
+ }
257
+ i++;
258
+ }
259
+ return '';
260
+ }
261
+
262
+ /**
263
+ * Parse a single .vue showcase file into a flat metadata record. We don't run
264
+ * a full JS parser — we just look for the top-level `export default` options
265
+ * we care about (`label`, `icon`, `importName`, `importFrom`).
266
+ *
267
+ * Match constraint: the option must appear at the export default block's first
268
+ * indent level. This avoids picking up string values nested deeper (e.g. a
269
+ * `label: 'false'` inside a `data() { ... accentOptions: [{ label: 'false' }] }`
270
+ * would otherwise match before the real top-level `label`).
41
271
  */
42
272
  function parseVueFile(filePath) {
43
273
  const raw = fs.readFileSync(filePath, 'utf-8');
44
274
  const name = path.basename(filePath, '.vue');
45
275
  const template = extractTemplate(raw);
46
276
 
47
- const labelMatch = raw.match(/label:\s*['"](.+?)['"]/);
48
- const iconMatch = raw.match(/icon:\s*['"](.+?)['"]/);
49
- const importNameMatch = raw.match(/importName:\s*['"](.+?)['"]/);
50
- const importFromMatch = raw.match(/importFrom:\s*['"](.+?)['"]/);
277
+ const body = findExportDefaultBody(raw);
278
+
279
+ // Detect the indent unit used by the body (tab or N spaces) by reading
280
+ // the first non-blank indented line.
281
+ const indentMatch = /^([\t ]+)\S/m.exec(body);
282
+ const indent = indentMatch ? indentMatch[1] : ' ';
283
+ // Anchor regexes to "newline + exactly this indent + key:" so deeper
284
+ // nested keys don't match.
285
+ const at = (key, valuePattern) =>
286
+ new RegExp(`(?:^|\\n)${indent.replace(/ /g, ' ').replace(/\t/g, '\\t')}${key}\\s*:\\s*${valuePattern}`);
287
+
288
+ const labelMatch = at('label', `(['"])(.+?)\\1`).exec(body);
289
+ const iconMatch = at('icon', `(['"])(.+?)\\1`).exec(body);
290
+ const importFromMatch = at('importFrom', `(['"])(.+?)\\1`).exec(body);
291
+
292
+ // `importName` can be either a string or an array of strings:
293
+ // importName: 'QBtn'
294
+ // importName: ['QSlider', 'QRange']
295
+ const importArrayMatch = at('importName', `\\[([^\\]]+)\\]`).exec(body);
296
+ let importName = null;
297
+ if (importArrayMatch) {
298
+ importName = importArrayMatch[1].match(/['"]([^'"]+)['"]/g)?.map(s => s.replace(/['"]/g, '')) || null;
299
+ } else {
300
+ const importNameMatch = at('importName', `(['"])(.+?)\\1`).exec(body);
301
+ importName = importNameMatch?.[2] || null;
302
+ }
51
303
 
52
304
  return {
53
305
  name,
54
- label: labelMatch?.[1] || name,
55
- icon: iconMatch?.[1] || null,
56
- importName: importNameMatch?.[1] || null,
57
- importFrom: importFromMatch?.[1] || null,
306
+ label: labelMatch?.[2] || name,
307
+ icon: iconMatch?.[2] || null,
308
+ importName,
309
+ importFrom: importFromMatch?.[2] || null,
58
310
  template,
59
311
  };
60
312
  }
61
313
 
62
- /**
63
- * Scan showcase/categories/ directory and return structured data.
64
- */
65
- function loadCategoriesFromDir(catDir) {
66
- if (!fs.existsSync(catDir)) return {};
67
-
68
- const categories = {};
69
- const folders = fs.readdirSync(catDir, { withFileTypes: true })
70
- .filter(d => d.isDirectory())
71
- .sort((a, b) => a.name.localeCompare(b.name));
72
-
73
- for (const folder of folders) {
74
- const displayName = folder.name.replace(/^\d+\s*/, '');
75
- const files = fs.readdirSync(path.join(catDir, folder.name))
76
- .filter(f => f.endsWith('.vue'))
77
- .sort();
78
-
79
- categories[displayName] = files.map(file =>
80
- parseVueFile(path.join(catDir, folder.name, file)),
81
- );
82
- }
83
- return categories;
84
- }
314
+ // ── Unified loader ──────────────────────────────────────────────────────────
85
315
 
86
316
  /**
87
- * Build categories from a flat list of file paths.
88
- * .vue files are grouped by their parent directory name (numeric prefixes stripped).
89
- * .md files are collected as docs.
90
- * .json files are collected as API definitions.
317
+ * Route a flat list of file paths by extension into the three in-memory caches
318
+ * used by MCP tools:
319
+ * .vue → categories[<parent-folder-with-NN-stripped>].push(parsedItem)
320
+ * .md → docs[<name>] (README.md keeps its `README` key)
321
+ * .json → definitions[<name>] (by filename, without extension)
322
+ *
323
+ * Later paths overwrite earlier ones for docs/definitions by the same name,
324
+ * so callers can override root-scanned entries via `options.files`.
91
325
  */
92
326
  function loadFromFiles(filePaths) {
93
327
  const categories = {};
@@ -100,111 +334,149 @@ function loadFromFiles(filePaths) {
100
334
  const ext = path.extname(abs);
101
335
 
102
336
  if (ext === '.vue') {
337
+ // Group by the .vue's parent directory name, stripping any
338
+ // numeric prefix (e.g. "04 Buttons" → "Buttons") — that's the
339
+ // category naming convention used across the showcase folder.
103
340
  const folder = path.basename(path.dirname(abs));
104
341
  const cat = folder.replace(/^\d+\s*/, '');
105
342
  if (!categories[cat]) categories[cat] = [];
106
343
  categories[cat].push(parseVueFile(abs));
107
344
  } else if (ext === '.md') {
108
345
  const name = path.basename(abs, '.md');
109
- const key = name === 'README' ? 'README' : name;
110
- docs[key] = fs.readFileSync(abs, 'utf-8');
346
+ docs[name] = fs.readFileSync(abs, 'utf-8');
111
347
  } else if (ext === '.json') {
112
348
  const name = path.basename(abs, '.json');
113
- definitions[name] = JSON.parse(fs.readFileSync(abs, 'utf-8'));
349
+ try { definitions[name] = JSON.parse(fs.readFileSync(abs, 'utf-8')); }
350
+ catch { /* ignore malformed json; don't poison the cache */ }
114
351
  }
115
352
  }
116
353
 
117
354
  return { categories, docs, definitions };
118
355
  }
119
356
 
357
+ // ── Quasar normalizer data ─────────────────────────────────────────────────
358
+
120
359
  /**
121
- * Load README.md and docs/*.md from a root directory.
360
+ * Load the two kinds of "resolver data" that `normalizeQuasarApi` needs to
361
+ * flatten a raw Quasar src JSON:
362
+ *
363
+ * apiExtends — the shared prop/slot/event defs referenced by
364
+ * `"extends": "<name>"` in component JSONs. Lives in
365
+ * `quasar/src/api.extends.json`.
366
+ * mixinLookup — every non-Q*.json file under quasar/src/ keyed by its
367
+ * relative path minus the `.json` extension. Matches the
368
+ * mixin reference format used in Quasar JSONs, e.g.
369
+ * `"composables/private.use-size/use-size"` or
370
+ * `"components/btn/use-btn"`.
371
+ *
372
+ * Returns empty lookups when Quasar isn't installed at rootDir — definitions
373
+ * without mixins/extends still pass through normalize unchanged.
122
374
  */
123
- function loadDocsFromDir(rootDir) {
124
- const docs = {};
125
- const readme = path.join(rootDir, 'README.md');
126
- if (fs.existsSync(readme)) {
127
- docs['README'] = fs.readFileSync(readme, 'utf-8');
375
+ function loadQuasarNormalizerData(rootDir) {
376
+ const quasarSrc = rootDir ? path.join(rootDir, 'node_modules/quasar/src') : null;
377
+ if (!quasarSrc || !fs.existsSync(quasarSrc)) {
378
+ return { apiExtends: {}, mixinLookup: {} };
128
379
  }
129
- const docsDir = path.join(rootDir, 'docs');
130
- if (fs.existsSync(docsDir)) {
131
- for (const file of fs.readdirSync(docsDir).filter(f => f.endsWith('.md'))) {
132
- docs[file.replace('.md', '')] = fs.readFileSync(path.join(docsDir, file), 'utf-8');
133
- }
380
+
381
+ const readJson = (p) => {
382
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
383
+ catch { return null; }
384
+ };
385
+
386
+ const apiExtends = readJson(path.join(quasarSrc, 'api.extends.json')) || {};
387
+
388
+ const mixinLookup = {};
389
+ for (const file of walkFiles(quasarSrc, n => n.endsWith('.json'))) {
390
+ const base = path.basename(file);
391
+ // Skip the extends source and any component JSON — we only want
392
+ // auxiliary mixin/composable definitions here.
393
+ if (base === 'api.extends.json' || /^Q[A-Z]/.test(base)) continue;
394
+ const rel = path.relative(quasarSrc, file).replace(/\\/g, '/').replace(/\.json$/, '');
395
+ const json = readJson(file);
396
+ if (json) mixinLookup[rel] = json;
134
397
  }
135
- return docs;
398
+
399
+ return { apiExtends, mixinLookup };
136
400
  }
137
401
 
138
- /**
139
- * Load API definition JSON files from a root directory.
140
- */
141
- function loadDefinitionsFromDir(rootDir) {
142
- const defs = {};
143
- const defsDir = path.join(rootDir, 'src/varmory/showcase/definitions');
144
- if (!fs.existsSync(defsDir)) return defs;
145
-
146
- const folders = fs.readdirSync(defsDir, { withFileTypes: true })
147
- .filter(d => d.isDirectory());
148
-
149
- for (const folder of folders) {
150
- const files = fs.readdirSync(path.join(defsDir, folder.name))
151
- .filter(f => f.endsWith('.json'));
152
- for (const file of files) {
153
- const name = file.replace('.json', '');
154
- const content = fs.readFileSync(path.join(defsDir, folder.name, file), 'utf-8');
155
- defs[name] = JSON.parse(content);
156
- }
157
- }
158
- return defs;
402
+ // ── Markdown formatters (used by the get_api tool) ─────────────────────────
403
+
404
+ function formatType(type) {
405
+ if (!type) return 'any';
406
+ return Array.isArray(type) ? type.join(' | ') : type;
159
407
  }
160
408
 
161
- /**
162
- * Format a component definition's props into a readable summary.
163
- */
409
+ /** Small trailing tag rendered next to inherited props/slots/events/methods. */
410
+ function formatMixinTag(entry) {
411
+ return entry?._mixin ? ` _[inherited: ${entry._mixin}]_` : '';
412
+ }
413
+
414
+ /** Render a component's props as a markdown bullet list. */
164
415
  function formatProps(apiDef) {
165
416
  if (!apiDef?.props) return 'No props defined.';
166
417
  const lines = [];
167
418
  for (const [name, prop] of Object.entries(apiDef.props)) {
419
+ const required = prop.required ? ' **(required)**' : '';
168
420
  const def = prop.default !== undefined ? ` (default: ${prop.default})` : '';
169
- lines.push(`- **${name}**: ${prop.type || 'any'}${def} — ${prop.desc || ''}`);
421
+ const values = Array.isArray(prop.values) && prop.values.length
422
+ ? ` [values: ${prop.values.join(', ')}]`
423
+ : '';
424
+ lines.push(`- **${name}**${required}: ${formatType(prop.type)}${def}${values} — ${prop.desc || ''}${formatMixinTag(prop)}`);
170
425
  }
171
426
  return lines.join('\n');
172
427
  }
173
428
 
429
+ // ── Public entry point ─────────────────────────────────────────────────────
430
+
174
431
  /**
175
432
  * Attach showcase resources and tools to an MCP server instance.
176
433
  *
177
434
  * @param {McpServer} server - An @modelcontextprotocol/sdk McpServer instance
178
435
  * @param {object} [options]
179
- * @param {string} [options.rootDir] - Absolute path to the varmory root. Defaults to one level up from this file.
180
- * @param {string[]} [options.files] - Explicit list of file paths (.vue, .md, .json). When provided, files are auto-classified by extension. Can be combined with rootDir.
181
- * @returns {McpServer} The same server instance, with resources and tools attached
436
+ * @param {string} [options.rootDir] - Absolute path to a varmory root. When
437
+ * provided, the standard showcase layout is scanned automatically. When
438
+ * omitted, the filesystem is NOT scanned only `options.files` are
439
+ * loaded.
440
+ * @param {string[]} [options.files] - Additional file paths to include on top
441
+ * of whatever `rootDir` produced. Classified by extension — see
442
+ * `loadFromFiles`. Useful for injecting fixtures in tests or overriding
443
+ * a specific definition/doc.
444
+ * @param {boolean} [options.quasar=true] - When `false`, Quasar's component
445
+ * JSONs and mixin/extends data are NOT auto-loaded from `node_modules`.
446
+ * Definitions provided via `files` still pass through the normalizer
447
+ * but won't have access to Quasar's shared `extends` / mixin pool.
448
+ * @param {number} [options.maxDepth=5] - How deep the `categories/` /
449
+ * `definitions/` folder search goes under `rootDir`.
450
+ * @returns {McpServer} The same server instance, now with resources and tools
451
+ * attached.
182
452
  */
183
453
  export default function attachShowcase(server, options = {}) {
184
- const rootDir = options.rootDir || (options.files ? null : path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'));
185
-
186
- let categories = {};
187
- let docs = {};
188
- let definitions = {};
189
-
190
- if (rootDir) {
191
- const catDir = path.join(rootDir, 'src/varmory/showcase/categories');
192
- categories = loadCategoriesFromDir(catDir);
193
- docs = loadDocsFromDir(rootDir);
194
- definitions = loadDefinitionsFromDir(rootDir);
454
+ const rootDir = options.rootDir || null;
455
+ const quasar = options.quasar !== false; // default true
456
+ const maxDepth = options.maxDepth ?? 5;
457
+
458
+ // Build one flat file list from (1) root-scan and (2) caller overrides,
459
+ // then run both through the same loader. `options.files` wins on
460
+ // same-named docs/definitions because it comes last.
461
+ const allFiles = [
462
+ ...(rootDir ? collectFilesFromRoot(rootDir, { quasar, maxDepth }) : []),
463
+ ...(options.files || []),
464
+ ];
465
+ const { categories, docs, definitions } = loadFromFiles(allFiles);
466
+
467
+ // Resolve `extends` / `mixins` in every definition. Pure no-op for
468
+ // definitions that don't have those markers, so hand-written JSONs
469
+ // (e.g. JPanel) are preserved byte-for-byte. When `quasar: false`, skip
470
+ // the (heavy) walk of node_modules/quasar/src — definitions still pass
471
+ // through normalize, just without Quasar's shared resolver pool.
472
+ const normalizerData = quasar ? loadQuasarNormalizerData(rootDir) : { apiExtends: {}, mixinLookup: {} };
473
+ for (const key of Object.keys(definitions)) {
474
+ definitions[key] = normalizeQuasarApi(definitions[key], normalizerData);
195
475
  }
196
476
 
197
- if (options.files) {
198
- const fromFiles = loadFromFiles(options.files);
199
- for (const [cat, items] of Object.entries(fromFiles.categories)) {
200
- if (!categories[cat]) categories[cat] = [];
201
- categories[cat].push(...items);
202
- }
203
- Object.assign(docs, fromFiles.docs);
204
- Object.assign(definitions, fromFiles.definitions);
205
- }
206
-
207
- // ── Resources ──────────────────────────────────────────
477
+ // ── Resources ──────────────────────────────────────────────────────
478
+ // Resources are URI-addressable listings; MCP clients typically enumerate
479
+ // them to discover what's available.
208
480
 
209
481
  server.resource('docs-list', 'showcase://docs', async () => {
210
482
  const names = Object.keys(docs);
@@ -231,7 +503,8 @@ export default function attachShowcase(server, options = {}) {
231
503
  for (const [cat, items] of Object.entries(categories)) {
232
504
  lines.push(`## ${cat}`);
233
505
  for (const item of items) {
234
- const imp = item.importName ? ` (${item.importName})` : '';
506
+ const impNames = Array.isArray(item.importName) ? item.importName.join(', ') : item.importName;
507
+ const imp = impNames ? ` (${impNames})` : '';
235
508
  lines.push(`- ${item.label}${imp}`);
236
509
  }
237
510
  lines.push('');
@@ -249,10 +522,14 @@ export default function attachShowcase(server, options = {}) {
249
522
  };
250
523
  });
251
524
 
252
- // ── Tools ──────────────────────────────────────────────
525
+ // ── Tools ──────────────────────────────────────────────────────────
526
+ // Tools are functions the model can call. All of ours are read-only —
527
+ // they only query the caches built above.
253
528
 
254
529
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
255
530
 
531
+ // Search across the showcase category tree. Matches on the raw filename,
532
+ // the display label, or any `importName` the Vue file declares.
256
533
  server.tool(
257
534
  'search_components',
258
535
  'Search showcase components by name or label',
@@ -263,7 +540,8 @@ export default function attachShowcase(server, options = {}) {
263
540
  const results = [];
264
541
  for (const [cat, items] of Object.entries(categories)) {
265
542
  for (const item of items) {
266
- if (item.name.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || (item.importName && item.importName.toLowerCase().includes(q))) {
543
+ const impNames = Array.isArray(item.importName) ? item.importName : (item.importName ? [item.importName] : []);
544
+ if (item.name.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || impNames.some(n => n.toLowerCase().includes(q))) {
267
545
  results.push({ category: cat, name: item.name, label: item.label });
268
546
  }
269
547
  }
@@ -279,6 +557,8 @@ export default function attachShowcase(server, options = {}) {
279
557
  },
280
558
  );
281
559
 
560
+ // Fetch a single showcase component's metadata + template snippet. Tries
561
+ // a case-insensitive match against file name, label, and importName.
282
562
  server.tool(
283
563
  'get_component',
284
564
  'Get a showcase component\'s template code and metadata',
@@ -287,10 +567,12 @@ export default function attachShowcase(server, options = {}) {
287
567
  async ({ name }) => {
288
568
  const q = name.toLowerCase();
289
569
  for (const [cat, items] of Object.entries(categories)) {
290
- const item = items.find(i => i.name.toLowerCase() === q || i.label.toLowerCase() === q || (i.importName && i.importName.toLowerCase() === q));
570
+ const impMatch = (imp) => { const names = Array.isArray(imp) ? imp : (imp ? [imp] : []); return names.some(n => n.toLowerCase() === q); };
571
+ const item = items.find(i => i.name.toLowerCase() === q || i.label.toLowerCase() === q || impMatch(i.importName));
291
572
  if (item) {
292
573
  const lines = [`# ${item.label}`, `Category: ${cat}`];
293
- if (item.importName) lines.push(`Import: ${item.importName} from '${item.importFrom || 'varmory'}'`);
574
+ const impNames = Array.isArray(item.importName) ? item.importName : (item.importName ? [item.importName] : []);
575
+ for (const n of impNames) lines.push(`Import: ${n} from '${item.importFrom || 'varmory'}'`);
294
576
  if (item.template) lines.push('', '## Template', '```html', item.template, '```');
295
577
  return { content: [{ type: 'text', text: lines.join('\n') }] };
296
578
  }
@@ -299,6 +581,9 @@ export default function attachShowcase(server, options = {}) {
299
581
  },
300
582
  );
301
583
 
584
+ // Render a component's full API (props, slots, events, methods) as
585
+ // markdown. Works on both hand-written and Quasar-src definitions — the
586
+ // normalize step upstream makes them interchangeable here.
302
587
  server.tool(
303
588
  'get_api',
304
589
  'Get the API definition (props, slots, events) for a component',
@@ -310,22 +595,55 @@ export default function attachShowcase(server, options = {}) {
310
595
  return { content: [{ type: 'text', text: `No API definition found for "${name}". Available: ${Object.keys(definitions).join(', ')}` }] };
311
596
  }
312
597
  const lines = [`# ${name} API`, '', '## Props', formatProps(def)];
313
- if (def.slots) {
598
+
599
+ if (def.slots && Object.keys(def.slots).length) {
314
600
  lines.push('', '## Slots');
315
601
  for (const [slot, info] of Object.entries(def.slots)) {
316
- lines.push(`- **${slot}** — ${info.desc || ''}`);
602
+ lines.push(`- **${slot}** — ${info.desc || ''}${formatMixinTag(info)}`);
603
+ // Scoped slot props — indented under the slot bullet.
604
+ if (info.scope && Object.keys(info.scope).length) {
605
+ for (const [k, v] of Object.entries(info.scope)) {
606
+ lines.push(` - \`props.${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
607
+ }
608
+ }
317
609
  }
318
610
  }
319
- if (def.events) {
611
+
612
+ if (def.events && Object.keys(def.events).length) {
320
613
  lines.push('', '## Events');
321
614
  for (const [event, info] of Object.entries(def.events)) {
322
- lines.push(`- **${event}** — ${info.desc || ''}`);
615
+ lines.push(`- **${event}** — ${info.desc || ''}${formatMixinTag(info)}`);
616
+ // Event payload args — one bullet per param.
617
+ if (info.params && Object.keys(info.params).length) {
618
+ for (const [k, v] of Object.entries(info.params)) {
619
+ lines.push(` - \`${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ if (def.methods && Object.keys(def.methods).length) {
626
+ lines.push('', '## Methods');
627
+ for (const [method, info] of Object.entries(def.methods)) {
628
+ lines.push(`- **${method}()** — ${info.desc || ''}${formatMixinTag(info)}`);
629
+ // Method params + return — indented under the method bullet.
630
+ if (info.params && Object.keys(info.params).length) {
631
+ for (const [k, v] of Object.entries(info.params)) {
632
+ const req = v?.required ? ' *(required)*' : '';
633
+ lines.push(` - param \`${k}\`${req}: ${formatType(v?.type)} — ${v?.desc || ''}`);
634
+ }
635
+ }
636
+ if (info.returns && typeof info.returns === 'object') {
637
+ lines.push(` - returns: ${formatType(info.returns.type)} — ${info.returns.desc || ''}`);
638
+ }
323
639
  }
324
640
  }
325
641
  return { content: [{ type: 'text', text: lines.join('\n') }] };
326
642
  },
327
643
  );
328
644
 
645
+ // Simple full-text search across docs. Returns the first matching line as
646
+ // a short snippet so the model can decide whether to fetch the full doc.
329
647
  server.tool(
330
648
  'search_docs',
331
649
  'Search documentation pages by name or content',
@@ -351,6 +669,7 @@ export default function attachShowcase(server, options = {}) {
351
669
  },
352
670
  );
353
671
 
672
+ // Return the full raw markdown of a single doc.
354
673
  server.tool(
355
674
  'get_doc',
356
675
  'Read a documentation page by name',