untitledui-mcp 0.1.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/src/server.ts ADDED
@@ -0,0 +1,374 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+
8
+ import { UntitledUIClient } from "./api/client.js";
9
+ import { MemoryCache, CACHE_TTL } from "./cache/memory-cache.js";
10
+ import { fuzzySearch, type SearchableItem } from "./utils/search.js";
11
+ import { generateDescription } from "./utils/descriptions.js";
12
+ import type { ComponentListItem, MCPComponentResponse } from "./api/types.js";
13
+
14
+ export function createServer(licenseKey: string) {
15
+ const client = new UntitledUIClient(licenseKey);
16
+ const cache = new MemoryCache();
17
+
18
+ const server = new Server(
19
+ {
20
+ name: "untitledui-mcp",
21
+ version: "0.1.0",
22
+ },
23
+ {
24
+ capabilities: {
25
+ tools: {},
26
+ },
27
+ }
28
+ );
29
+
30
+ // Helper: Build searchable index
31
+ async function buildSearchIndex(): Promise<SearchableItem[]> {
32
+ const cacheKey = "search:index";
33
+ const cached = cache.get<SearchableItem[]>(cacheKey);
34
+ if (cached) return cached;
35
+
36
+ const items: SearchableItem[] = [];
37
+ const types = await client.listComponentTypes();
38
+
39
+ for (const type of types) {
40
+ const components = await client.listComponents(type);
41
+ for (const comp of components) {
42
+ if (comp.type === "dir" && comp.count) {
43
+ // Has variants - fetch them
44
+ const variants = await client.listComponents(type, comp.name);
45
+ for (const variant of variants) {
46
+ items.push({
47
+ name: variant.name,
48
+ type,
49
+ fullPath: `${type}/${comp.name}/${variant.name}`,
50
+ });
51
+ }
52
+ } else {
53
+ items.push({
54
+ name: comp.name,
55
+ type,
56
+ fullPath: `${type}/${comp.name}`,
57
+ });
58
+ }
59
+ }
60
+ }
61
+
62
+ cache.set(cacheKey, items, CACHE_TTL.componentList);
63
+ return items;
64
+ }
65
+
66
+ // List available tools
67
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
68
+ tools: [
69
+ {
70
+ name: "list_component_types",
71
+ description: "List all available component categories (application, base, marketing, etc.)",
72
+ inputSchema: { type: "object", properties: {} },
73
+ },
74
+ {
75
+ name: "list_components",
76
+ description: "List components in a category. Use subfolder for variants (e.g., type='application', subfolder='modals')",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ type: { type: "string", description: "Component type (application, base, foundations, marketing, shared-assets)" },
81
+ subfolder: { type: "string", description: "Optional subfolder for variants (e.g., 'modals', 'slideout-menus')" },
82
+ },
83
+ required: ["type"],
84
+ },
85
+ },
86
+ {
87
+ name: "search_components",
88
+ description: "Search for components by name across all categories",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ query: { type: "string", description: "Search query" },
93
+ },
94
+ required: ["query"],
95
+ },
96
+ },
97
+ {
98
+ name: "get_component",
99
+ description: "Get a single component's code. Does NOT include dependencies - use get_component_with_deps for that.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ type: { type: "string", description: "Component type" },
104
+ name: { type: "string", description: "Component name (e.g., 'button' or 'modals/ai-assistant-modal')" },
105
+ },
106
+ required: ["type", "name"],
107
+ },
108
+ },
109
+ {
110
+ name: "get_component_with_deps",
111
+ description: "Get a component with all its base component dependencies included",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ type: { type: "string", description: "Component type" },
116
+ name: { type: "string", description: "Component name" },
117
+ },
118
+ required: ["type", "name"],
119
+ },
120
+ },
121
+ {
122
+ name: "list_examples",
123
+ description: "List available page examples (dashboards, marketing pages, etc.)",
124
+ inputSchema: { type: "object", properties: {} },
125
+ },
126
+ {
127
+ name: "get_example",
128
+ description: "Get a complete page example with all files",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ name: { type: "string", description: "Example name (e.g., 'application', 'marketing')" },
133
+ },
134
+ required: ["name"],
135
+ },
136
+ },
137
+ {
138
+ name: "validate_license",
139
+ description: "Verify that the license key is valid",
140
+ inputSchema: { type: "object", properties: {} },
141
+ },
142
+ {
143
+ name: "clear_cache",
144
+ description: "Clear cached data. Optionally specify a pattern to clear specific entries.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ pattern: { type: "string", description: "Optional pattern to match (e.g., 'component:' clears all component cache)" },
149
+ },
150
+ },
151
+ },
152
+ ],
153
+ }));
154
+
155
+ // Handle tool calls
156
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
157
+ const { name, arguments: args } = request.params;
158
+
159
+ try {
160
+ switch (name) {
161
+ case "list_component_types": {
162
+ const cacheKey = "types";
163
+ let types = cache.get<string[]>(cacheKey);
164
+ if (!types) {
165
+ types = await client.listComponentTypes();
166
+ cache.set(cacheKey, types, CACHE_TTL.componentTypes);
167
+ }
168
+ return { content: [{ type: "text", text: JSON.stringify({ types }, null, 2) }] };
169
+ }
170
+
171
+ case "list_components": {
172
+ const { type, subfolder } = args as { type: string; subfolder?: string };
173
+ const cacheKey = subfolder ? `list:${type}:${subfolder}` : `list:${type}`;
174
+
175
+ let components = cache.get<ComponentListItem[]>(cacheKey);
176
+ if (!components) {
177
+ components = await client.listComponents(type, subfolder);
178
+ cache.set(cacheKey, components, CACHE_TTL.componentList);
179
+ }
180
+
181
+ return { content: [{ type: "text", text: JSON.stringify({ type, subfolder, components }, null, 2) }] };
182
+ }
183
+
184
+ case "search_components": {
185
+ const { query } = args as { query: string };
186
+ const index = await buildSearchIndex();
187
+ const results = fuzzySearch(query, index);
188
+ return { content: [{ type: "text", text: JSON.stringify({ query, results }, null, 2) }] };
189
+ }
190
+
191
+ case "get_component": {
192
+ const { type, name: componentName } = args as { type: string; name: string };
193
+ const cacheKey = `component:${type}:${componentName}`;
194
+
195
+ let component = cache.get<MCPComponentResponse>(cacheKey);
196
+ if (!component) {
197
+ const fetched = await client.fetchComponent(type, componentName);
198
+ if (!fetched) {
199
+ // Not found - suggest alternatives
200
+ const index = await buildSearchIndex();
201
+ const suggestions = fuzzySearch(componentName, index, 5).map(r => r.fullPath);
202
+ return {
203
+ content: [{
204
+ type: "text",
205
+ text: JSON.stringify({
206
+ error: `Component "${componentName}" not found in ${type}`,
207
+ code: "NOT_FOUND",
208
+ suggestions,
209
+ }, null, 2),
210
+ }],
211
+ };
212
+ }
213
+
214
+ component = {
215
+ name: fetched.name,
216
+ type,
217
+ description: generateDescription(fetched.name, type),
218
+ files: fetched.files,
219
+ dependencies: fetched.dependencies || [],
220
+ devDependencies: fetched.devDependencies || [],
221
+ baseComponents: (fetched.components || []).map(c => c.name),
222
+ };
223
+ cache.set(cacheKey, component, CACHE_TTL.componentCode);
224
+ }
225
+
226
+ return { content: [{ type: "text", text: JSON.stringify(component, null, 2) }] };
227
+ }
228
+
229
+ case "get_component_with_deps": {
230
+ const { type, name: componentName } = args as { type: string; name: string };
231
+
232
+ const primary = await client.fetchComponent(type, componentName);
233
+ if (!primary) {
234
+ const index = await buildSearchIndex();
235
+ const suggestions = fuzzySearch(componentName, index, 5).map(r => r.fullPath);
236
+ return {
237
+ content: [{
238
+ type: "text",
239
+ text: JSON.stringify({
240
+ error: `Component "${componentName}" not found`,
241
+ code: "NOT_FOUND",
242
+ suggestions,
243
+ }, null, 2),
244
+ }],
245
+ };
246
+ }
247
+
248
+ // Fetch base components
249
+ const baseComponentNames = (primary.components || []).map(c => c.name);
250
+ const baseComponents = baseComponentNames.length > 0
251
+ ? await client.fetchComponents("base", baseComponentNames)
252
+ : [];
253
+
254
+ // Deduplicate dependencies
255
+ const allDeps = new Set<string>();
256
+ const allDevDeps = new Set<string>();
257
+
258
+ [primary, ...baseComponents].forEach(c => {
259
+ c.dependencies?.forEach(d => allDeps.add(d));
260
+ c.devDependencies?.forEach(d => allDevDeps.add(d));
261
+ });
262
+
263
+ const result = {
264
+ primary: {
265
+ name: primary.name,
266
+ type,
267
+ description: generateDescription(primary.name, type),
268
+ files: primary.files,
269
+ dependencies: primary.dependencies || [],
270
+ devDependencies: primary.devDependencies || [],
271
+ baseComponents: baseComponentNames,
272
+ },
273
+ baseComponents: baseComponents.map(c => ({
274
+ name: c.name,
275
+ type: "base",
276
+ description: generateDescription(c.name, "base"),
277
+ files: c.files,
278
+ dependencies: c.dependencies || [],
279
+ devDependencies: c.devDependencies || [],
280
+ })),
281
+ totalFiles: primary.files.length + baseComponents.reduce((sum, c) => sum + c.files.length, 0),
282
+ allDependencies: Array.from(allDeps),
283
+ allDevDependencies: Array.from(allDevDeps),
284
+ };
285
+
286
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
287
+ }
288
+
289
+ case "list_examples": {
290
+ return {
291
+ content: [{
292
+ type: "text",
293
+ text: JSON.stringify({
294
+ examples: [
295
+ { name: "application", type: "application", description: "Dashboard application example" },
296
+ { name: "marketing", type: "marketing", description: "Marketing landing page example" },
297
+ ],
298
+ }, null, 2),
299
+ }],
300
+ };
301
+ }
302
+
303
+ case "get_example": {
304
+ const { name: exampleName } = args as { name: string };
305
+ const cacheKey = `example:${exampleName}`;
306
+
307
+ let example = cache.get(cacheKey);
308
+ if (!example) {
309
+ example = await client.fetchExample(exampleName);
310
+ if (example) {
311
+ cache.set(cacheKey, example, CACHE_TTL.examples);
312
+ }
313
+ }
314
+
315
+ return { content: [{ type: "text", text: JSON.stringify(example, null, 2) }] };
316
+ }
317
+
318
+ case "validate_license": {
319
+ const valid = await client.validateLicense();
320
+ return {
321
+ content: [{
322
+ type: "text",
323
+ text: JSON.stringify({
324
+ valid,
325
+ message: valid ? "License key is valid" : "Invalid or missing license key",
326
+ }, null, 2),
327
+ }],
328
+ };
329
+ }
330
+
331
+ case "clear_cache": {
332
+ const { pattern } = args as { pattern?: string };
333
+ let cleared: number;
334
+ if (pattern) {
335
+ cleared = cache.clearPattern(pattern);
336
+ } else {
337
+ cleared = cache.size();
338
+ cache.clear();
339
+ }
340
+ return {
341
+ content: [{
342
+ type: "text",
343
+ text: JSON.stringify({ cleared, message: `Cleared ${cleared} cache entries` }, null, 2),
344
+ }],
345
+ };
346
+ }
347
+
348
+ default:
349
+ return {
350
+ content: [{
351
+ type: "text",
352
+ text: JSON.stringify({ error: `Unknown tool: ${name}`, code: "UNKNOWN_TOOL" }, null, 2),
353
+ }],
354
+ };
355
+ }
356
+ } catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ return {
359
+ content: [{
360
+ type: "text",
361
+ text: JSON.stringify({ error: message, code: "API_ERROR" }, null, 2),
362
+ }],
363
+ };
364
+ }
365
+ });
366
+
367
+ return server;
368
+ }
369
+
370
+ export async function runServer(licenseKey: string) {
371
+ const server = createServer(licenseKey);
372
+ const transport = new StdioServerTransport();
373
+ await server.connect(transport);
374
+ }
@@ -0,0 +1,65 @@
1
+ // Generated descriptions based on component name and type
2
+ // Since UntitledUI API doesn't provide descriptions, we generate them
3
+
4
+ const TYPE_DESCRIPTIONS: Record<string, string> = {
5
+ application: "Application UI component for dashboards and web apps",
6
+ base: "Core UI primitive component",
7
+ foundations: "Foundational element (icon, logo, visual)",
8
+ marketing: "Marketing section component for landing pages",
9
+ "shared-assets": "Shared visual asset (illustration, pattern, mockup)",
10
+ icons: "Icon component",
11
+ };
12
+
13
+ const NAME_PATTERNS: [RegExp, string][] = [
14
+ [/modal/i, "Modal dialog component"],
15
+ [/button/i, "Interactive button component"],
16
+ [/input/i, "Form input component"],
17
+ [/select/i, "Selection/dropdown component"],
18
+ [/table/i, "Data table component"],
19
+ [/calendar/i, "Calendar/date component"],
20
+ [/date-picker/i, "Date selection component"],
21
+ [/sidebar/i, "Sidebar navigation component"],
22
+ [/header/i, "Header/navigation component"],
23
+ [/footer/i, "Footer section component"],
24
+ [/card/i, "Card container component"],
25
+ [/avatar/i, "User avatar component"],
26
+ [/badge/i, "Badge/label component"],
27
+ [/alert/i, "Alert/notification component"],
28
+ [/toast/i, "Toast notification component"],
29
+ [/dropdown/i, "Dropdown menu component"],
30
+ [/tabs/i, "Tabbed interface component"],
31
+ [/pagination/i, "Pagination component"],
32
+ [/carousel/i, "Carousel/slider component"],
33
+ [/chart/i, "Chart/visualization component"],
34
+ [/metric/i, "Metrics/statistics component"],
35
+ [/form/i, "Form component"],
36
+ [/pricing/i, "Pricing section component"],
37
+ [/testimonial/i, "Testimonial section component"],
38
+ [/feature/i, "Features section component"],
39
+ [/cta/i, "Call-to-action section component"],
40
+ [/hero/i, "Hero section component"],
41
+ [/faq/i, "FAQ section component"],
42
+ [/blog/i, "Blog section component"],
43
+ [/team/i, "Team section component"],
44
+ [/contact/i, "Contact section component"],
45
+ [/login/i, "Login/authentication component"],
46
+ [/signup/i, "Signup/registration component"],
47
+ ];
48
+
49
+ export function generateDescription(name: string, type: string): string {
50
+ // Check name patterns first
51
+ for (const [pattern, description] of NAME_PATTERNS) {
52
+ if (pattern.test(name)) {
53
+ return description;
54
+ }
55
+ }
56
+
57
+ // Fall back to type description
58
+ const typeDesc = TYPE_DESCRIPTIONS[type];
59
+ if (typeDesc) {
60
+ return `${typeDesc}: ${name.replace(/-/g, " ")}`;
61
+ }
62
+
63
+ // Generic fallback
64
+ return `UI component: ${name.replace(/-/g, " ")}`;
65
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { resolveLicenseKey } from "./license.js";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+
6
+ vi.mock("fs");
7
+ vi.mock("os");
8
+
9
+ describe("resolveLicenseKey", () => {
10
+ beforeEach(() => {
11
+ vi.resetAllMocks();
12
+ delete process.env.UNTITLEDUI_LICENSE_KEY;
13
+ });
14
+
15
+ it("should prefer CLI argument over env var", () => {
16
+ process.env.UNTITLEDUI_LICENSE_KEY = "env-key";
17
+ const result = resolveLicenseKey("cli-key");
18
+ expect(result).toBe("cli-key");
19
+ });
20
+
21
+ it("should use env var if no CLI argument", () => {
22
+ process.env.UNTITLEDUI_LICENSE_KEY = "env-key";
23
+ const result = resolveLicenseKey();
24
+ expect(result).toBe("env-key");
25
+ });
26
+
27
+ it("should read from config file if no env var", () => {
28
+ vi.mocked(os.homedir).mockReturnValue("/home/user");
29
+ vi.mocked(fs.existsSync).mockReturnValue(true);
30
+ vi.mocked(fs.readFileSync).mockReturnValue(
31
+ JSON.stringify({ license: "file-key" })
32
+ );
33
+
34
+ const result = resolveLicenseKey();
35
+ expect(result).toBe("file-key");
36
+ });
37
+
38
+ it("should return undefined if no key found", () => {
39
+ vi.mocked(os.homedir).mockReturnValue("/home/user");
40
+ vi.mocked(fs.existsSync).mockReturnValue(false);
41
+
42
+ const result = resolveLicenseKey();
43
+ expect(result).toBeUndefined();
44
+ });
45
+ });
@@ -0,0 +1,35 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ function getConfigPath(): string {
6
+ return path.join(os.homedir(), ".untitledui", "config.json");
7
+ }
8
+
9
+ export function resolveLicenseKey(cliArg?: string): string | undefined {
10
+ // Priority 1: CLI argument
11
+ if (cliArg) {
12
+ return cliArg;
13
+ }
14
+
15
+ // Priority 2: Environment variable
16
+ const envKey = process.env.UNTITLEDUI_LICENSE_KEY;
17
+ if (envKey) {
18
+ return envKey;
19
+ }
20
+
21
+ // Priority 3: Config file (~/.untitledui/config.json)
22
+ try {
23
+ const configPath = getConfigPath();
24
+ if (fs.existsSync(configPath)) {
25
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
26
+ if (config.license) {
27
+ return config.license;
28
+ }
29
+ }
30
+ } catch {
31
+ // Ignore file read errors
32
+ }
33
+
34
+ return undefined;
35
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { fuzzySearch, type SearchableItem } from "./search.js";
3
+
4
+ describe("fuzzySearch", () => {
5
+ const items: SearchableItem[] = [
6
+ { name: "button", type: "base", fullPath: "base/button" },
7
+ { name: "date-picker", type: "application", fullPath: "application/date-picker" },
8
+ { name: "date-range-picker", type: "application", fullPath: "application/date-range-picker" },
9
+ { name: "ai-assistant-modal", type: "application", fullPath: "application/modals/ai-assistant-modal" },
10
+ ];
11
+
12
+ it("should find exact matches", () => {
13
+ const results = fuzzySearch("button", items);
14
+ expect(results[0].name).toBe("button");
15
+ expect(results[0].matchType).toBe("exact");
16
+ });
17
+
18
+ it("should find partial matches", () => {
19
+ const results = fuzzySearch("date", items);
20
+ expect(results.length).toBe(2);
21
+ expect(results.every(r => r.name.includes("date"))).toBe(true);
22
+ });
23
+
24
+ it("should rank exact matches higher", () => {
25
+ const results = fuzzySearch("date-picker", items);
26
+ expect(results[0].name).toBe("date-picker");
27
+ expect(results[0].matchType).toBe("exact");
28
+ });
29
+
30
+ it("should return empty array for no matches", () => {
31
+ const results = fuzzySearch("nonexistent", items);
32
+ expect(results).toEqual([]);
33
+ });
34
+
35
+ it("should limit results", () => {
36
+ const results = fuzzySearch("a", items, 2);
37
+ expect(results.length).toBeLessThanOrEqual(2);
38
+ });
39
+ });
@@ -0,0 +1,71 @@
1
+ export interface SearchableItem {
2
+ name: string;
3
+ type: string;
4
+ fullPath: string;
5
+ }
6
+
7
+ export interface SearchResult extends SearchableItem {
8
+ matchType: "exact" | "partial";
9
+ score: number;
10
+ }
11
+
12
+ export function fuzzySearch(
13
+ query: string,
14
+ items: SearchableItem[],
15
+ limit = 20
16
+ ): SearchResult[] {
17
+ const queryLower = query.toLowerCase();
18
+
19
+ const results: SearchResult[] = [];
20
+
21
+ for (const item of items) {
22
+ const nameLower = item.name.toLowerCase();
23
+ const fullPathLower = item.fullPath.toLowerCase();
24
+
25
+ let score = 0;
26
+ let matchType: "exact" | "partial" = "partial";
27
+
28
+ // Exact match on name
29
+ if (nameLower === queryLower) {
30
+ score = 1.0;
31
+ matchType = "exact";
32
+ }
33
+ // Name starts with query
34
+ else if (nameLower.startsWith(queryLower)) {
35
+ score = 0.9;
36
+ }
37
+ // Name contains query
38
+ else if (nameLower.includes(queryLower)) {
39
+ score = 0.7;
40
+ }
41
+ // Full path contains query
42
+ else if (fullPathLower.includes(queryLower)) {
43
+ score = 0.5;
44
+ }
45
+ // Fuzzy: all query chars appear in order
46
+ else {
47
+ let queryIndex = 0;
48
+ for (const char of nameLower) {
49
+ if (char === queryLower[queryIndex]) {
50
+ queryIndex++;
51
+ }
52
+ if (queryIndex === queryLower.length) {
53
+ score = 0.3;
54
+ break;
55
+ }
56
+ }
57
+ }
58
+
59
+ if (score > 0) {
60
+ results.push({
61
+ ...item,
62
+ matchType,
63
+ score,
64
+ });
65
+ }
66
+ }
67
+
68
+ return results
69
+ .sort((a, b) => b.score - a.score)
70
+ .slice(0, limit);
71
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }