@epilot/volt-ui-mcp 0.1.4 → 0.3.0

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.
Files changed (6) hide show
  1. package/index.js +13 -335
  2. package/lib.d.ts +40 -0
  3. package/lib.js +499 -0
  4. package/package.json +12 -2
  5. package/registry.json +10503 -9197
  6. package/tools.js +226 -0
package/lib.js ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Pure query functions over the volt-ui registry.
3
+ *
4
+ * Shared by the stdio bin (index.js) and the remote MCP route on the docs app
5
+ * (docs/app/api/[transport]/route.ts). Transport wiring stays in the consumers —
6
+ * this module is plain data-in/data-out so both servers answer identically.
7
+ *
8
+ * Response-size discipline (remote clients hard-truncate large tool results):
9
+ * - list/search_components return lean summaries; search_components is capped to
10
+ * the top SEARCH_LIMIT matches (the true total stays in `count`)
11
+ * - list_tokens is topics-first: a bare call returns only the category index;
12
+ * token pages (DEFAULT_TOKEN_LIMIT) come back only when drilling into a group
13
+ */
14
+
15
+ export const DEFAULT_TOKEN_LIMIT = 200
16
+ export const MAX_TOKEN_LIMIT = 500
17
+
18
+ // Coerce a tool argument to a string before string ops — tolerant of null,
19
+ // undefined, or a non-string value from a misbehaving client. The transports
20
+ // already coerce, but these query functions are exported and called directly.
21
+ const asText = (value) =>
22
+ typeof value === "string" ? value : value == null ? "" : String(value)
23
+
24
+ // decodeURIComponent throws on malformed escapes (e.g. "%ZZ"); fall back to the
25
+ // raw segment so a bad resource URI yields a clean "not found", not a crash.
26
+ const safeDecode = (value) => {
27
+ try {
28
+ return decodeURIComponent(value)
29
+ } catch {
30
+ return value
31
+ }
32
+ }
33
+
34
+ // One-line descriptions of each token group, surfaced in the list_tokens
35
+ // `groups` index so consumers can see the full token taxonomy (not just colors)
36
+ // and learn how each group is used.
37
+ const GROUP_DESCRIPTIONS = {
38
+ spacing:
39
+ "Intent-based gap scale: element (inside a component), group (between items in a group), layout (between groups/sections). Shipped as gap-* and p-* utilities ONLY (not m-/space-); prefix volt- in consumer apps (e.g. volt-gap-group-2).",
40
+ palette: "Raw color scale steps per hue (1–12 plus alpha aN).",
41
+ semantic:
42
+ "Semantic color roles (accent/gray/success/error/warning/info × soft/solid/surface/contrast/…).",
43
+ utility: "Color utility aliases resolved from the palette.",
44
+ other:
45
+ "Uncategorized tokens — discovered from the CSS but not yet assigned a known group; surfaced (never dropped) so they can be classified.",
46
+ }
47
+
48
+ export function createQueries(registry) {
49
+ if (
50
+ !registry ||
51
+ !Array.isArray(registry.components) ||
52
+ registry.components.length === 0
53
+ ) {
54
+ // Fail loud: an empty/missing registry must never masquerade as a working
55
+ // server with 0 components (e.g. a file-tracing miss on the host).
56
+ throw new Error(
57
+ "volt-ui-mcp: registry is missing or empty — refusing to serve. " +
58
+ "Regenerate with `bun run build:mcp` from the repo root."
59
+ )
60
+ }
61
+
62
+ const tokens = registry.tokens || []
63
+ const guidelines = registry.guidelines || []
64
+
65
+ // Category index computed once — exposes which token groups exist (spacing,
66
+ // palette, semantic, utility, …) so consumers can tell the surface is
67
+ // complete and discover non-color tokens.
68
+ const tokenGroups = (() => {
69
+ const counts = {}
70
+ for (const token of tokens) {
71
+ counts[token.group] = (counts[token.group] || 0) + 1
72
+ }
73
+ return Object.keys(counts)
74
+ .sort()
75
+ .map((group) => ({
76
+ group,
77
+ count: counts[group],
78
+ description: GROUP_DESCRIPTIONS[group] || "",
79
+ }))
80
+ })()
81
+
82
+ // Lean summary for browse/search results — name/title/description only. The
83
+ // heavier fields (docs/source paths, urls, props, examples) come from
84
+ // get_component when the agent drills in.
85
+ function componentSummary(component) {
86
+ return {
87
+ name: component.name,
88
+ title: component.title,
89
+ description: component.description,
90
+ }
91
+ }
92
+
93
+ function findComponent(name) {
94
+ return registry.components.find(
95
+ (component) => component.name.toLowerCase() === name.toLowerCase()
96
+ )
97
+ }
98
+
99
+ function listComponents(query = "") {
100
+ const q = asText(query).trim().toLowerCase()
101
+ const components = registry.components.filter((component) => {
102
+ if (!q) {
103
+ return true
104
+ }
105
+ return (
106
+ component.name.toLowerCase().includes(q) ||
107
+ (component.description ?? "").toLowerCase().includes(q)
108
+ )
109
+ })
110
+
111
+ return {
112
+ count: components.length,
113
+ components: components.map(componentSummary),
114
+ }
115
+ }
116
+
117
+ // Lean default projection. composition/constraints/canonicalExample are
118
+ // high-signal and default-on (they close the composition-error gap); the
119
+ // bulky examples[] and recommendedTokens are opt-in via include=. aliases/
120
+ // useCases are search-only and never returned here.
121
+ function projectComponent(component, includeSet) {
122
+ const projected = {
123
+ name: component.name,
124
+ title: component.title,
125
+ description: component.description,
126
+ docsPath: component.docsPath,
127
+ docSlug: component.docSlug,
128
+ documentationUrl: component.documentationUrl,
129
+ apiReferenceUrl: component.apiReferenceUrl,
130
+ sourcePaths: component.sourcePaths,
131
+ props: component.props,
132
+ }
133
+ if (component.composition) {
134
+ projected.composition = component.composition
135
+ }
136
+ if (component.constraints) {
137
+ projected.constraints = component.constraints
138
+ }
139
+ if (component.canonicalExample) {
140
+ projected.canonicalExample = component.canonicalExample
141
+ }
142
+ const all = includeSet.has("all")
143
+ if (all || includeSet.has("examples")) {
144
+ projected.examples = component.examples
145
+ }
146
+ if ((all || includeSet.has("tokens")) && component.recommendedTokens) {
147
+ projected.recommendedTokens = component.recommendedTokens
148
+ }
149
+ return projected
150
+ }
151
+
152
+ function getComponent(name, include = "") {
153
+ const nameText = asText(name)
154
+ if (!nameText.trim()) {
155
+ return { error: "Component name is required." }
156
+ }
157
+ const component = findComponent(nameText)
158
+ if (!component) {
159
+ return { error: `Component not found: ${nameText}` }
160
+ }
161
+ const includeSet = new Set(
162
+ String(include || "")
163
+ .split(",")
164
+ .map((part) => part.trim().toLowerCase())
165
+ .filter(Boolean)
166
+ )
167
+ return projectComponent(component, includeSet)
168
+ }
169
+
170
+ const SEARCH_LIMIT = 25
171
+
172
+ function searchComponents(query) {
173
+ const qFull = asText(query).trim().toLowerCase()
174
+ if (!qFull) {
175
+ return { count: 0, returned: 0, hasMore: false, components: [] }
176
+ }
177
+ const qWords = qFull.split(/\s+/).filter(Boolean)
178
+
179
+ // Tiered scoring so an EXACT name match always beats an incidental intent
180
+ // substring (the old bug: "button" ranked Tooltip #1 via its useCase text).
181
+ // Exact alias phrase beats alias substring (so "dropdown" → Select, not
182
+ // Popover's "dropdown panel"). A final all/any-words tier gives recall for
183
+ // non-verbatim multi-word intents.
184
+ const scored = []
185
+ for (const component of registry.components) {
186
+ const name = (component.name || "").toLowerCase()
187
+ const title = (component.title || "").toLowerCase()
188
+ const description = (component.description || "").toLowerCase()
189
+ const aliases = (component.aliases || []).map((a) => a.toLowerCase())
190
+ const useCases = (component.useCases || []).map((u) => u.toLowerCase())
191
+ const aliasText = aliases.concat(useCases).join(" ")
192
+ const propNames = (component.props || [])
193
+ .map((prop) => prop.name)
194
+ .join(" ")
195
+ .toLowerCase()
196
+
197
+ let score = 0
198
+ if (name === qFull) {
199
+ score = 100
200
+ } else if (name.startsWith(qFull)) {
201
+ score = 85
202
+ } else if (aliases.some((a) => a === qFull)) {
203
+ score = 80
204
+ } else if (name.includes(qFull)) {
205
+ score = 70
206
+ } else if (aliasText.includes(qFull)) {
207
+ score = 55
208
+ } else if (title.includes(qFull)) {
209
+ score = 45
210
+ } else {
211
+ const hay = [name, aliasText, title, description, propNames].join(" ")
212
+ const hits = qWords.filter((w) => hay.includes(w)).length
213
+ if (qWords.length > 0 && hits === qWords.length) {
214
+ score = 30 + hits
215
+ } else if (hits > 0) {
216
+ score = 10 + hits
217
+ }
218
+ }
219
+ if (score > 0) {
220
+ scored.push({ component, score })
221
+ }
222
+ }
223
+
224
+ scored.sort(
225
+ (a, b) =>
226
+ b.score - a.score || a.component.name.localeCompare(b.component.name)
227
+ )
228
+
229
+ // Summaries only, capped to the most relevant — full objects blow up remote
230
+ // clients on broad queries; callers drill in via get_component. `count` is
231
+ // the true match total so the caller knows results were capped.
232
+ const page = scored.slice(0, SEARCH_LIMIT)
233
+ return {
234
+ count: scored.length,
235
+ returned: page.length,
236
+ hasMore: scored.length > page.length,
237
+ components: page.map((entry) => componentSummary(entry.component)),
238
+ }
239
+ }
240
+
241
+ function listTokens({
242
+ query = "",
243
+ theme = "",
244
+ group = "",
245
+ limit = DEFAULT_TOKEN_LIMIT,
246
+ offset = 0,
247
+ } = {}) {
248
+ const q = asText(query).trim().toLowerCase()
249
+ const t = asText(theme).trim().toLowerCase()
250
+ const g = asText(group).trim().toLowerCase()
251
+
252
+ // Drill-down level 0: with no query and no group, return only the category
253
+ // index (cheap) so an agent can see the full taxonomy and pick a category
254
+ // to drill into — instead of receiving a ~200-token color dump.
255
+ if (!q && !g) {
256
+ return {
257
+ mode: "categories",
258
+ groups: tokenGroups,
259
+ hint: "Drill in with list_tokens({ group }) for a category's tokens, or search_tokens(<name|intent>) / get_token(<name>) to narrow large color groups.",
260
+ tokens: [],
261
+ }
262
+ }
263
+
264
+ const matched = tokens.filter((token) => {
265
+ if (t && token.theme.toLowerCase() !== t) {
266
+ return false
267
+ }
268
+ if (g && token.group.toLowerCase() !== g) {
269
+ return false
270
+ }
271
+ if (!q) {
272
+ return true
273
+ }
274
+ // Match name/value AND intent metadata (utility/usage) so non-color
275
+ // tokens are discoverable by intent — e.g. "gap" or "spacing" → the
276
+ // gap-token scale, "between items" → the group tier.
277
+ return (
278
+ token.name.toLowerCase().includes(q) ||
279
+ token.value.toLowerCase().includes(q) ||
280
+ (token.utility || "").toLowerCase().includes(q) ||
281
+ (token.usage || "").toLowerCase().includes(q)
282
+ )
283
+ })
284
+
285
+ const safeLimit = Math.min(
286
+ Math.max(1, Number(limit) || DEFAULT_TOKEN_LIMIT),
287
+ MAX_TOKEN_LIMIT
288
+ )
289
+ const safeOffset = Math.max(0, Number(offset) || 0)
290
+ const page = matched.slice(safeOffset, safeOffset + safeLimit)
291
+
292
+ return {
293
+ count: matched.length,
294
+ returned: page.length,
295
+ offset: safeOffset,
296
+ limit: safeLimit,
297
+ hasMore: safeOffset + page.length < matched.length,
298
+ // Always present so consumers can see the full token taxonomy and tell
299
+ // the surface is complete (not color-only).
300
+ groups: tokenGroups,
301
+ tokens: page,
302
+ }
303
+ }
304
+
305
+ function getToken(name) {
306
+ const target = asText(name).trim().toLowerCase()
307
+ if (!target) {
308
+ return { error: "Token name is required." }
309
+ }
310
+ const matches = tokens.filter(
311
+ (token) => token.name.toLowerCase() === target
312
+ )
313
+ if (matches.length === 0) {
314
+ return { error: `Token not found: ${name}` }
315
+ }
316
+ return {
317
+ name,
318
+ tokens: matches,
319
+ }
320
+ }
321
+
322
+ function searchTokens(query) {
323
+ const qFull = asText(query).trim().toLowerCase()
324
+ if (!qFull) {
325
+ return {
326
+ count: 0,
327
+ returned: 0,
328
+ hasMore: false,
329
+ groups: tokenGroups,
330
+ tokens: [],
331
+ }
332
+ }
333
+ // Word-tokenized (like search_components): a match is the whole phrase OR
334
+ // every query word appearing in the token's searchable text, so reworded
335
+ // multi-word intents ("between groups", "icon to label") still resolve.
336
+ const qWords = qFull.split(/\s+/).filter(Boolean)
337
+ const matched = tokens.filter((token) => {
338
+ const hay = [token.name, token.value, token.utility, token.usage]
339
+ .filter(Boolean)
340
+ .join(" ")
341
+ .toLowerCase()
342
+ return hay.includes(qFull) || qWords.every((w) => hay.includes(w))
343
+ })
344
+ const page = matched.slice(0, DEFAULT_TOKEN_LIMIT)
345
+ return {
346
+ count: matched.length,
347
+ returned: page.length,
348
+ hasMore: matched.length > page.length,
349
+ groups: tokenGroups,
350
+ tokens: page,
351
+ }
352
+ }
353
+
354
+ function guidelineSummary(guideline) {
355
+ return {
356
+ slug: guideline.slug,
357
+ title: guideline.title,
358
+ description: guideline.description,
359
+ headings: guideline.headings,
360
+ }
361
+ }
362
+
363
+ function listGuidelines(query = "") {
364
+ const q = asText(query).trim().toLowerCase()
365
+ const matched = guidelines.filter((guideline) => {
366
+ if (!q) {
367
+ return true
368
+ }
369
+ const haystack = [
370
+ guideline.slug,
371
+ guideline.title,
372
+ guideline.description,
373
+ (guideline.headings || []).join(" "),
374
+ ]
375
+ .filter(Boolean)
376
+ .join(" ")
377
+ .toLowerCase()
378
+ return haystack.includes(q)
379
+ })
380
+ // Index only (no body) — get_guideline returns the full prose on demand.
381
+ return {
382
+ count: matched.length,
383
+ guidelines: matched.map(guidelineSummary),
384
+ }
385
+ }
386
+
387
+ function getGuideline(slug) {
388
+ const target = asText(slug).trim().toLowerCase()
389
+ if (!target) {
390
+ return { error: "Guideline slug is required." }
391
+ }
392
+ const guideline = guidelines.find(
393
+ (entry) => entry.slug.toLowerCase() === target
394
+ )
395
+ if (!guideline) {
396
+ return { error: `Guideline not found: ${slug}` }
397
+ }
398
+ // Project out the internal sourcePath — consumers get the content, not the
399
+ // repo layout (consistent with the other tools' projections).
400
+ return {
401
+ slug: guideline.slug,
402
+ title: guideline.title,
403
+ description: guideline.description,
404
+ headings: guideline.headings,
405
+ body: guideline.body,
406
+ }
407
+ }
408
+
409
+ function resolveResource(uri) {
410
+ const u = asText(uri)
411
+ if (u === "volt-ui://components") {
412
+ return listComponents("")
413
+ }
414
+ if (u === "volt-ui://tokens") {
415
+ return listTokens({})
416
+ }
417
+ if (u.startsWith("volt-ui://components/")) {
418
+ const name = safeDecode(u.replace("volt-ui://components/", ""))
419
+ // Resources have no include= param, so return full detail (examples +
420
+ // tokens) — a resource read shouldn't silently drop usage examples.
421
+ return getComponent(name, "all")
422
+ }
423
+ if (u.startsWith("volt-ui://tokens/")) {
424
+ const name = safeDecode(u.replace("volt-ui://tokens/", ""))
425
+ return getToken(name)
426
+ }
427
+ return { error: `Unknown resource: ${u}` }
428
+ }
429
+
430
+ function listResources() {
431
+ const baseResources = [
432
+ {
433
+ uri: "volt-ui://components",
434
+ name: "Volt UI Components",
435
+ description: "List of all available Volt UI components.",
436
+ mimeType: "application/json",
437
+ },
438
+ {
439
+ uri: "volt-ui://tokens",
440
+ name: "Volt UI Tokens",
441
+ description: "List of Volt UI design tokens.",
442
+ mimeType: "application/json",
443
+ },
444
+ ]
445
+
446
+ const componentResources = registry.components.map((component) => ({
447
+ uri: `volt-ui://components/${encodeURIComponent(component.name)}`,
448
+ name: component.name,
449
+ description: component.description ?? "",
450
+ mimeType: "application/json",
451
+ }))
452
+
453
+ const tokenNames = Array.from(
454
+ new Set(tokens.map((token) => token.name))
455
+ ).sort((a, b) => a.localeCompare(b))
456
+ const tokenResources = tokenNames.map((name) => ({
457
+ uri: `volt-ui://tokens/${encodeURIComponent(name)}`,
458
+ name,
459
+ description: "Design token",
460
+ mimeType: "application/json",
461
+ }))
462
+
463
+ return [...baseResources, ...componentResources, ...tokenResources]
464
+ }
465
+
466
+ return {
467
+ listComponents,
468
+ getComponent,
469
+ searchComponents,
470
+ listTokens,
471
+ getToken,
472
+ searchTokens,
473
+ listGuidelines,
474
+ getGuideline,
475
+ resolveResource,
476
+ listResources,
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Compact tool-result envelope (no pretty-printing — size discipline). Sets
482
+ * isError:true for error-shaped payloads ({error}) so MCP clients surface the
483
+ * failure instead of silently consuming it as a successful result.
484
+ */
485
+ export function toolResult(payload) {
486
+ const isError =
487
+ payload != null &&
488
+ typeof payload === "object" &&
489
+ typeof payload.error === "string"
490
+ return {
491
+ content: [
492
+ {
493
+ type: "text",
494
+ text: JSON.stringify(payload),
495
+ },
496
+ ],
497
+ ...(isError ? { isError: true } : {}),
498
+ }
499
+ }
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "@epilot/volt-ui-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "volt-ui-mcp": "./index.js"
8
8
  },
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./lib.js": "./lib.js",
12
+ "./tools.js": "./tools.js",
13
+ "./registry.json": "./registry.json"
14
+ },
9
15
  "files": [
10
16
  "index.js",
17
+ "lib.js",
18
+ "lib.d.ts",
19
+ "tools.js",
11
20
  "registry.json"
12
21
  ],
13
22
  "scripts": {
14
- "prepack": "bun ../react/scripts/build-mcp-registry.ts"
23
+ "prepack": "bun ../react/scripts/build-mcp-registry.ts",
24
+ "test": "bun test"
15
25
  },
16
26
  "dependencies": {
17
27
  "@modelcontextprotocol/sdk": "^1.0.0"