@atomixstudio/mcp 0.1.1 → 1.0.1

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,436 +0,0 @@
1
- /**
2
- * Tenant Store
3
- *
4
- * Manages per-tenant token configurations with support for:
5
- * - Local file storage (default)
6
- * - Cloud API storage (future)
7
- * - In-memory caching
8
- *
9
- * Each tenant gets isolated storage for:
10
- * - Component defaults
11
- * - Token overrides
12
- * - Locked token configurations
13
- */
14
-
15
- import fs from "fs";
16
- import path from "path";
17
- import { fileURLToPath } from "url";
18
-
19
- const __filename = fileURLToPath(import.meta.url);
20
- const __dirname = path.dirname(__filename);
21
-
22
- // ============================================
23
- // TYPES
24
- // ============================================
25
-
26
- export interface TenantConfig {
27
- tenantId: string;
28
- createdAt: string;
29
- updatedAt: string;
30
- componentDefaults: Record<string, unknown>;
31
- lockedTokens: string[];
32
- tokenOverrides: Record<string, string>;
33
- }
34
-
35
- export interface TenantStoreOptions {
36
- /** Base directory for tenant data (default: ./data/tenants) */
37
- dataDir?: string;
38
- /** Cloud API URL for remote storage (optional) */
39
- cloudApiUrl?: string;
40
- /** Enable in-memory caching */
41
- enableCache?: boolean;
42
- /** Cache TTL in milliseconds */
43
- cacheTtlMs?: number;
44
- }
45
-
46
- interface CacheEntry {
47
- data: TenantConfig;
48
- timestamp: number;
49
- }
50
-
51
- // ============================================
52
- // TENANT STORE
53
- // ============================================
54
-
55
- export class TenantStore {
56
- private dataDir: string;
57
- private cloudApiUrl: string | null;
58
- private cache: Map<string, CacheEntry>;
59
- private enableCache: boolean;
60
- private cacheTtlMs: number;
61
-
62
- constructor(options: TenantStoreOptions = {}) {
63
- this.dataDir = options.dataDir || path.join(__dirname, "../data/tenants");
64
- this.cloudApiUrl = options.cloudApiUrl || null;
65
- this.cache = new Map();
66
- this.enableCache = options.enableCache ?? true;
67
- this.cacheTtlMs = options.cacheTtlMs ?? 60000; // 1 minute default
68
-
69
- // Ensure data directory exists
70
- this.ensureDataDir();
71
- }
72
-
73
- private ensureDataDir(): void {
74
- if (!fs.existsSync(this.dataDir)) {
75
- fs.mkdirSync(this.dataDir, { recursive: true });
76
- }
77
- }
78
-
79
- private getTenantFilePath(tenantId: string): string {
80
- // Sanitize tenant ID for filesystem safety
81
- const safeId = tenantId.replace(/[^a-zA-Z0-9-_]/g, "_");
82
- return path.join(this.dataDir, `${safeId}.json`);
83
- }
84
-
85
- private isCacheValid(entry: CacheEntry): boolean {
86
- return Date.now() - entry.timestamp < this.cacheTtlMs;
87
- }
88
-
89
- // ============================================
90
- // PUBLIC API
91
- // ============================================
92
-
93
- /**
94
- * Get tenant configuration
95
- */
96
- async getTenant(tenantId: string): Promise<TenantConfig | null> {
97
- // Check cache first
98
- if (this.enableCache) {
99
- const cached = this.cache.get(tenantId);
100
- if (cached && this.isCacheValid(cached)) {
101
- return cached.data;
102
- }
103
- }
104
-
105
- // Try cloud API first if configured
106
- if (this.cloudApiUrl) {
107
- try {
108
- const cloudData = await this.fetchFromCloud(tenantId);
109
- if (cloudData) {
110
- this.updateCache(tenantId, cloudData);
111
- return cloudData;
112
- }
113
- } catch (error) {
114
- console.warn(`Cloud fetch failed for tenant ${tenantId}, falling back to local`);
115
- }
116
- }
117
-
118
- // Fall back to local storage
119
- const localData = this.loadFromFile(tenantId);
120
- if (localData) {
121
- this.updateCache(tenantId, localData);
122
- }
123
- return localData;
124
- }
125
-
126
- /**
127
- * Save tenant configuration
128
- */
129
- async saveTenant(tenantId: string, config: Partial<TenantConfig>): Promise<TenantConfig> {
130
- const existing = await this.getTenant(tenantId);
131
- const now = new Date().toISOString();
132
-
133
- const updated: TenantConfig = {
134
- tenantId,
135
- createdAt: existing?.createdAt || now,
136
- updatedAt: now,
137
- componentDefaults: config.componentDefaults || existing?.componentDefaults || {},
138
- lockedTokens: config.lockedTokens || existing?.lockedTokens || [],
139
- tokenOverrides: config.tokenOverrides || existing?.tokenOverrides || {},
140
- };
141
-
142
- // Save to cloud if configured
143
- if (this.cloudApiUrl) {
144
- try {
145
- await this.saveToCloud(tenantId, updated);
146
- } catch (error) {
147
- console.warn(`Cloud save failed for tenant ${tenantId}, saving locally`);
148
- }
149
- }
150
-
151
- // Always save locally as backup
152
- this.saveToFile(tenantId, updated);
153
- this.updateCache(tenantId, updated);
154
-
155
- return updated;
156
- }
157
-
158
- /**
159
- * Update component defaults for a tenant
160
- */
161
- async updateComponentDefaults(
162
- tenantId: string,
163
- componentKey: string,
164
- defaults: unknown
165
- ): Promise<TenantConfig> {
166
- const existing = await this.getTenant(tenantId);
167
- const componentDefaults = existing?.componentDefaults || {};
168
-
169
- return this.saveTenant(tenantId, {
170
- ...existing,
171
- componentDefaults: {
172
- ...componentDefaults,
173
- [componentKey]: defaults,
174
- },
175
- });
176
- }
177
-
178
- /**
179
- * Check if a token is locked for a tenant
180
- */
181
- async isTokenLocked(tenantId: string, tokenPath: string): Promise<boolean> {
182
- const tenant = await this.getTenant(tenantId);
183
- if (!tenant) return false;
184
-
185
- return tenant.lockedTokens.some(locked => {
186
- // Support wildcards: "button.*.bgColor" matches "button.primary.bgColor"
187
- if (locked.includes("*")) {
188
- const regex = new RegExp("^" + locked.replace(/\*/g, "[^.]+") + "$");
189
- return regex.test(tokenPath);
190
- }
191
- return locked === tokenPath;
192
- });
193
- }
194
-
195
- /**
196
- * Lock tokens for a tenant
197
- */
198
- async lockTokens(tenantId: string, tokenPaths: string[]): Promise<TenantConfig> {
199
- const existing = await this.getTenant(tenantId);
200
- const currentLocked = existing?.lockedTokens || [];
201
- const newLocked = [...new Set([...currentLocked, ...tokenPaths])];
202
-
203
- return this.saveTenant(tenantId, {
204
- ...existing,
205
- lockedTokens: newLocked,
206
- });
207
- }
208
-
209
- /**
210
- * Unlock tokens for a tenant
211
- */
212
- async unlockTokens(tenantId: string, tokenPaths: string[]): Promise<TenantConfig> {
213
- const existing = await this.getTenant(tenantId);
214
- const currentLocked = existing?.lockedTokens || [];
215
- const newLocked = currentLocked.filter(t => !tokenPaths.includes(t));
216
-
217
- return this.saveTenant(tenantId, {
218
- ...existing,
219
- lockedTokens: newLocked,
220
- });
221
- }
222
-
223
- /**
224
- * List all tenants
225
- */
226
- listTenants(): string[] {
227
- if (!fs.existsSync(this.dataDir)) {
228
- return [];
229
- }
230
-
231
- return fs.readdirSync(this.dataDir)
232
- .filter(f => f.endsWith(".json"))
233
- .map(f => f.replace(".json", ""));
234
- }
235
-
236
- /**
237
- * Delete a tenant
238
- */
239
- async deleteTenant(tenantId: string): Promise<boolean> {
240
- const filePath = this.getTenantFilePath(tenantId);
241
-
242
- // Remove from cache
243
- this.cache.delete(tenantId);
244
-
245
- // Delete from cloud if configured
246
- if (this.cloudApiUrl) {
247
- try {
248
- await this.deleteFromCloud(tenantId);
249
- } catch (error) {
250
- console.warn(`Cloud delete failed for tenant ${tenantId}`);
251
- }
252
- }
253
-
254
- // Delete local file
255
- if (fs.existsSync(filePath)) {
256
- fs.unlinkSync(filePath);
257
- return true;
258
- }
259
-
260
- return false;
261
- }
262
-
263
- /**
264
- * Clear cache
265
- */
266
- clearCache(): void {
267
- this.cache.clear();
268
- }
269
-
270
- // ============================================
271
- // PRIVATE: File Storage
272
- // ============================================
273
-
274
- private loadFromFile(tenantId: string): TenantConfig | null {
275
- const filePath = this.getTenantFilePath(tenantId);
276
-
277
- if (!fs.existsSync(filePath)) {
278
- return null;
279
- }
280
-
281
- try {
282
- const content = fs.readFileSync(filePath, "utf-8");
283
- return JSON.parse(content) as TenantConfig;
284
- } catch (error) {
285
- console.error(`Failed to load tenant ${tenantId}:`, error);
286
- return null;
287
- }
288
- }
289
-
290
- private saveToFile(tenantId: string, config: TenantConfig): void {
291
- const filePath = this.getTenantFilePath(tenantId);
292
-
293
- try {
294
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf-8");
295
- } catch (error) {
296
- console.error(`Failed to save tenant ${tenantId}:`, error);
297
- throw error;
298
- }
299
- }
300
-
301
- // ============================================
302
- // PRIVATE: Cloud Storage (Future)
303
- // ============================================
304
-
305
- private async fetchFromCloud(tenantId: string): Promise<TenantConfig | null> {
306
- if (!this.cloudApiUrl) return null;
307
-
308
- const response = await fetch(`${this.cloudApiUrl}/tenants/${tenantId}`);
309
-
310
- if (!response.ok) {
311
- if (response.status === 404) return null;
312
- throw new Error(`Cloud API error: ${response.status}`);
313
- }
314
-
315
- return response.json() as Promise<TenantConfig>;
316
- }
317
-
318
- private async saveToCloud(tenantId: string, config: TenantConfig): Promise<void> {
319
- if (!this.cloudApiUrl) return;
320
-
321
- const response = await fetch(`${this.cloudApiUrl}/tenants/${tenantId}`, {
322
- method: "PUT",
323
- headers: { "Content-Type": "application/json" },
324
- body: JSON.stringify(config),
325
- });
326
-
327
- if (!response.ok) {
328
- throw new Error(`Cloud API error: ${response.status}`);
329
- }
330
- }
331
-
332
- private async deleteFromCloud(tenantId: string): Promise<void> {
333
- if (!this.cloudApiUrl) return;
334
-
335
- const response = await fetch(`${this.cloudApiUrl}/tenants/${tenantId}`, {
336
- method: "DELETE",
337
- });
338
-
339
- if (!response.ok && response.status !== 404) {
340
- throw new Error(`Cloud API error: ${response.status}`);
341
- }
342
- }
343
-
344
- // ============================================
345
- // PRIVATE: Cache
346
- // ============================================
347
-
348
- private updateCache(tenantId: string, data: TenantConfig): void {
349
- if (this.enableCache) {
350
- this.cache.set(tenantId, {
351
- data,
352
- timestamp: Date.now(),
353
- });
354
- }
355
- }
356
- }
357
-
358
- // ============================================
359
- // SINGLETON INSTANCE
360
- // ============================================
361
-
362
- let defaultStore: TenantStore | null = null;
363
-
364
- export function getTenantStore(options?: TenantStoreOptions): TenantStore {
365
- if (!defaultStore) {
366
- defaultStore = new TenantStore({
367
- cloudApiUrl: process.env.ATOMIX_CLOUD_API || undefined,
368
- ...options,
369
- });
370
- }
371
- return defaultStore;
372
- }
373
-
374
- // ============================================
375
- // VALIDATION HELPERS
376
- // ============================================
377
-
378
- export interface TokenChangeValidation {
379
- valid: boolean;
380
- blockedChanges: Array<{
381
- path: string;
382
- reason: string;
383
- }>;
384
- }
385
-
386
- /**
387
- * Validate token changes against locked tokens
388
- */
389
- export async function validateTokenChanges(
390
- tenantId: string,
391
- changes: Record<string, unknown>,
392
- store?: TenantStore
393
- ): Promise<TokenChangeValidation> {
394
- const tenantStore = store || getTenantStore();
395
- const blockedChanges: TokenChangeValidation["blockedChanges"] = [];
396
-
397
- // Flatten changes to paths
398
- const changePaths = flattenToPaths(changes);
399
-
400
- for (const changePath of changePaths) {
401
- const isLocked = await tenantStore.isTokenLocked(tenantId, changePath);
402
- if (isLocked) {
403
- blockedChanges.push({
404
- path: changePath,
405
- reason: `Token "${changePath}" is locked for tenant "${tenantId}"`,
406
- });
407
- }
408
- }
409
-
410
- return {
411
- valid: blockedChanges.length === 0,
412
- blockedChanges,
413
- };
414
- }
415
-
416
- /**
417
- * Flatten an object to dot-notation paths
418
- */
419
- function flattenToPaths(obj: unknown, prefix = ""): string[] {
420
- const paths: string[] = [];
421
-
422
- if (obj && typeof obj === "object" && !Array.isArray(obj)) {
423
- for (const [key, value] of Object.entries(obj)) {
424
- const newPrefix = prefix ? `${prefix}.${key}` : key;
425
-
426
- if (value && typeof value === "object" && !Array.isArray(value)) {
427
- paths.push(...flattenToPaths(value, newPrefix));
428
- } else {
429
- paths.push(newPrefix);
430
- }
431
- }
432
- }
433
-
434
- return paths;
435
- }
436
-
package/src/tokens.ts DELETED
@@ -1,208 +0,0 @@
1
- /**
2
- * Token data for MCP server
3
- *
4
- * Loads primitives dynamically from the local @atomix/tokens package.
5
- * This module provides the single source of truth for all design tokens.
6
- */
7
-
8
- import { createRequire } from "node:module";
9
- import { fileURLToPath } from "node:url";
10
- import { dirname, join } from "node:path";
11
-
12
- // Get the directory of this file
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = dirname(__filename);
15
-
16
- // Load primitives from the sibling atomix package
17
- // Path: packages/atomix-mcp/dist -> packages/atomix/dist
18
- const atomixPath = join(__dirname, "../../atomix/dist/index.mjs");
19
-
20
- // Dynamic import with fallback to inline primitives
21
- let loadedPrimitives: Record<string, unknown> | null = null;
22
-
23
- async function loadPrimitives(): Promise<Record<string, unknown>> {
24
- if (loadedPrimitives) return loadedPrimitives;
25
-
26
- try {
27
- const module = await import(atomixPath);
28
- loadedPrimitives = module.primitives as Record<string, unknown>;
29
- return loadedPrimitives;
30
- } catch (error) {
31
- console.error(`Failed to load @atomix/tokens from ${atomixPath}:`, error);
32
- console.error("Falling back to inline primitives...");
33
- loadedPrimitives = FALLBACK_PRIMITIVES;
34
- return FALLBACK_PRIMITIVES;
35
- }
36
- }
37
-
38
- // Export a synchronous reference (populated on first use)
39
- // For the MCP server, we'll use loadPrimitives() to ensure it's loaded
40
- export { loadPrimitives };
41
-
42
- // Synchronous export (may be empty until loadPrimitives is called)
43
- export let primitives: Record<string, unknown> = {};
44
-
45
- // Initialize primitives immediately
46
- loadPrimitives().then((p) => {
47
- primitives = p;
48
- });
49
-
50
- export const TOKEN_CATEGORIES = [
51
- "colors",
52
- "typography",
53
- "spacing",
54
- "sizing",
55
- "shadows",
56
- "radius",
57
- "motion",
58
- "zIndex",
59
- "borders",
60
- ] as const;
61
-
62
- export type TokenCategory = typeof TOKEN_CATEGORIES[number];
63
-
64
- // Fallback primitives (minimal set for testing when @atomix/tokens isn't available)
65
- const FALLBACK_PRIMITIVES: Record<string, unknown> = {
66
- colors: {
67
- static: {
68
- brand: {
69
- primary: "#007061",
70
- primaryLight: "#00A389",
71
- primaryDark: "#005A4D",
72
- primaryForeground: "#FFFFFF",
73
- },
74
- white: "#FFFFFF",
75
- black: "#000000",
76
- },
77
- modes: {
78
- light: {
79
- bgPage: "#FFFFFF",
80
- bgSurface: "#FFFFFF",
81
- bgMuted: "#F5F5F5",
82
- textPrimary: "#171717",
83
- textSecondary: "#525252",
84
- textMuted: "#A3A3A3",
85
- // Icon colors (derived from brand/text)
86
- iconBrand: "#007061", // = brand.primary
87
- iconStrong: "#171717", // = textPrimary
88
- iconSubtle: "#525252", // = textSecondary
89
- iconDisabled: "#A3A3A3", // = textMuted
90
- borderPrimary: "#E5E5E5",
91
- },
92
- dark: {
93
- bgPage: "#0A0A0A",
94
- bgSurface: "#1A1A1A",
95
- bgMuted: "#262626",
96
- textPrimary: "#FAFAFA",
97
- textSecondary: "#A3A3A3",
98
- textMuted: "#737373",
99
- // Icon colors (derived from brand/text)
100
- iconBrand: "#007061", // = brand.primary
101
- iconStrong: "#FAFAFA", // = textPrimary
102
- iconSubtle: "#A3A3A3", // = textSecondary
103
- iconDisabled: "#737373", // = textMuted
104
- borderPrimary: "#404040",
105
- },
106
- },
107
- scales: {
108
- green: {
109
- 50: "#E6F5F2",
110
- 500: "#007061",
111
- 900: "#002E28",
112
- },
113
- },
114
- },
115
- typography: {
116
- fontFamily: {
117
- sans: "Inter, system-ui, sans-serif",
118
- mono: "JetBrains Mono, monospace",
119
- },
120
- fontSize: {
121
- xs: "0.75rem",
122
- sm: "0.875rem",
123
- md: "1rem",
124
- lg: "1.125rem",
125
- xl: "1.25rem",
126
- },
127
- fontWeight: {
128
- regular: 400,
129
- medium: 500,
130
- semibold: 600,
131
- bold: 700,
132
- },
133
- lineHeight: {
134
- tight: 1.25,
135
- normal: 1.5,
136
- relaxed: 1.625,
137
- },
138
- },
139
- spacing: {
140
- scale: {
141
- xs: "0.25rem",
142
- sm: "0.5rem",
143
- md: "1rem",
144
- lg: "1.5rem",
145
- xl: "2rem",
146
- },
147
- inset: {
148
- xs: "0.25rem",
149
- sm: "0.5rem",
150
- md: "1rem",
151
- lg: "1.5rem",
152
- },
153
- },
154
- sizing: {
155
- button: {
156
- sm: { height: "32px" },
157
- md: { height: "40px" },
158
- lg: { height: "48px" },
159
- },
160
- },
161
- shadows: {
162
- elevation: {
163
- none: "none",
164
- sm: "0 1px 2px rgba(0, 0, 0, 0.05)",
165
- md: "0 4px 6px rgba(0, 0, 0, 0.1)",
166
- lg: "0 10px 15px rgba(0, 0, 0, 0.1)",
167
- },
168
- focus: {
169
- ring: "0 0 0 2px var(--atomix-brand)",
170
- },
171
- },
172
- radius: {
173
- scale: {
174
- none: "0",
175
- sm: "0.25rem",
176
- md: "0.5rem",
177
- lg: "0.75rem",
178
- xl: "1rem",
179
- full: "9999px",
180
- },
181
- },
182
- motion: {
183
- duration: {
184
- instant: "0ms",
185
- fast: "150ms",
186
- normal: "200ms",
187
- slow: "300ms",
188
- },
189
- easing: {
190
- ease: "cubic-bezier(0.4, 0, 0.2, 1)",
191
- easeIn: "cubic-bezier(0.4, 0, 1, 1)",
192
- easeOut: "cubic-bezier(0, 0, 0.2, 1)",
193
- },
194
- },
195
- zIndex: {
196
- dropdown: 1000,
197
- modal: 1100,
198
- tooltip: 1200,
199
- },
200
- borders: {
201
- width: {
202
- none: "0",
203
- thin: "1px",
204
- medium: "2px",
205
- },
206
- },
207
- };
208
-