nyxui-mcp 1.0.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.
- package/mcp-server.mjs +686 -0
- package/package.json +40 -0
- package/readme.md +64 -0
package/mcp-server.mjs
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
McpServer,
|
|
6
|
+
ResourceTemplate,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const siteUrl = "https://nyxui.com";
|
|
13
|
+
|
|
14
|
+
const docSections = ["components", "templates", "blocks", "root"];
|
|
15
|
+
const registryTypes = [
|
|
16
|
+
"registry:style",
|
|
17
|
+
"registry:ui",
|
|
18
|
+
"registry:example",
|
|
19
|
+
"registry:lib",
|
|
20
|
+
];
|
|
21
|
+
const registryKinds = ["component", "example", "style", "utility"];
|
|
22
|
+
|
|
23
|
+
let docsCache;
|
|
24
|
+
let registryCache;
|
|
25
|
+
const publicRegistryCache = new Map();
|
|
26
|
+
|
|
27
|
+
// ── fetchers ───────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
async function getRegistry() {
|
|
30
|
+
if (registryCache) return registryCache;
|
|
31
|
+
const res = await fetch(`${siteUrl}/registry.json`);
|
|
32
|
+
if (!res.ok) throw new Error(`Failed to fetch registry: ${res.status}`);
|
|
33
|
+
registryCache = await res.json();
|
|
34
|
+
return registryCache;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getPublicRegistryItem(name) {
|
|
38
|
+
if (publicRegistryCache.has(name)) return publicRegistryCache.get(name);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${siteUrl}/r/${name}.json`);
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
publicRegistryCache.set(name, null);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const parsed = await res.json();
|
|
46
|
+
publicRegistryCache.set(name, parsed);
|
|
47
|
+
return parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
publicRegistryCache.set(name, null);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getDocs() {
|
|
55
|
+
if (docsCache) return docsCache;
|
|
56
|
+
const res = await fetch(`${siteUrl}/api/docs`);
|
|
57
|
+
if (!res.ok) throw new Error(`Failed to fetch docs: ${res.status}`);
|
|
58
|
+
const raw = await res.json();
|
|
59
|
+
|
|
60
|
+
// parse frontmatter from each doc
|
|
61
|
+
docsCache = raw.map(({ slug, content }) => {
|
|
62
|
+
const parsed = parseFrontmatter(content);
|
|
63
|
+
const section = getDocSection(slug);
|
|
64
|
+
return {
|
|
65
|
+
slug,
|
|
66
|
+
section,
|
|
67
|
+
title: typeof parsed.data.title === "string" ? parsed.data.title : slug,
|
|
68
|
+
description:
|
|
69
|
+
typeof parsed.data.description === "string"
|
|
70
|
+
? parsed.data.description
|
|
71
|
+
: "",
|
|
72
|
+
tags: Array.isArray(parsed.data.tags) ? parsed.data.tags : [],
|
|
73
|
+
published: parsed.data.published !== false,
|
|
74
|
+
frontmatter: parsed.data,
|
|
75
|
+
body: parsed.body,
|
|
76
|
+
content,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
docsCache.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
81
|
+
return docsCache;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── frontmatter parser ─────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function cleanValue(value) {
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (
|
|
89
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
90
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
91
|
+
) {
|
|
92
|
+
return trimmed.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
if (trimmed === "true") return true;
|
|
95
|
+
if (trimmed === "false") return false;
|
|
96
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseArray(value) {
|
|
101
|
+
const inner = value.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
102
|
+
if (!inner) return [];
|
|
103
|
+
return inner
|
|
104
|
+
.split(",")
|
|
105
|
+
.map((item) => cleanValue(item))
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseFrontmatter(raw) {
|
|
110
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
111
|
+
if (!match) return { data: {}, body: raw };
|
|
112
|
+
|
|
113
|
+
const block = match[1];
|
|
114
|
+
const data = {};
|
|
115
|
+
|
|
116
|
+
for (const line of block.split(/\r?\n/)) {
|
|
117
|
+
const keyValue = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
118
|
+
if (!keyValue) continue;
|
|
119
|
+
const [, key, value] = keyValue;
|
|
120
|
+
if (value.trim().startsWith("[") && value.trim().endsWith("]")) {
|
|
121
|
+
data[key] = parseArray(value);
|
|
122
|
+
} else {
|
|
123
|
+
data[key] = cleanValue(value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { data, body: raw.slice(match[0].length) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── helpers ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function normalizeText(value) {
|
|
133
|
+
return value
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
136
|
+
.trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function tokenize(value) {
|
|
140
|
+
return new Set(
|
|
141
|
+
normalizeText(value)
|
|
142
|
+
.split(/\s+/)
|
|
143
|
+
.filter((t) => t.length > 2),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function escapeRegExp(value) {
|
|
148
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getDocSection(slug) {
|
|
152
|
+
const section = slug.split("/")[0];
|
|
153
|
+
return docSections.includes(section) ? section : "root";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getDocUrl(doc) {
|
|
157
|
+
if (doc.section === "components")
|
|
158
|
+
return `${siteUrl}/components/${doc.slug.replace(/^components\//, "")}`;
|
|
159
|
+
if (doc.section === "templates")
|
|
160
|
+
return `${siteUrl}/templates/${doc.slug.replace(/^templates\//, "")}`;
|
|
161
|
+
if (doc.section === "blocks")
|
|
162
|
+
return `${siteUrl}/blocks/${doc.slug.replace(/^blocks\//, "")}`;
|
|
163
|
+
if (doc.slug === "index") return `${siteUrl}/docs`;
|
|
164
|
+
return `${siteUrl}/docs/${doc.slug}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildSnippet(body, query) {
|
|
168
|
+
const normalizedBody = body.replace(/\s+/g, " ").trim();
|
|
169
|
+
const normalizedQuery = query.toLowerCase();
|
|
170
|
+
const index = normalizedBody.toLowerCase().indexOf(normalizedQuery);
|
|
171
|
+
if (index === -1) return normalizedBody.slice(0, 220);
|
|
172
|
+
const start = Math.max(0, index - 80);
|
|
173
|
+
const end = Math.min(normalizedBody.length, index + query.length + 140);
|
|
174
|
+
return normalizedBody.slice(start, end);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scoreDoc(doc, query) {
|
|
178
|
+
const haystack =
|
|
179
|
+
`${doc.title}\n${doc.description}\n${doc.body}`.toLowerCase();
|
|
180
|
+
const normalizedQuery = query.toLowerCase();
|
|
181
|
+
if (!haystack.includes(normalizedQuery)) return 0;
|
|
182
|
+
let score = 1;
|
|
183
|
+
if (doc.title.toLowerCase().includes(normalizedQuery)) score += 5;
|
|
184
|
+
if (doc.description.toLowerCase().includes(normalizedQuery)) score += 3;
|
|
185
|
+
const matches = haystack.match(
|
|
186
|
+
new RegExp(escapeRegExp(normalizedQuery), "g"),
|
|
187
|
+
);
|
|
188
|
+
score += Math.min(matches?.length ?? 0, 10);
|
|
189
|
+
return score;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getRegistryKind(item) {
|
|
193
|
+
switch (item.type) {
|
|
194
|
+
case "registry:ui":
|
|
195
|
+
return "component";
|
|
196
|
+
case "registry:example":
|
|
197
|
+
return "example";
|
|
198
|
+
case "registry:style":
|
|
199
|
+
return "style";
|
|
200
|
+
case "registry:lib":
|
|
201
|
+
return "utility";
|
|
202
|
+
default:
|
|
203
|
+
return item.type;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function matchesRegistryFilter(item, filter) {
|
|
208
|
+
if (!filter) return true;
|
|
209
|
+
if (registryTypes.includes(filter)) return item.type === filter;
|
|
210
|
+
switch (filter) {
|
|
211
|
+
case "component":
|
|
212
|
+
return item.type === "registry:ui";
|
|
213
|
+
case "example":
|
|
214
|
+
return item.type === "registry:example";
|
|
215
|
+
case "style":
|
|
216
|
+
return item.type === "registry:style";
|
|
217
|
+
case "utility":
|
|
218
|
+
return item.type === "registry:lib";
|
|
219
|
+
default:
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function scoreRegistryItem(item, query) {
|
|
225
|
+
const kind = getRegistryKind(item);
|
|
226
|
+
const haystack = [
|
|
227
|
+
item.name,
|
|
228
|
+
item.title ?? "",
|
|
229
|
+
item.description ?? "",
|
|
230
|
+
item.type,
|
|
231
|
+
kind,
|
|
232
|
+
]
|
|
233
|
+
.join("\n")
|
|
234
|
+
.toLowerCase();
|
|
235
|
+
const normalizedQuery = query.toLowerCase();
|
|
236
|
+
if (!haystack.includes(normalizedQuery)) return 0;
|
|
237
|
+
let score = 1;
|
|
238
|
+
if (item.name.toLowerCase() === normalizedQuery) score += 10;
|
|
239
|
+
if (item.name.toLowerCase().includes(normalizedQuery)) score += 6;
|
|
240
|
+
if ((item.title ?? "").toLowerCase().includes(normalizedQuery)) score += 5;
|
|
241
|
+
if ((item.description ?? "").toLowerCase().includes(normalizedQuery))
|
|
242
|
+
score += 3;
|
|
243
|
+
if (kind.includes(normalizedQuery)) score += 2;
|
|
244
|
+
const matches = haystack.match(
|
|
245
|
+
new RegExp(escapeRegExp(normalizedQuery), "g"),
|
|
246
|
+
);
|
|
247
|
+
score += Math.min(matches?.length ?? 0, 10);
|
|
248
|
+
return score;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeRegistryDependency(value) {
|
|
252
|
+
if (typeof value !== "string") return "";
|
|
253
|
+
const normalized = value.includes("/r/") ? value.split("/r/").pop() : value;
|
|
254
|
+
return normalized.replace(/\.json$/, "");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getInstallUrl(name) {
|
|
258
|
+
return `${siteUrl}/r/${name}.json`;
|
|
259
|
+
}
|
|
260
|
+
function getInstallCommand(name) {
|
|
261
|
+
return `npx shadcn@latest add "${getInstallUrl(name)}"`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function findDocForName(docs, name) {
|
|
265
|
+
const exactCandidates = [
|
|
266
|
+
`components/${name}`,
|
|
267
|
+
`templates/${name}`,
|
|
268
|
+
`blocks/${name}`,
|
|
269
|
+
name,
|
|
270
|
+
];
|
|
271
|
+
return (
|
|
272
|
+
docs.find((doc) => exactCandidates.includes(doc.slug)) ??
|
|
273
|
+
docs.find((doc) => doc.slug.endsWith(`/${name}`)) ??
|
|
274
|
+
null
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getExamplesForItem(registry, name) {
|
|
279
|
+
return registry.items.filter(
|
|
280
|
+
(item) =>
|
|
281
|
+
item.type === "registry:example" &&
|
|
282
|
+
(item.registryDependencies ?? []).some(
|
|
283
|
+
(dep) => normalizeRegistryDependency(dep) === name,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildRegistryPreview(item, docs, registry) {
|
|
289
|
+
const doc = findDocForName(docs, item.name);
|
|
290
|
+
const examples = getExamplesForItem(registry, item.name);
|
|
291
|
+
return {
|
|
292
|
+
name: item.name,
|
|
293
|
+
kind: getRegistryKind(item),
|
|
294
|
+
type: item.type,
|
|
295
|
+
title: item.title ?? item.name,
|
|
296
|
+
description: item.description ?? null,
|
|
297
|
+
installUrl: getInstallUrl(item.name),
|
|
298
|
+
installCommand: getInstallCommand(item.name),
|
|
299
|
+
docsUrl: doc ? getDocUrl(doc) : null,
|
|
300
|
+
exampleNames: examples.slice(0, 5).map((e) => e.name),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getRelatedItems(baseItem, docs, registry, limit = 5) {
|
|
305
|
+
const baseDoc = findDocForName(docs, baseItem.name);
|
|
306
|
+
const baseTags = new Set(
|
|
307
|
+
(baseDoc?.tags ?? []).map((tag) => normalizeText(tag)),
|
|
308
|
+
);
|
|
309
|
+
const baseTokens = tokenize(
|
|
310
|
+
`${baseItem.name} ${baseItem.title ?? ""} ${baseItem.description ?? ""}`,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return registry.items
|
|
314
|
+
.filter((c) => c.name !== baseItem.name && c.type === baseItem.type)
|
|
315
|
+
.map((candidate) => {
|
|
316
|
+
const candidateDoc = findDocForName(docs, candidate.name);
|
|
317
|
+
const candidateTokens = tokenize(
|
|
318
|
+
`${candidate.name} ${candidate.title ?? ""} ${candidate.description ?? ""}`,
|
|
319
|
+
);
|
|
320
|
+
let score = 0;
|
|
321
|
+
if (baseDoc && candidateDoc && candidateDoc.section === baseDoc.section)
|
|
322
|
+
score += 2;
|
|
323
|
+
if (baseDoc && candidateDoc) {
|
|
324
|
+
const sharedTags = (candidateDoc.tags ?? []).filter((tag) =>
|
|
325
|
+
baseTags.has(normalizeText(tag)),
|
|
326
|
+
).length;
|
|
327
|
+
score += sharedTags * 3;
|
|
328
|
+
}
|
|
329
|
+
for (const token of candidateTokens) {
|
|
330
|
+
if (baseTokens.has(token)) score += 1;
|
|
331
|
+
}
|
|
332
|
+
return { candidate, score };
|
|
333
|
+
})
|
|
334
|
+
.filter((e) => e.score > 0)
|
|
335
|
+
.sort(
|
|
336
|
+
(a, b) =>
|
|
337
|
+
b.score - a.score || a.candidate.name.localeCompare(b.candidate.name),
|
|
338
|
+
)
|
|
339
|
+
.slice(0, limit)
|
|
340
|
+
.map(({ candidate }) => buildRegistryPreview(candidate, docs, registry));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function buildRegistryItemPayload(
|
|
344
|
+
item,
|
|
345
|
+
{ includeSource = false, includeExamples = true, includeRelated = true } = {},
|
|
346
|
+
) {
|
|
347
|
+
const docs = await getDocs();
|
|
348
|
+
const registry = await getRegistry();
|
|
349
|
+
const doc = findDocForName(docs, item.name);
|
|
350
|
+
const publicItem = await getPublicRegistryItem(item.name);
|
|
351
|
+
const mergedItem = publicItem ? { ...item, ...publicItem } : item;
|
|
352
|
+
|
|
353
|
+
const files = includeSource
|
|
354
|
+
? (mergedItem.files ?? [])
|
|
355
|
+
: (mergedItem.files ?? []).map(({ content, ...file }) => file);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
name: mergedItem.name,
|
|
359
|
+
kind: getRegistryKind(mergedItem),
|
|
360
|
+
type: mergedItem.type,
|
|
361
|
+
title: mergedItem.title ?? mergedItem.name,
|
|
362
|
+
description: mergedItem.description ?? null,
|
|
363
|
+
install: {
|
|
364
|
+
url: getInstallUrl(mergedItem.name),
|
|
365
|
+
command: getInstallCommand(mergedItem.name),
|
|
366
|
+
},
|
|
367
|
+
docs: doc
|
|
368
|
+
? {
|
|
369
|
+
slug: doc.slug,
|
|
370
|
+
section: doc.section,
|
|
371
|
+
title: doc.title,
|
|
372
|
+
description: doc.description,
|
|
373
|
+
tags: doc.tags,
|
|
374
|
+
url: getDocUrl(doc),
|
|
375
|
+
}
|
|
376
|
+
: null,
|
|
377
|
+
dependencies: mergedItem.dependencies ?? [],
|
|
378
|
+
devDependencies: mergedItem.devDependencies ?? [],
|
|
379
|
+
registryDependencies: mergedItem.registryDependencies ?? [],
|
|
380
|
+
files,
|
|
381
|
+
cssVars: mergedItem.cssVars ?? null,
|
|
382
|
+
css: mergedItem.css ?? null,
|
|
383
|
+
examples: includeExamples
|
|
384
|
+
? getExamplesForItem(registry, mergedItem.name).map((e) =>
|
|
385
|
+
buildRegistryPreview(e, docs, registry),
|
|
386
|
+
)
|
|
387
|
+
: [],
|
|
388
|
+
relatedItems: includeRelated
|
|
389
|
+
? getRelatedItems(mergedItem, docs, registry)
|
|
390
|
+
: [],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function listRegistryItemsResult({ filter, query, limit, offset }) {
|
|
395
|
+
const docs = await getDocs();
|
|
396
|
+
const registry = await getRegistry();
|
|
397
|
+
|
|
398
|
+
const filteredItems = registry.items
|
|
399
|
+
.filter((item) => matchesRegistryFilter(item, filter))
|
|
400
|
+
.map((item) => ({
|
|
401
|
+
item,
|
|
402
|
+
score: query ? scoreRegistryItem(item, query) : 1,
|
|
403
|
+
}))
|
|
404
|
+
.filter((e) => e.score > 0)
|
|
405
|
+
.sort((a, b) =>
|
|
406
|
+
query
|
|
407
|
+
? b.score - a.score || a.item.name.localeCompare(b.item.name)
|
|
408
|
+
: a.item.name.localeCompare(b.item.name),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const total = filteredItems.length;
|
|
412
|
+
const items = filteredItems
|
|
413
|
+
.slice(offset, offset + limit)
|
|
414
|
+
.map(({ item }) => buildRegistryPreview(item, docs, registry));
|
|
415
|
+
|
|
416
|
+
return { total, limit, offset, items };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── result helpers ─────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
function textResult(value) {
|
|
422
|
+
const text =
|
|
423
|
+
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
424
|
+
return { content: [{ type: "text", text }] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function errorResult(message) {
|
|
428
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function validateRegistryFilter(filter) {
|
|
432
|
+
if (!filter) return null;
|
|
433
|
+
if (registryKinds.includes(filter) || registryTypes.includes(filter))
|
|
434
|
+
return null;
|
|
435
|
+
return `Invalid registry filter "${filter}". Use one of ${[...registryKinds, ...registryTypes].join(", ")}.`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── server ─────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
export function createServer() {
|
|
441
|
+
const server = new McpServer({ name: "nyxui-mcp", version: "1.0.0" });
|
|
442
|
+
|
|
443
|
+
server.tool(
|
|
444
|
+
"listRegistryItems",
|
|
445
|
+
{
|
|
446
|
+
kind: z.string().optional(),
|
|
447
|
+
query: z.string().min(1).optional(),
|
|
448
|
+
limit: z.number().int().min(1).max(50).default(20),
|
|
449
|
+
offset: z.number().int().min(0).default(0),
|
|
450
|
+
},
|
|
451
|
+
async ({ kind, query, limit, offset }) => {
|
|
452
|
+
const validationError = validateRegistryFilter(kind);
|
|
453
|
+
if (validationError) return errorResult(validationError);
|
|
454
|
+
return textResult(
|
|
455
|
+
await listRegistryItemsResult({ filter: kind, query, limit, offset }),
|
|
456
|
+
);
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
server.tool(
|
|
461
|
+
"searchRegistryItems",
|
|
462
|
+
{
|
|
463
|
+
query: z.string().min(1),
|
|
464
|
+
kind: z.string().optional(),
|
|
465
|
+
limit: z.number().int().min(1).max(50).default(10),
|
|
466
|
+
offset: z.number().int().min(0).default(0),
|
|
467
|
+
},
|
|
468
|
+
async ({ query, kind, limit, offset }) => {
|
|
469
|
+
const validationError = validateRegistryFilter(kind);
|
|
470
|
+
if (validationError) return errorResult(validationError);
|
|
471
|
+
return textResult(
|
|
472
|
+
await listRegistryItemsResult({ filter: kind, query, limit, offset }),
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
server.tool(
|
|
478
|
+
"getRegistryItem",
|
|
479
|
+
{
|
|
480
|
+
name: z.string().min(1),
|
|
481
|
+
includeSource: z.boolean().default(false),
|
|
482
|
+
includeExamples: z.boolean().default(true),
|
|
483
|
+
includeRelated: z.boolean().default(true),
|
|
484
|
+
},
|
|
485
|
+
async ({ name, includeSource, includeExamples, includeRelated }) => {
|
|
486
|
+
const registry = await getRegistry();
|
|
487
|
+
const item = registry.items.find((e) => e.name === name);
|
|
488
|
+
if (!item) return errorResult(`Registry item "${name}" not found.`);
|
|
489
|
+
return textResult(
|
|
490
|
+
await buildRegistryItemPayload(item, {
|
|
491
|
+
includeSource,
|
|
492
|
+
includeExamples,
|
|
493
|
+
includeRelated,
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
server.tool("project_overview", {}, async () => {
|
|
500
|
+
const docs = await getDocs();
|
|
501
|
+
const registry = await getRegistry();
|
|
502
|
+
|
|
503
|
+
const docsBySection = docs.reduce((acc, doc) => {
|
|
504
|
+
acc[doc.section] = (acc[doc.section] ?? 0) + 1;
|
|
505
|
+
return acc;
|
|
506
|
+
}, {});
|
|
507
|
+
|
|
508
|
+
const registryByKind = registry.items.reduce((acc, item) => {
|
|
509
|
+
const kind = getRegistryKind(item);
|
|
510
|
+
acc[kind] = (acc[kind] ?? 0) + 1;
|
|
511
|
+
return acc;
|
|
512
|
+
}, {});
|
|
513
|
+
|
|
514
|
+
return textResult({
|
|
515
|
+
name: "Nyx UI",
|
|
516
|
+
siteUrl,
|
|
517
|
+
docsCount: docs.length,
|
|
518
|
+
registryItemCount: registry.items.length,
|
|
519
|
+
docsBySection,
|
|
520
|
+
registryByKind,
|
|
521
|
+
primaryTools: [
|
|
522
|
+
"listRegistryItems",
|
|
523
|
+
"searchRegistryItems",
|
|
524
|
+
"getRegistryItem",
|
|
525
|
+
],
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
server.tool(
|
|
530
|
+
"list_docs",
|
|
531
|
+
{
|
|
532
|
+
section: z.enum(["components", "templates", "blocks", "root"]).optional(),
|
|
533
|
+
includeUnpublished: z.boolean().default(false),
|
|
534
|
+
},
|
|
535
|
+
async ({ section, includeUnpublished }) => {
|
|
536
|
+
const docs = await getDocs();
|
|
537
|
+
const filteredDocs = docs
|
|
538
|
+
.filter((doc) => !section || doc.section === section)
|
|
539
|
+
.filter((doc) => includeUnpublished || doc.published);
|
|
540
|
+
return textResult(
|
|
541
|
+
filteredDocs.map((doc) => ({
|
|
542
|
+
slug: doc.slug,
|
|
543
|
+
section: doc.section,
|
|
544
|
+
title: doc.title,
|
|
545
|
+
description: doc.description,
|
|
546
|
+
tags: doc.tags,
|
|
547
|
+
published: doc.published,
|
|
548
|
+
url: getDocUrl(doc),
|
|
549
|
+
})),
|
|
550
|
+
);
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
server.tool("get_doc", { slug: z.string().min(1) }, async ({ slug }) => {
|
|
555
|
+
const docs = await getDocs();
|
|
556
|
+
const doc = docs.find((e) => e.slug === slug.replace(/\.mdx$/, ""));
|
|
557
|
+
if (!doc) return errorResult(`Document not found for slug "${slug}".`);
|
|
558
|
+
return textResult({
|
|
559
|
+
slug: doc.slug,
|
|
560
|
+
section: doc.section,
|
|
561
|
+
title: doc.title,
|
|
562
|
+
description: doc.description,
|
|
563
|
+
frontmatter: doc.frontmatter,
|
|
564
|
+
url: getDocUrl(doc),
|
|
565
|
+
body: doc.body,
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
server.tool(
|
|
570
|
+
"search_docs",
|
|
571
|
+
{
|
|
572
|
+
query: z.string().min(1),
|
|
573
|
+
section: z.enum(["components", "templates", "blocks", "root"]).optional(),
|
|
574
|
+
limit: z.number().int().min(1).max(20).default(5),
|
|
575
|
+
includeUnpublished: z.boolean().default(false),
|
|
576
|
+
},
|
|
577
|
+
async ({ query, section, limit, includeUnpublished }) => {
|
|
578
|
+
const docs = await getDocs();
|
|
579
|
+
const matches = docs
|
|
580
|
+
.filter((doc) => !section || doc.section === section)
|
|
581
|
+
.filter((doc) => includeUnpublished || doc.published)
|
|
582
|
+
.map((doc) => ({ doc, score: scoreDoc(doc, query) }))
|
|
583
|
+
.filter((r) => r.score > 0)
|
|
584
|
+
.sort((a, b) => b.score - a.score)
|
|
585
|
+
.slice(0, limit)
|
|
586
|
+
.map(({ doc, score }) => ({
|
|
587
|
+
slug: doc.slug,
|
|
588
|
+
section: doc.section,
|
|
589
|
+
title: doc.title,
|
|
590
|
+
description: doc.description,
|
|
591
|
+
score,
|
|
592
|
+
url: getDocUrl(doc),
|
|
593
|
+
snippet: buildSnippet(doc.body, query),
|
|
594
|
+
}));
|
|
595
|
+
return textResult(matches);
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
server.resource("catalog", "nyxui://catalog", async (uri) => {
|
|
600
|
+
const docs = await getDocs();
|
|
601
|
+
const registry = await getRegistry();
|
|
602
|
+
return {
|
|
603
|
+
contents: [
|
|
604
|
+
{
|
|
605
|
+
uri: uri.href,
|
|
606
|
+
mimeType: "application/json",
|
|
607
|
+
text: JSON.stringify(
|
|
608
|
+
{
|
|
609
|
+
name: "Nyx UI MCP Catalog",
|
|
610
|
+
primaryTools: [
|
|
611
|
+
"listRegistryItems",
|
|
612
|
+
"searchRegistryItems",
|
|
613
|
+
"getRegistryItem",
|
|
614
|
+
],
|
|
615
|
+
docs: docs.map((doc) => ({
|
|
616
|
+
slug: doc.slug,
|
|
617
|
+
section: doc.section,
|
|
618
|
+
title: doc.title,
|
|
619
|
+
url: getDocUrl(doc),
|
|
620
|
+
})),
|
|
621
|
+
registryItems: registry.items.map((item) =>
|
|
622
|
+
buildRegistryPreview(item, docs, registry),
|
|
623
|
+
),
|
|
624
|
+
},
|
|
625
|
+
null,
|
|
626
|
+
2,
|
|
627
|
+
),
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
server.resource(
|
|
634
|
+
"registry-item",
|
|
635
|
+
new ResourceTemplate("nyxui://registry/{name}", { list: undefined }),
|
|
636
|
+
async (uri, { name }) => {
|
|
637
|
+
const registry = await getRegistry();
|
|
638
|
+
const item = registry.items.find((e) => e.name === name);
|
|
639
|
+
if (!item) {
|
|
640
|
+
return {
|
|
641
|
+
contents: [
|
|
642
|
+
{
|
|
643
|
+
uri: uri.href,
|
|
644
|
+
mimeType: "application/json",
|
|
645
|
+
text: JSON.stringify(
|
|
646
|
+
{ error: `Registry item "${name}" not found.` },
|
|
647
|
+
null,
|
|
648
|
+
2,
|
|
649
|
+
),
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const publicItem = await getPublicRegistryItem(name);
|
|
655
|
+
return {
|
|
656
|
+
contents: [
|
|
657
|
+
{
|
|
658
|
+
uri: uri.href,
|
|
659
|
+
mimeType: "application/json",
|
|
660
|
+
text: JSON.stringify(publicItem ?? item, null, 2),
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
};
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
return server;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export async function startServer() {
|
|
671
|
+
const server = createServer();
|
|
672
|
+
const transport = new StdioServerTransport();
|
|
673
|
+
await server.connect(transport);
|
|
674
|
+
return server;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const isEntrypoint =
|
|
678
|
+
process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
679
|
+
|
|
680
|
+
if (isEntrypoint) {
|
|
681
|
+
startServer().catch((error) => {
|
|
682
|
+
console.error("[nyxui-mcp] Failed to start server");
|
|
683
|
+
console.error(error);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
});
|
|
686
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nyxui-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official MCP server for Nyx UI - Modern React components built with Tailwind CSS and Framer Motion",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nyxui-mcp": "./mcp-server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node mcp-server.mjs"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "latest",
|
|
14
|
+
"zod": "latest"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"nyxui",
|
|
19
|
+
"nyx-ui",
|
|
20
|
+
"ui-components",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"react",
|
|
23
|
+
"tailwindcss",
|
|
24
|
+
"framer-motion",
|
|
25
|
+
"nextjs"
|
|
26
|
+
],
|
|
27
|
+
"author": "Mihir Jaiswal",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"homepage": "https://nyxui.com",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/MihirJaiswal/nyxui-mcp"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/MihirJaiswal/nyxui-mcp/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# nyxui-mcp
|
|
2
|
+
|
|
3
|
+
Official [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server for [Nyx UI](https://nyxui.com/).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your MCP client config:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"nyxui": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "nyxui-mcp@latest"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Supported Clients
|
|
21
|
+
|
|
22
|
+
- Claude Desktop
|
|
23
|
+
- Cursor
|
|
24
|
+
- Windsurf
|
|
25
|
+
- Cline
|
|
26
|
+
|
|
27
|
+
## Example Usage
|
|
28
|
+
|
|
29
|
+
Once configured, you can ask things like:
|
|
30
|
+
|
|
31
|
+
> "Install the animated code block from Nyx UI"
|
|
32
|
+
|
|
33
|
+
> "Show me all Nyx UI components"
|
|
34
|
+
|
|
35
|
+
> "How do I use the Apple Glass Effect component?"
|
|
36
|
+
|
|
37
|
+
> "Find me a card component from Nyx UI"
|
|
38
|
+
|
|
39
|
+
## Available Tools
|
|
40
|
+
|
|
41
|
+
| Tool | Description |
|
|
42
|
+
| --------------------- | ---------------------------------------------------------------- |
|
|
43
|
+
| `listRegistryItems` | Browse all Nyx UI components with optional filters |
|
|
44
|
+
| `searchRegistryItems` | Search components by keyword |
|
|
45
|
+
| `getRegistryItem` | Get details, install command and source for a specific component |
|
|
46
|
+
| `search_docs` | Search Nyx UI documentation |
|
|
47
|
+
| `get_doc` | Get a specific documentation page |
|
|
48
|
+
| `list_docs` | List all documentation pages |
|
|
49
|
+
| `project_overview` | Get a high level overview of Nyx UI |
|
|
50
|
+
|
|
51
|
+
## Links
|
|
52
|
+
|
|
53
|
+
- [Nyx UI Website](https://nyxui.com)
|
|
54
|
+
- [Components](https://nyxui.com/components)
|
|
55
|
+
- [Documentation](https://nyxui.com/docs)
|
|
56
|
+
- [GitHub](https://github.com/MihirJaiswal/nyxui)
|
|
57
|
+
|
|
58
|
+
## Author
|
|
59
|
+
|
|
60
|
+
Made by [Mihir Jaiswal](https://x.com/mihir_jaiswal_)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|