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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "untitledui-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for UntitledUI Pro components - browse, search, and retrieve UI components via Claude Code",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "untitledui-mcp": "dist/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/sbilde/untitledui-mcp"
13
+ },
14
+ "homepage": "https://github.com/sbilde/untitledui-mcp#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/sbilde/untitledui-mcp/issues"
17
+ },
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts",
20
+ "dev": "tsup src/index.ts --format esm --watch",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "start": "node dist/index.js"
24
+ },
25
+ "keywords": ["mcp", "untitledui", "ui-components", "claude"],
26
+ "author": "Steffen Bilde",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.0.0",
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.0.0",
35
+ "vitest": "^2.0.0"
36
+ }
37
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { UntitledUIClient } from "./client.js";
3
+
4
+ describe("UntitledUIClient", () => {
5
+ describe("validateLicense", () => {
6
+ it("should return true for valid license", async () => {
7
+ const client = new UntitledUIClient("valid-key");
8
+
9
+ global.fetch = vi.fn().mockResolvedValue({
10
+ status: 200,
11
+ ok: true,
12
+ });
13
+
14
+ const result = await client.validateLicense();
15
+ expect(result).toBe(true);
16
+ });
17
+
18
+ it("should return false for invalid license", async () => {
19
+ const client = new UntitledUIClient("invalid-key");
20
+
21
+ global.fetch = vi.fn().mockResolvedValue({
22
+ status: 401,
23
+ ok: false,
24
+ });
25
+
26
+ const result = await client.validateLicense();
27
+ expect(result).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("listComponentTypes", () => {
32
+ it("should return array of types", async () => {
33
+ const client = new UntitledUIClient("valid-key");
34
+
35
+ global.fetch = vi.fn().mockResolvedValue({
36
+ ok: true,
37
+ json: () => Promise.resolve({
38
+ types: ["application", "base", "foundations"]
39
+ }),
40
+ });
41
+
42
+ const result = await client.listComponentTypes();
43
+ expect(result).toEqual(["application", "base", "foundations"]);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,117 @@
1
+ import { ENDPOINTS } from "./endpoints.js";
2
+ import type {
3
+ ComponentListItem,
4
+ ComponentListResponse,
5
+ ComponentsResponse,
6
+ ExampleResponse,
7
+ FetchedComponent,
8
+ } from "./types.js";
9
+
10
+ export class UntitledUIClient {
11
+ constructor(private licenseKey: string) {}
12
+
13
+ async validateLicense(): Promise<boolean> {
14
+ try {
15
+ const response = await fetch(ENDPOINTS.validateKey(this.licenseKey));
16
+ return response.status === 200;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ async listComponentTypes(): Promise<string[]> {
23
+ const response = await fetch(ENDPOINTS.listTypes(this.licenseKey));
24
+ if (!response.ok) {
25
+ throw new Error(`API error: ${response.status}`);
26
+ }
27
+ const data = await response.json();
28
+ return data.types;
29
+ }
30
+
31
+ async listComponents(type: string, subfolder?: string): Promise<ComponentListItem[]> {
32
+ let url: string;
33
+ if (subfolder) {
34
+ url = ENDPOINTS.listSubfolder(this.licenseKey, type, [subfolder]);
35
+ } else {
36
+ url = ENDPOINTS.listComponents(this.licenseKey, type);
37
+ }
38
+
39
+ const response = await fetch(url);
40
+ if (!response.ok) {
41
+ throw new Error(`API error: ${response.status}`);
42
+ }
43
+
44
+ const data: ComponentListResponse = await response.json();
45
+
46
+ // Handle subfolder response format
47
+ if (subfolder && Array.isArray(data.components)) {
48
+ const subfolderData = data.components[0];
49
+ if (subfolderData && typeof subfolderData === "object" && subfolder in subfolderData) {
50
+ return (subfolderData as Record<string, ComponentListItem[]>)[subfolder];
51
+ }
52
+ }
53
+
54
+ return data.components as ComponentListItem[];
55
+ }
56
+
57
+ async fetchComponent(type: string, name: string): Promise<FetchedComponent | null> {
58
+ const response = await fetch(ENDPOINTS.fetchComponents, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({
62
+ type,
63
+ components: [name],
64
+ key: this.licenseKey,
65
+ }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(`API error: ${response.status}`);
70
+ }
71
+
72
+ const data: ComponentsResponse = await response.json();
73
+
74
+ if (data.pro && data.pro.length > 0) {
75
+ throw new Error(`PRO access required for: ${data.pro.join(", ")}`);
76
+ }
77
+
78
+ return data.components[0] || null;
79
+ }
80
+
81
+ async fetchComponents(type: string, names: string[]): Promise<FetchedComponent[]> {
82
+ const response = await fetch(ENDPOINTS.fetchComponents, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({
86
+ type,
87
+ components: names,
88
+ key: this.licenseKey,
89
+ }),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ throw new Error(`API error: ${response.status}`);
94
+ }
95
+
96
+ const data: ComponentsResponse = await response.json();
97
+
98
+ if (data.pro && data.pro.length > 0) {
99
+ throw new Error(`PRO access required for: ${data.pro.join(", ")}`);
100
+ }
101
+
102
+ return data.components;
103
+ }
104
+
105
+ async fetchExample(name: string): Promise<ExampleResponse> {
106
+ const response = await fetch(ENDPOINTS.fetchExample, {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({
110
+ example: name,
111
+ key: this.licenseKey,
112
+ }),
113
+ });
114
+
115
+ return response.json();
116
+ }
117
+ }
@@ -0,0 +1,12 @@
1
+ export const API_BASE = "https://www.untitledui.com/react/api";
2
+
3
+ export const ENDPOINTS = {
4
+ validateKey: (key: string) => `${API_BASE}/validate-key?key=${key}`,
5
+ listTypes: (key: string) => `${API_BASE}/components/list?key=${key}`,
6
+ listComponents: (key: string, type: string) =>
7
+ `${API_BASE}/components/list?key=${key}&type=${type}`,
8
+ listSubfolder: (key: string, type: string, subfolders: string[]) =>
9
+ `${API_BASE}/components/list?key=${key}&type=${type}&subfolders=${subfolders.join(",")}`,
10
+ fetchComponents: `${API_BASE}/components`,
11
+ fetchExample: `${API_BASE}/components/example`,
12
+ } as const;
@@ -0,0 +1,84 @@
1
+ // UntitledUI API Response Types
2
+
3
+ export interface ComponentType {
4
+ types: string[];
5
+ }
6
+
7
+ export interface ComponentListItem {
8
+ name: string;
9
+ type: "file" | "dir";
10
+ count?: number;
11
+ }
12
+
13
+ export interface ComponentListResponse {
14
+ components: ComponentListItem[] | ComponentListWithSubfolder[];
15
+ }
16
+
17
+ export interface ComponentListWithSubfolder {
18
+ [subfolder: string]: ComponentListItem[];
19
+ }
20
+
21
+ export interface ComponentFile {
22
+ path: string;
23
+ code: string;
24
+ }
25
+
26
+ export interface FetchedComponent {
27
+ name: string;
28
+ files: ComponentFile[];
29
+ dependencies?: string[];
30
+ devDependencies?: string[];
31
+ components?: BaseComponentRef[];
32
+ }
33
+
34
+ export interface BaseComponentRef {
35
+ name: string;
36
+ path: string;
37
+ }
38
+
39
+ export interface ComponentsResponse {
40
+ components: FetchedComponent[];
41
+ pro?: string[];
42
+ }
43
+
44
+ export interface ExampleResponse {
45
+ type: "json-file" | "directory" | "json-files" | "error";
46
+ content?: ExampleContent;
47
+ results?: string[];
48
+ status?: number;
49
+ message?: string;
50
+ }
51
+
52
+ export interface ExampleContent {
53
+ name: string;
54
+ files: ComponentFile[];
55
+ dependencies?: string[];
56
+ devDependencies?: string[];
57
+ components?: BaseComponentRef[];
58
+ }
59
+
60
+ // MCP Tool Response Types
61
+
62
+ export interface MCPComponentResponse {
63
+ name: string;
64
+ type: string;
65
+ description: string;
66
+ files: ComponentFile[];
67
+ dependencies: string[];
68
+ devDependencies: string[];
69
+ baseComponents: string[];
70
+ }
71
+
72
+ export interface MCPSearchResult {
73
+ name: string;
74
+ type: string;
75
+ fullPath: string;
76
+ matchType: "exact" | "partial";
77
+ score: number;
78
+ }
79
+
80
+ export interface MCPErrorResponse {
81
+ error: string;
82
+ code: "INVALID_LICENSE" | "NOT_FOUND" | "API_ERROR" | "NETWORK_ERROR";
83
+ suggestions?: string[];
84
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { MemoryCache } from "./memory-cache.js";
3
+
4
+ describe("MemoryCache", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it("should store and retrieve values", () => {
14
+ const cache = new MemoryCache();
15
+ cache.set("key1", "value1", 60);
16
+ expect(cache.get("key1")).toBe("value1");
17
+ });
18
+
19
+ it("should return undefined for missing keys", () => {
20
+ const cache = new MemoryCache();
21
+ expect(cache.get("missing")).toBeUndefined();
22
+ });
23
+
24
+ it("should expire entries after TTL", () => {
25
+ const cache = new MemoryCache();
26
+ cache.set("key1", "value1", 60);
27
+
28
+ vi.advanceTimersByTime(61 * 1000);
29
+
30
+ expect(cache.get("key1")).toBeUndefined();
31
+ });
32
+
33
+ it("should clear all entries", () => {
34
+ const cache = new MemoryCache();
35
+ cache.set("key1", "value1", 60);
36
+ cache.set("key2", "value2", 60);
37
+
38
+ cache.clear();
39
+
40
+ expect(cache.get("key1")).toBeUndefined();
41
+ expect(cache.get("key2")).toBeUndefined();
42
+ });
43
+
44
+ it("should clear entries matching pattern", () => {
45
+ const cache = new MemoryCache();
46
+ cache.set("component:button", "data1", 60);
47
+ cache.set("component:input", "data2", 60);
48
+ cache.set("search:button", "data3", 60);
49
+
50
+ const cleared = cache.clearPattern("component:");
51
+
52
+ expect(cleared).toBe(2);
53
+ expect(cache.get("component:button")).toBeUndefined();
54
+ expect(cache.get("search:button")).toBe("data3");
55
+ });
56
+ });
@@ -0,0 +1,64 @@
1
+ interface CacheEntry<T> {
2
+ data: T;
3
+ expiresAt: number;
4
+ }
5
+
6
+ export class MemoryCache {
7
+ private cache = new Map<string, CacheEntry<unknown>>();
8
+
9
+ set<T>(key: string, data: T, ttlSeconds: number): void {
10
+ this.cache.set(key, {
11
+ data,
12
+ expiresAt: Date.now() + ttlSeconds * 1000,
13
+ });
14
+ }
15
+
16
+ get<T>(key: string): T | undefined {
17
+ const entry = this.cache.get(key);
18
+ if (!entry) return undefined;
19
+
20
+ if (Date.now() > entry.expiresAt) {
21
+ this.cache.delete(key);
22
+ return undefined;
23
+ }
24
+
25
+ return entry.data as T;
26
+ }
27
+
28
+ has(key: string): boolean {
29
+ return this.get(key) !== undefined;
30
+ }
31
+
32
+ clear(): void {
33
+ this.cache.clear();
34
+ }
35
+
36
+ clearPattern(pattern: string): number {
37
+ let cleared = 0;
38
+ for (const key of this.cache.keys()) {
39
+ if (key.startsWith(pattern)) {
40
+ this.cache.delete(key);
41
+ cleared++;
42
+ }
43
+ }
44
+ return cleared;
45
+ }
46
+
47
+ size(): number {
48
+ for (const [key, entry] of this.cache.entries()) {
49
+ if (Date.now() > entry.expiresAt) {
50
+ this.cache.delete(key);
51
+ }
52
+ }
53
+ return this.cache.size;
54
+ }
55
+ }
56
+
57
+ export const CACHE_TTL = {
58
+ componentTypes: 3600,
59
+ componentList: 3600,
60
+ componentCode: 86400,
61
+ searchResults: 1800,
62
+ examples: 86400,
63
+ licenseValidation: 300,
64
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolveLicenseKey } from "./utils/license.js";
4
+ import { runServer } from "./server.js";
5
+ import { UntitledUIClient } from "./api/client.js";
6
+
7
+ async function main() {
8
+ const args = process.argv.slice(2);
9
+
10
+ // Parse CLI arguments
11
+ let cliLicenseKey: string | undefined;
12
+ let testMode = false;
13
+
14
+ for (let i = 0; i < args.length; i++) {
15
+ if (args[i] === "--license-key" && args[i + 1]) {
16
+ cliLicenseKey = args[i + 1];
17
+ i++;
18
+ } else if (args[i] === "--test") {
19
+ testMode = true;
20
+ } else if (args[i] === "--help" || args[i] === "-h") {
21
+ console.log(`
22
+ mcp-untitledui - MCP server for UntitledUI Pro components
23
+
24
+ Usage:
25
+ mcp-untitledui [options]
26
+
27
+ Options:
28
+ --license-key <key> Specify license key (overrides env/config)
29
+ --test Test connection and exit
30
+ --help, -h Show this help message
31
+
32
+ Environment:
33
+ UNTITLEDUI_LICENSE_KEY License key (if not using --license-key)
34
+
35
+ Config:
36
+ ~/.untitledui/config.json Auto-detected from UntitledUI CLI login
37
+ `);
38
+ process.exit(0);
39
+ }
40
+ }
41
+
42
+ // Resolve license key (optional - some features work without it)
43
+ const licenseKey = resolveLicenseKey(cliLicenseKey) || "";
44
+
45
+ // Test mode
46
+ if (testMode) {
47
+ console.log("Testing connection...");
48
+
49
+ if (!licenseKey) {
50
+ console.error("✗ No license key configured");
51
+ console.error("");
52
+ console.error("Get your license key:");
53
+ console.error(" npx untitledui login");
54
+ console.error("");
55
+ console.error("Then configure it via:");
56
+ console.error(" • Environment: UNTITLEDUI_LICENSE_KEY=<key>");
57
+ console.error(" • MCP config env field");
58
+ console.error(" • Auto-detected from ~/.untitledui/config.json");
59
+ process.exit(1);
60
+ }
61
+
62
+ const client = new UntitledUIClient(licenseKey);
63
+
64
+ const valid = await client.validateLicense();
65
+ if (!valid) {
66
+ console.error("✗ License key is invalid");
67
+ process.exit(1);
68
+ }
69
+ console.log("✓ License key is valid");
70
+
71
+ try {
72
+ const types = await client.listComponentTypes();
73
+ console.log(`✓ API connection successful`);
74
+ console.log(`✓ ${types.length} component types available`);
75
+ console.log("✓ Ready to serve");
76
+ process.exit(0);
77
+ } catch (error) {
78
+ console.error("✗ API connection failed:", error);
79
+ process.exit(1);
80
+ }
81
+ }
82
+
83
+ // Warn if no license key
84
+ if (!licenseKey) {
85
+ console.error("Warning: No license key configured. API calls will fail.");
86
+ console.error("Run 'npx untitledui login' to authenticate.");
87
+ }
88
+
89
+ // Run server
90
+ await runServer(licenseKey);
91
+ }
92
+
93
+ main().catch((error) => {
94
+ console.error("Fatal error:", error);
95
+ process.exit(1);
96
+ });