lucent-ui 0.37.0 → 0.39.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.
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from 'node:http';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { registerTools, DESIGN_RULES_SUMMARY } from './tools.js';
6
+ const PORT = Number(process.env['PORT'] ?? 3000);
7
+ const HOST = process.env['HOST'] ?? '127.0.0.1';
8
+ const API_KEY = process.env['LUCENT_API_KEY'];
9
+ const MCP_PATH = process.env['LUCENT_MCP_PATH'] ?? '/mcp';
10
+ function log(msg) {
11
+ process.stderr.write(`[lucent-mcp-http] ${msg}\n`);
12
+ }
13
+ // Build a fresh McpServer + tool registrations per request (stateless mode).
14
+ function createServerInstance() {
15
+ const server = new McpServer({ name: 'lucent-mcp', version: '0.1.0' }, { instructions: DESIGN_RULES_SUMMARY });
16
+ registerTools(server);
17
+ return server;
18
+ }
19
+ function readJsonBody(req) {
20
+ return new Promise((resolve, reject) => {
21
+ const chunks = [];
22
+ req.on('data', (chunk) => chunks.push(chunk));
23
+ req.on('end', () => {
24
+ const raw = Buffer.concat(chunks).toString('utf8');
25
+ if (!raw)
26
+ return resolve(undefined);
27
+ try {
28
+ resolve(JSON.parse(raw));
29
+ }
30
+ catch (err) {
31
+ reject(err instanceof Error ? err : new Error(String(err)));
32
+ }
33
+ });
34
+ req.on('error', reject);
35
+ });
36
+ }
37
+ function writeJson(res, status, body) {
38
+ res.writeHead(status, { 'Content-Type': 'application/json' });
39
+ res.end(JSON.stringify(body));
40
+ }
41
+ function jsonRpcError(res, status, code, message) {
42
+ writeJson(res, status, {
43
+ jsonrpc: '2.0',
44
+ error: { code, message },
45
+ id: null,
46
+ });
47
+ }
48
+ function checkAuth(req) {
49
+ if (!API_KEY)
50
+ return true; // Auth disabled when env var is not set
51
+ const header = req.headers['authorization'];
52
+ if (typeof header !== 'string')
53
+ return false;
54
+ const match = header.match(/^Bearer\s+(.+)$/i);
55
+ return match !== null && match[1] === API_KEY;
56
+ }
57
+ function setCorsHeaders(res) {
58
+ res.setHeader('Access-Control-Allow-Origin', '*');
59
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
60
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
61
+ }
62
+ const httpServer = createServer(async (req, res) => {
63
+ setCorsHeaders(res);
64
+ // CORS preflight
65
+ if (req.method === 'OPTIONS') {
66
+ res.writeHead(204, { 'Access-Control-Max-Age': '86400' });
67
+ res.end();
68
+ return;
69
+ }
70
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
71
+ // Health check — unauthenticated, useful for uptime probes
72
+ if (url.pathname === '/health' && req.method === 'GET') {
73
+ writeJson(res, 200, { status: 'ok' });
74
+ return;
75
+ }
76
+ if (url.pathname !== MCP_PATH) {
77
+ writeJson(res, 404, { error: 'Not found' });
78
+ return;
79
+ }
80
+ if (!checkAuth(req)) {
81
+ res.setHeader('WWW-Authenticate', 'Bearer');
82
+ writeJson(res, 401, { error: 'Unauthorized' });
83
+ return;
84
+ }
85
+ // Only POST is supported in stateless mode (no server-initiated streams).
86
+ if (req.method !== 'POST') {
87
+ jsonRpcError(res, 405, -32000, 'Method not allowed.');
88
+ return;
89
+ }
90
+ try {
91
+ const body = await readJsonBody(req);
92
+ const server = createServerInstance();
93
+ const transport = new StreamableHTTPServerTransport({}); // stateless
94
+ // SDK types widen onclose/onerror with `| undefined`, which trips
95
+ // exactOptionalPropertyTypes against the stricter Transport interface.
96
+ await server.connect(transport);
97
+ res.on('close', () => {
98
+ void transport.close();
99
+ void server.close();
100
+ });
101
+ await transport.handleRequest(req, res, body);
102
+ }
103
+ catch (err) {
104
+ log(`error handling request: ${err.message}`);
105
+ if (!res.headersSent) {
106
+ jsonRpcError(res, 500, -32603, 'Internal server error');
107
+ }
108
+ }
109
+ });
110
+ httpServer.listen(PORT, HOST, () => {
111
+ log(`listening on http://${HOST}:${PORT}${MCP_PATH}`);
112
+ if (!API_KEY) {
113
+ log('WARNING: LUCENT_API_KEY is not set — the server is unauthenticated.');
114
+ }
115
+ if (HOST === '0.0.0.0' || HOST === '::') {
116
+ log(`WARNING: bound to ${HOST}. Set LUCENT_API_KEY before exposing publicly.`);
117
+ }
118
+ });
119
+ function shutdown() {
120
+ log('shutting down...');
121
+ httpServer.close(() => process.exit(0));
122
+ // Force-exit if graceful close hangs
123
+ setTimeout(() => process.exit(1), 5000).unref();
124
+ }
125
+ process.on('SIGINT', shutdown);
126
+ process.on('SIGTERM', shutdown);
@@ -1,11 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { z } from 'zod';
5
- import { ALL_MANIFESTS } from './registry.js';
6
- import { ALL_PATTERNS } from './pattern-registry.js';
7
- import { PALETTES, SHAPES, DENSITIES, SHADOWS, COMBINED, generatePresetConfig } from './presets.js';
8
- import { DESIGN_RULES, DESIGN_RULES_SUMMARY } from './design-rules.js';
4
+ import { registerTools, DESIGN_RULES_SUMMARY } from './tools.js';
9
5
  // ─── Auth stub ───────────────────────────────────────────────────────────────
10
6
  // LUCENT_API_KEY is reserved for the future paid tier.
11
7
  // When set, the server acknowledges it but does not yet enforce it.
@@ -13,55 +9,6 @@ const apiKey = process.env['LUCENT_API_KEY'];
13
9
  if (apiKey) {
14
10
  process.stderr.write('[lucent-mcp] Auth mode active (LUCENT_API_KEY is set).\n');
15
11
  }
16
- // ─── Helpers ─────────────────────────────────────────────────────────────────
17
- function findManifest(nameOrId) {
18
- const q = nameOrId.trim().toLowerCase();
19
- return ALL_MANIFESTS.find((m) => m.id.toLowerCase() === q || m.name.toLowerCase() === q);
20
- }
21
- function scoreManifest(m, query) {
22
- const q = query.toLowerCase();
23
- let score = 0;
24
- if (m.name.toLowerCase().includes(q))
25
- score += 10;
26
- if (m.id.toLowerCase().includes(q))
27
- score += 8;
28
- if (m.tier.toLowerCase().includes(q))
29
- score += 5;
30
- if (m.description.toLowerCase().includes(q))
31
- score += 4;
32
- if (m.designIntent.toLowerCase().includes(q))
33
- score += 3;
34
- for (const p of m.props) {
35
- if (p.name.toLowerCase().includes(q))
36
- score += 2;
37
- if (p.description.toLowerCase().includes(q))
38
- score += 1;
39
- }
40
- return score;
41
- }
42
- function findPattern(nameOrId) {
43
- const q = nameOrId.trim().toLowerCase();
44
- return ALL_PATTERNS.find((r) => r.id.toLowerCase() === q || r.name.toLowerCase() === q);
45
- }
46
- function scorePattern(r, query) {
47
- const q = query.toLowerCase();
48
- let score = 0;
49
- if (r.name.toLowerCase().includes(q))
50
- score += 10;
51
- if (r.id.toLowerCase().includes(q))
52
- score += 8;
53
- if (r.category.toLowerCase().includes(q))
54
- score += 5;
55
- if (r.description.toLowerCase().includes(q))
56
- score += 4;
57
- if (r.designNotes.toLowerCase().includes(q))
58
- score += 3;
59
- for (const c of r.components) {
60
- if (c.toLowerCase().includes(q))
61
- score += 2;
62
- }
63
- return score;
64
- }
65
12
  // ─── MCP Server ───────────────────────────────────────────────────────────────
66
13
  const server = new McpServer({
67
14
  name: 'lucent-mcp',
@@ -69,245 +16,7 @@ const server = new McpServer({
69
16
  }, {
70
17
  instructions: DESIGN_RULES_SUMMARY,
71
18
  });
72
- // Tool: list_components
73
- server.tool('list_components', 'Lists all available Lucent UI components with their name, tier (atom/molecule), and one-line description.', {}, async () => {
74
- const components = ALL_MANIFESTS.map((m) => ({
75
- id: m.id,
76
- name: m.name,
77
- tier: m.tier,
78
- description: m.description,
79
- }));
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: JSON.stringify({ components }, null, 2),
85
- },
86
- ],
87
- };
88
- });
89
- // Tool: get_component_manifest
90
- server.tool('get_component_manifest', 'Returns the full manifest JSON for a Lucent UI component, including props, usage examples, design intent, and accessibility notes.', { componentName: z.string().describe('Component name or id, e.g. "Button" or "form-field"') }, async ({ componentName }) => {
91
- const manifest = findManifest(componentName);
92
- if (!manifest) {
93
- return {
94
- content: [
95
- {
96
- type: 'text',
97
- text: JSON.stringify({
98
- error: `Component "${componentName}" not found.`,
99
- available: ALL_MANIFESTS.map((m) => m.name),
100
- }),
101
- },
102
- ],
103
- isError: true,
104
- };
105
- }
106
- return {
107
- content: [
108
- {
109
- type: 'text',
110
- text: JSON.stringify(manifest, null, 2),
111
- },
112
- ],
113
- };
114
- });
115
- // Tool: search_components
116
- server.tool('search_components', 'Searches Lucent UI components and composition patterns by description or concept. Returns matching components and patterns ranked by relevance.', { query: z.string().describe('Natural language or keyword query, e.g. "loading indicator", "form validation", or "profile card"') }, async ({ query }) => {
117
- const componentResults = ALL_MANIFESTS
118
- .map((m) => ({ manifest: m, score: scoreManifest(m, query) }))
119
- .filter(({ score }) => score > 0)
120
- .sort((a, b) => b.score - a.score)
121
- .map(({ manifest, score }) => ({
122
- id: manifest.id,
123
- name: manifest.name,
124
- tier: manifest.tier,
125
- description: manifest.description,
126
- score,
127
- }));
128
- const patternResults = ALL_PATTERNS
129
- .map((r) => ({ pattern: r, score: scorePattern(r, query) }))
130
- .filter(({ score }) => score > 0)
131
- .sort((a, b) => b.score - a.score)
132
- .map(({ pattern, score }) => ({
133
- id: pattern.id,
134
- name: pattern.name,
135
- category: pattern.category,
136
- description: pattern.description,
137
- score,
138
- }));
139
- return {
140
- content: [
141
- {
142
- type: 'text',
143
- text: JSON.stringify({ query, components: componentResults, patterns: patternResults }, null, 2),
144
- },
145
- ],
146
- };
147
- });
148
- // Tool: get_composition_pattern
149
- server.tool('get_composition_pattern', 'Returns a full composition pattern with structure tree, working JSX code, variants, and design notes. Query by pattern name/id or by category to get all patterns in that category.', {
150
- name: z.string().optional().describe('Pattern name or id, e.g. "Profile Card" or "settings-panel"'),
151
- category: z.string().optional().describe('Pattern category: "card", "form", "nav", "dashboard", "settings", or "action"'),
152
- }, async ({ name, category }) => {
153
- if (name) {
154
- const pattern = findPattern(name);
155
- if (!pattern) {
156
- return {
157
- content: [
158
- {
159
- type: 'text',
160
- text: JSON.stringify({
161
- error: `Pattern "${name}" not found.`,
162
- available: ALL_PATTERNS.map((r) => ({ id: r.id, name: r.name, category: r.category })),
163
- }),
164
- },
165
- ],
166
- isError: true,
167
- };
168
- }
169
- return {
170
- content: [
171
- {
172
- type: 'text',
173
- text: JSON.stringify(pattern, null, 2),
174
- },
175
- ],
176
- };
177
- }
178
- if (category) {
179
- const cat = category.trim().toLowerCase();
180
- const patterns = ALL_PATTERNS.filter((r) => r.category === cat);
181
- if (patterns.length === 0) {
182
- return {
183
- content: [
184
- {
185
- type: 'text',
186
- text: JSON.stringify({
187
- error: `No patterns found in category "${category}".`,
188
- availableCategories: [...new Set(ALL_PATTERNS.map((r) => r.category))],
189
- }),
190
- },
191
- ],
192
- isError: true,
193
- };
194
- }
195
- return {
196
- content: [
197
- {
198
- type: 'text',
199
- text: JSON.stringify({ category: cat, patterns }, null, 2),
200
- },
201
- ],
202
- };
203
- }
204
- // No filter — return all patterns
205
- return {
206
- content: [
207
- {
208
- type: 'text',
209
- text: JSON.stringify({
210
- patterns: ALL_PATTERNS.map((r) => ({
211
- id: r.id,
212
- name: r.name,
213
- category: r.category,
214
- description: r.description,
215
- components: r.components,
216
- })),
217
- }, null, 2),
218
- },
219
- ],
220
- };
221
- });
222
- // Tool: list_presets
223
- server.tool('list_presets', 'Lists all available Lucent UI design presets. Returns combined presets (modern, enterprise, playful) and individual dimensions (palettes, shapes, densities, shadows) that can be mixed and matched.', {}, async () => {
224
- return {
225
- content: [
226
- {
227
- type: 'text',
228
- text: JSON.stringify({
229
- combined: COMBINED,
230
- palettes: PALETTES,
231
- shapes: SHAPES,
232
- densities: DENSITIES,
233
- shadows: SHADOWS,
234
- }, null, 2),
235
- },
236
- ],
237
- };
238
- });
239
- // Tool: get_preset_config
240
- server.tool('get_preset_config', 'Returns the LucentProvider configuration code for a given preset selection. Pass a combined preset name OR individual dimension names to get a ready-to-use config file and provider snippet.', {
241
- preset: z.string().optional().describe('Combined preset name: "modern", "enterprise", or "playful"'),
242
- palette: z.string().optional().describe('Palette name: "default", "brand", "indigo", "emerald", "rose", or "ocean"'),
243
- shape: z.string().optional().describe('Shape name: "sharp", "rounded", or "pill"'),
244
- density: z.string().optional().describe('Density name: "compact", "default", or "spacious"'),
245
- shadow: z.string().optional().describe('Shadow name: "flat", "subtle", or "elevated"'),
246
- }, async ({ preset, palette, shape, density, shadow }) => {
247
- const result = generatePresetConfig({ preset, palette, shape, density, shadow });
248
- if ('error' in result) {
249
- return {
250
- content: [{ type: 'text', text: JSON.stringify({ error: result.error }) }],
251
- isError: true,
252
- };
253
- }
254
- return {
255
- content: [
256
- {
257
- type: 'text',
258
- text: JSON.stringify({
259
- configFile: result.configFile,
260
- providerSnippet: result.providerSnippet,
261
- }, null, 2),
262
- },
263
- ],
264
- };
265
- });
266
- // Tool: get_design_rules
267
- server.tool('get_design_rules', 'Returns Lucent UI design rules for spacing, typography, button pairing, layout patterns, color usage, and density. These rules ensure AI-generated layouts are aesthetically consistent. Query a specific section or get all rules.', {
268
- section: z
269
- .string()
270
- .optional()
271
- .describe('Optional section id: "spacing", "typography", "buttons", "layout", "color", or "density". Omit to get all rules.'),
272
- }, async ({ section }) => {
273
- if (section) {
274
- const s = section.trim().toLowerCase();
275
- const rule = DESIGN_RULES.find((r) => r.id === s);
276
- if (!rule) {
277
- return {
278
- content: [
279
- {
280
- type: 'text',
281
- text: JSON.stringify({
282
- error: `Section "${section}" not found.`,
283
- availableSections: DESIGN_RULES.map((r) => ({
284
- id: r.id,
285
- title: r.title,
286
- })),
287
- }),
288
- },
289
- ],
290
- isError: true,
291
- };
292
- }
293
- return {
294
- content: [
295
- {
296
- type: 'text',
297
- text: `## ${rule.title}\n\n${rule.body}`,
298
- },
299
- ],
300
- };
301
- }
302
- return {
303
- content: [
304
- {
305
- type: 'text',
306
- text: DESIGN_RULES_SUMMARY,
307
- },
308
- ],
309
- };
310
- });
19
+ registerTools(server);
311
20
  // ─── Start ────────────────────────────────────────────────────────────────────
312
21
  const transport = new StdioServerTransport();
313
22
  await server.connect(transport);
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Structured logging for MCP tool calls.
4
+ *
5
+ * One JSON line per call is written to stderr (greppable, parseable by log
6
+ * shippers). Set `LUCENT_MCP_QUIET=1` to disable all logging.
7
+ *
8
+ * When `LUCENT_API_KEY` is set, a short hash prefix of the key is included
9
+ * in log entries for usage analytics. The raw key is never logged.
10
+ */
11
+ const QUIET = process.env['LUCENT_MCP_QUIET'] === '1';
12
+ /**
13
+ * Returns the first 8 hex chars of sha256(key). Short enough to stay readable
14
+ * in logs, safe to leak (pre-image resistant), and unique enough to distinguish
15
+ * customers once multi-key auth lands (see issue #15).
16
+ */
17
+ function hashKeyPrefix(key) {
18
+ return createHash('sha256').update(key).digest('hex').slice(0, 8);
19
+ }
20
+ export function logToolCall(entry) {
21
+ if (QUIET)
22
+ return;
23
+ const apiKey = process.env['LUCENT_API_KEY'];
24
+ const line = JSON.stringify({
25
+ t: new Date().toISOString(),
26
+ tool: entry.tool,
27
+ params: entry.params,
28
+ durationMs: entry.durationMs,
29
+ ok: entry.ok,
30
+ ...(entry.error !== undefined && { error: entry.error }),
31
+ ...(apiKey !== undefined && { key: hashKeyPrefix(apiKey) }),
32
+ });
33
+ process.stderr.write(line + '\n');
34
+ }
35
+ /**
36
+ * Wraps a tool handler with timing + structured logging. The returned function
37
+ * has the same signature as the input, so it can be passed directly to
38
+ * `server.tool(...)` without any call-site changes.
39
+ */
40
+ export function withLogging(name, handler) {
41
+ const wrapped = async (...args) => {
42
+ const start = Date.now();
43
+ const params = args[0] ?? {};
44
+ try {
45
+ const result = await handler(...args);
46
+ logToolCall({
47
+ tool: name,
48
+ params,
49
+ durationMs: Date.now() - start,
50
+ ok: !result.isError,
51
+ });
52
+ return result;
53
+ }
54
+ catch (err) {
55
+ logToolCall({
56
+ tool: name,
57
+ params,
58
+ durationMs: Date.now() - start,
59
+ ok: false,
60
+ error: err instanceof Error ? err.message : String(err),
61
+ });
62
+ throw err;
63
+ }
64
+ };
65
+ return wrapped;
66
+ }