viewgate-mcp 1.0.42 → 1.0.43

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.
Files changed (2) hide show
  1. package/dist/index.js +51 -288
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,8 +23,6 @@ import dotenv from "dotenv";
23
23
  import path from "path";
24
24
  import os from "os";
25
25
  import { fileURLToPath } from "url";
26
- import fs from "fs";
27
- import fsp from "fs/promises";
28
26
  const __filename = fileURLToPath(import.meta.url);
29
27
  const __dirname = path.dirname(__filename);
30
28
  dotenv.config({ path: path.join(__dirname, "..", ".env") });
@@ -73,6 +71,19 @@ function createMcpServer(apiKey, personalKey) {
73
71
  },
74
72
  },
75
73
  },
74
+ {
75
+ name: "mark_ui_component_generated",
76
+ description: "Mark a UI component as generated by submitting its code and props to the backend. This enables the dashboard iframe preview (/preview/:id).",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ id: { type: "string", description: "Internal UI component id." },
81
+ code: { type: "string", description: "Generated component code used for iframe preview." },
82
+ props: { type: "object", description: "Props object used for iframe preview." }
83
+ },
84
+ required: ["id", "code"]
85
+ }
86
+ },
76
87
  {
77
88
  name: "get_annotations",
78
89
  description: "Fetch feedback. Keys ('VG-XXXX') or IDs. Workflow: 1. Fetch, 2. Fix, 3. Mark Ready.",
@@ -189,247 +200,6 @@ function createMcpServer(apiKey, personalKey) {
189
200
  ],
190
201
  };
191
202
  });
192
- const toPascalCase = (input) => {
193
- return (input || '')
194
- .replace(/[^a-zA-Z0-9 ]+/g, ' ')
195
- .split(' ')
196
- .map((w) => w.trim())
197
- .filter(Boolean)
198
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
199
- .join('');
200
- };
201
- const normalizePropTokens = (raw) => {
202
- const line = (raw || '').trim();
203
- if (!line)
204
- return [];
205
- // Split "onClick / onChange / onFocus / onBlur" style
206
- const parts = line.split('/').map((s) => s.trim()).filter(Boolean);
207
- return parts.flatMap((p) => {
208
- // Take part before spaces or parentheses for "variant (primary...)" patterns
209
- const base = p.split('(')[0].trim();
210
- const token = base.split(' ')[0].trim();
211
- return token ? [token] : [];
212
- });
213
- };
214
- const buildPropModel = (commonProps, requiredProps) => {
215
- const out = {
216
- hasChildren: false,
217
- hasAs: false,
218
- hasDataIndex: false,
219
- hasAriaIndex: false,
220
- common: [],
221
- required: [],
222
- };
223
- const add = (arr, target) => {
224
- for (const raw of arr) {
225
- for (const token of normalizePropTokens(raw)) {
226
- if (token === 'children')
227
- out.hasChildren = true;
228
- if (token === 'as')
229
- out.hasAs = true;
230
- if (token.startsWith('data-'))
231
- out.hasDataIndex = true;
232
- if (token.startsWith('aria-'))
233
- out.hasAriaIndex = true;
234
- if (token === 'ref')
235
- continue; // handled via forwardRef if needed later
236
- if (token === 'data-*' || token === 'aria-*')
237
- continue;
238
- // skip wildcard tokens, handled by index signatures
239
- if (token.endsWith('*'))
240
- continue;
241
- target.push(token);
242
- }
243
- }
244
- };
245
- add(commonProps || [], out.common);
246
- add(requiredProps || [], out.required);
247
- const uniq = (xs) => [...new Set(xs)].filter(Boolean);
248
- out.common = uniq(out.common);
249
- out.required = uniq(out.required);
250
- return out;
251
- };
252
- const readJsonIfExists = async (p) => {
253
- try {
254
- const txt = await fsp.readFile(p, 'utf8');
255
- return JSON.parse(txt);
256
- }
257
- catch {
258
- return null;
259
- }
260
- };
261
- const looksLikeFrontendPackageJson = (pkg) => {
262
- const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
263
- const hasReact = typeof deps.react === 'string';
264
- const hasViteOrNext = typeof deps.vite === 'string' || typeof deps.next === 'string';
265
- return hasReact && hasViteOrNext;
266
- };
267
- const listSubdirs = async (dir) => {
268
- try {
269
- const entries = await fsp.readdir(dir, { withFileTypes: true });
270
- return entries.filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name));
271
- }
272
- catch {
273
- return [];
274
- }
275
- };
276
- const findFrontendRoot = async () => {
277
- // In many MCP clients, INIT_CWD points to the directory where `npx viewgate-mcp` was launched.
278
- const baseDir = process.env.VIEWGATE_WORKSPACE || process.env.INIT_CWD || process.cwd();
279
- // Scan baseDir and one level deep for candidate frontends.
280
- const candidates = [baseDir, ...(await listSubdirs(baseDir))];
281
- const hits = [];
282
- for (const c of candidates) {
283
- const pkg = await readJsonIfExists(path.join(c, 'package.json'));
284
- if (pkg && looksLikeFrontendPackageJson(pkg))
285
- hits.push(c);
286
- }
287
- if (hits.length === 0)
288
- return null;
289
- if (hits.length === 1)
290
- return hits[0];
291
- // Prefer common names when multiple projects exist.
292
- const preferred = hits.find((h) => {
293
- const name = path.basename(h).toLowerCase();
294
- return name === 'dashboard' || name === 'view-gate-dashboard' || name.includes('dashboard');
295
- });
296
- return preferred || hits[0];
297
- };
298
- const resolveComponentsDir = async () => {
299
- const frontendRoot = await findFrontendRoot();
300
- const cwd = process.cwd();
301
- // Order matters: prefer src/components for SPA projects like Vite.
302
- const rootsToTry = [
303
- ...(frontendRoot ? [frontendRoot] : []),
304
- cwd,
305
- ];
306
- for (const root of rootsToTry) {
307
- const c1 = path.join(root, 'src', 'components');
308
- if (fs.existsSync(c1))
309
- return c1;
310
- const c2 = path.join(root, 'components');
311
- if (fs.existsSync(c2))
312
- return c2;
313
- }
314
- // Fallback: create in src/components under detected frontendRoot if present.
315
- if (frontendRoot)
316
- return path.join(frontendRoot, 'src', 'components');
317
- return path.join(cwd, 'components');
318
- };
319
- const writeComponentFile = async (componentType, model) => {
320
- const componentsDir = await resolveComponentsDir();
321
- await fsp.mkdir(componentsDir, { recursive: true });
322
- const baseName = toPascalCase(componentType) || 'Component';
323
- const projectRoot = path.dirname(componentsDir);
324
- const hasTs = fs.existsSync(path.join(projectRoot, 'tsconfig.json'));
325
- const ext = hasTs ? 'tsx' : 'jsx';
326
- const fileBase = `${baseName}.${ext}`;
327
- let filePath = path.join(componentsDir, fileBase);
328
- if (fs.existsSync(filePath)) {
329
- // never overwrite existing
330
- let i = 2;
331
- while (fs.existsSync(path.join(componentsDir, `${baseName}.${i}.${ext}`)))
332
- i++;
333
- filePath = path.join(componentsDir, `${baseName}.${i}.${ext}`);
334
- }
335
- const isTs = ext === 'tsx';
336
- const commonOptional = model.common.map((p) => `${p}${isTs ? '?: any' : ''}`);
337
- const required = model.required.map((p) => `${p}${isTs ? ': any' : ''}`);
338
- const ariaIndex = model.hasAriaIndex ? (isTs ? `\n [key: \`aria-\${string}\`]: any;` : '') : '';
339
- const dataIndex = model.hasDataIndex ? (isTs ? `\n [key: \`data-\${string}\`]: any;` : '') : '';
340
- const propsBlock = isTs
341
- ? `export type ${baseName}Props = {\n${[
342
- model.hasAs ? ` as?: React.ElementType;` : null,
343
- model.hasChildren ? ` children?: React.ReactNode;` : null,
344
- ...required.map((l) => ` ${l};`),
345
- ...commonOptional.map((l) => ` ${l};`),
346
- ].filter(Boolean).join('\n')}${ariaIndex}${dataIndex}\n};`
347
- : '';
348
- const destructure = [
349
- model.hasAs ? 'as: Comp = "div"' : null,
350
- model.hasChildren ? 'children' : null,
351
- ...model.required,
352
- ...model.common,
353
- '...rest'
354
- ].filter(Boolean).join(', ');
355
- const jsxTag = model.hasAs ? '<Comp' : '<div';
356
- const jsxClose = model.hasAs ? '</Comp>' : '</div>';
357
- const content = isTs
358
- ? `import React from 'react';\n\n${propsBlock}\n\nexport function ${baseName}({ ${destructure} }: ${baseName}Props) {\n return (\n ${jsxTag} {...rest}>\n {children}\n ${jsxClose}\n );\n}\n`
359
- : `import React from 'react';\n\nexport function ${baseName}(props) {\n const { ${destructure} } = props || {};\n return (\n ${jsxTag} {...rest}>\n {children}\n ${jsxClose}\n );\n}\n`;
360
- await fsp.writeFile(filePath, content, 'utf8');
361
- return { filePath, fileName: path.basename(filePath) };
362
- };
363
- const makePreviewSvg = (title, props) => {
364
- const safeTitle = (title || '').replace(/[<>]/g, '');
365
- const list = (props || []).slice(0, 14).map((p) => p.replace(/[<>]/g, '')).join(' • ');
366
- const text = `${safeTitle}${list ? ' — ' + list : ''}`;
367
- return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420">\n <defs>\n <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">\n <stop offset="0" stop-color="#0f172a"/>\n <stop offset="1" stop-color="#111827"/>\n </linearGradient>\n </defs>\n <rect width="1200" height="420" rx="32" fill="url(#g)"/>\n <rect x="40" y="40" width="1120" height="340" rx="28" fill="#0b1220" stroke="rgba(255,255,255,0.08)"/>\n <text x="80" y="150" font-family="Inter,ui-sans-serif,system-ui" font-size="46" font-weight="800" fill="#e2e8f0">${safeTitle}</text>\n <text x="80" y="220" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="22" font-weight="700" fill="#94a3b8">Preview (fast)</text>\n <text x="80" y="280" font-family="Inter,ui-sans-serif,system-ui" font-size="18" font-weight="600" fill="#64748b">${text}</text>\n</svg>`;
368
- };
369
- const buildDefaultProps = (propNames) => {
370
- const defaults = {};
371
- for (const p of propNames || []) {
372
- if (!p)
373
- continue;
374
- const name = String(p);
375
- if (name === 'disabled' || name === 'loading' || name === 'fullWidth') {
376
- defaults[name] = false;
377
- continue;
378
- }
379
- if (name === 'variant') {
380
- defaults[name] = 'primary';
381
- continue;
382
- }
383
- if (name === 'size') {
384
- defaults[name] = 'md';
385
- continue;
386
- }
387
- if (name === 'type') {
388
- defaults[name] = 'button';
389
- continue;
390
- }
391
- if (name === 'href') {
392
- defaults[name] = '#';
393
- continue;
394
- }
395
- if (name.toLowerCase().startsWith('on')) {
396
- defaults[name] = null;
397
- continue;
398
- }
399
- if (name === 'iconLeft' || name === 'iconRight') {
400
- defaults[name] = null;
401
- continue;
402
- }
403
- // Fallback
404
- defaults[name] = '';
405
- }
406
- return defaults;
407
- };
408
- const buildComponentCode = (componentType, requiredProps) => {
409
- const safeName = toPascalCase(componentType) || 'Component';
410
- const props = (requiredProps || []).filter(Boolean);
411
- const destructure = props.length > 0 ? `{ ${props.join(', ')} }` : 'props';
412
- // IMPORTANT: This code is executed inside a browser ESM module where React is already imported.
413
- // Avoid JSX to keep it runnable without a build step.
414
- const lines = [];
415
- lines.push(`const ${safeName} = (${destructure}) => {`);
416
- lines.push(` return React.createElement(`);
417
- lines.push(` "div",`);
418
- lines.push(` { style: { fontFamily: 'ui-sans-serif, system-ui', padding: 12 } },`);
419
- lines.push(` React.createElement(`);
420
- lines.push(` "div",`);
421
- lines.push(` { style: { fontSize: 14, fontWeight: 700, marginBottom: 8 } },`);
422
- lines.push(` ${JSON.stringify(safeName)}
423
- ),`);
424
- lines.push(` React.createElement(`);
425
- lines.push(` "pre",`);
426
- lines.push(` { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'pre-wrap' } },`);
427
- lines.push(` JSON.stringify({ ${props.join(', ')} }, null, 2)
428
- )`);
429
- lines.push(` );`);
430
- lines.push(`};`);
431
- return { exportName: safeName, code: lines.join('\n') };
432
- };
433
203
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
434
204
  const toolName = request.params.name;
435
205
  console.error(`[MCP] Handling tool call: ${toolName}`);
@@ -458,7 +228,6 @@ function createMcpServer(apiKey, personalKey) {
458
228
  case "generate_ui_components": {
459
229
  const args = request.params.arguments;
460
230
  const limit = Math.min(args.limit || 1, 10);
461
- const targetComponentsDir = await resolveComponentsDir();
462
231
  const fetchUrl = new URL(`${BACKEND_URL}/api/mcp/components`);
463
232
  fetchUrl.searchParams.append("limit", limit.toString());
464
233
  fetchUrl.searchParams.append("status", "pending");
@@ -482,51 +251,25 @@ function createMcpServer(apiKey, personalKey) {
482
251
  const componentType = item.componentType;
483
252
  const requiredProps = (item.requiredProps || []);
484
253
  const commonProps = (item.commonProps || []);
485
- const defaults = buildDefaultProps(requiredProps);
486
- const built = buildComponentCode(componentType, requiredProps);
487
- // Fast preview generation (SVG placeholder) + upload
488
- const svg = makePreviewSvg(componentType, requiredProps);
489
- const svg64 = Buffer.from(svg, 'utf8').toString('base64');
490
- const imageDataUrl = `data:image/svg+xml;base64,${svg64}`;
491
- const uploadResp = await fetch(`${BACKEND_URL}/api/screenshots/upload`, {
492
- method: 'POST',
493
- headers: {
494
- 'Content-Type': 'application/json',
495
- 'x-api-key': apiKey,
496
- ...(personalKey ? { 'x-personal-key': personalKey } : {})
497
- },
498
- body: JSON.stringify({ image: imageDataUrl })
499
- });
500
- if (!uploadResp.ok) {
501
- const errorBody = await uploadResp.text();
502
- throw new Error(`Preview upload failed (${uploadResp.status}): ${errorBody}`);
503
- }
504
- const uploaded = (await uploadResp.json());
505
- const previewUrl = uploaded?.url || null;
506
- const markResp = await fetch(`${BACKEND_URL}/api/mcp/components/${item._id}/generated`, {
507
- method: 'PATCH',
508
- headers: {
509
- 'Content-Type': 'application/json',
510
- 'x-api-key': apiKey,
511
- ...(personalKey ? { 'x-personal-key': personalKey } : {})
512
- },
513
- body: JSON.stringify({ previewImage: previewUrl, code: built.code, props: defaults })
514
- });
515
- if (!markResp.ok) {
516
- const errorBody = await markResp.text();
517
- throw new Error(`Mark generated failed (${markResp.status}): ${errorBody}`);
518
- }
519
- const marked = (await markResp.json());
254
+ const llmInstruction = {
255
+ componentType,
256
+ requiredProps,
257
+ commonProps,
258
+ figmaUrl: item.figmaUrl,
259
+ constraints: {
260
+ mustBeFunctional: true,
261
+ mustSupportRequiredProps: true,
262
+ mustSupportCommonProps: true,
263
+ avoidBreakingChanges: true,
264
+ }
265
+ };
520
266
  results.push({
521
267
  componentType,
522
268
  figmaUrl: item.figmaUrl,
523
269
  requiredProps,
524
- target: {
525
- componentsDir: targetComponentsDir,
526
- suggestedFileName: `${toPascalCase(componentType) || 'Component'}.jsx`
527
- },
528
- previewImage: previewUrl,
529
- db: marked?.data?._id || item._id
270
+ commonProps,
271
+ db: item._id,
272
+ llmInstruction
530
273
  });
531
274
  }
532
275
  return {
@@ -534,14 +277,34 @@ function createMcpServer(apiKey, personalKey) {
534
277
  type: "text",
535
278
  text: JSON.stringify({
536
279
  ok: true,
537
- generated: results.length,
538
- instruction: `IMPORTANTE: Generar/guardar el componente React real dentro del proyecto frontend detectado. Ruta objetivo sugerida: ${targetComponentsDir}. No escribir en rutas globales del editor.`,
539
- targetComponentsDir,
280
+ generated: 0,
281
+ instruction: "IMPORTANTE: Este tool NO debe generar código ni escribir archivos. Solo entrega al LLM la especificación (componentType + props) para que el LLM implemente un componente real y funcional en el repo.",
540
282
  results
541
283
  }, null, 2)
542
284
  }]
543
285
  };
544
286
  }
287
+ case "mark_ui_component_generated": {
288
+ const args = request.params.arguments;
289
+ if (!args?.id || !args?.code) {
290
+ throw new Error("id and code are required");
291
+ }
292
+ const response = await fetch(`${BACKEND_URL}/api/mcp/components/${args.id}/generated`, {
293
+ method: 'PATCH',
294
+ headers: {
295
+ 'Content-Type': 'application/json',
296
+ 'x-api-key': apiKey,
297
+ ...(personalKey ? { 'x-personal-key': personalKey } : {})
298
+ },
299
+ body: JSON.stringify({ code: args.code, props: args.props })
300
+ });
301
+ if (!response.ok) {
302
+ const errorBody = await response.text();
303
+ throw new Error(`Backend responded with ${response.status}: ${errorBody}`);
304
+ }
305
+ const data = await response.json();
306
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
307
+ }
545
308
  case "get_annotations": {
546
309
  const args = request.params.arguments;
547
310
  const limit = args.limit || 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viewgate-mcp",
3
- "version": "1.0.42",
3
+ "version": "1.0.43",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "viewgate-mcp": "./dist/index.js"