viewgate-mcp 1.0.38 → 1.0.39

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 +226 -0
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ 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";
26
28
  const __filename = fileURLToPath(import.meta.url);
27
29
  const __dirname = path.dirname(__filename);
28
30
  dotenv.config({ path: path.join(__dirname, "..", ".env") });
@@ -49,6 +51,28 @@ function createMcpServer(apiKey, personalKey) {
49
51
  server.setRequestHandler(ListToolsRequestSchema, async () => {
50
52
  return {
51
53
  tools: [
54
+ {
55
+ name: "get_ui_components",
56
+ description: "Fetch UI components created by UX/UI (progressive). Returns ONLY the pending components requested and their required props.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ limit: { type: "number", description: "Max results.", default: 1 },
61
+ status: { type: "string", description: "Component status filter (pending,generated).", default: "pending" },
62
+ },
63
+ },
64
+ },
65
+ {
66
+ name: "generate_ui_components",
67
+ description: "Generate real UI components into /components (no overwrite). Also generates and uploads a preview, then marks the component as generated.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ limit: { type: "number", description: "How many pending components to generate.", default: 1 },
72
+ framework: { type: "string", description: "Target: react or react+vite (both output React components).", default: "react" },
73
+ },
74
+ },
75
+ },
52
76
  {
53
77
  name: "get_annotations",
54
78
  description: "Fetch feedback. Keys ('VG-XXXX') or IDs. Workflow: 1. Fetch, 2. Fix, 3. Mark Ready.",
@@ -165,11 +189,213 @@ function createMcpServer(apiKey, personalKey) {
165
189
  ],
166
190
  };
167
191
  });
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 writeComponentFile = async (componentType, model) => {
253
+ const cwd = process.cwd();
254
+ const componentsDir = path.join(cwd, 'components');
255
+ await fsp.mkdir(componentsDir, { recursive: true });
256
+ const baseName = toPascalCase(componentType) || 'Component';
257
+ const hasTs = fs.existsSync(path.join(cwd, 'tsconfig.json'));
258
+ const ext = hasTs ? 'tsx' : 'jsx';
259
+ const fileBase = `${baseName}.${ext}`;
260
+ let filePath = path.join(componentsDir, fileBase);
261
+ if (fs.existsSync(filePath)) {
262
+ // never overwrite existing
263
+ let i = 2;
264
+ while (fs.existsSync(path.join(componentsDir, `${baseName}.${i}.${ext}`)))
265
+ i++;
266
+ filePath = path.join(componentsDir, `${baseName}.${i}.${ext}`);
267
+ }
268
+ const isTs = ext === 'tsx';
269
+ const commonOptional = model.common.map((p) => `${p}${isTs ? '?: any' : ''}`);
270
+ const required = model.required.map((p) => `${p}${isTs ? ': any' : ''}`);
271
+ const ariaIndex = model.hasAriaIndex ? (isTs ? `\n [key: \`aria-\${string}\`]: any;` : '') : '';
272
+ const dataIndex = model.hasDataIndex ? (isTs ? `\n [key: \`data-\${string}\`]: any;` : '') : '';
273
+ const propsBlock = isTs
274
+ ? `export type ${baseName}Props = {\n${[
275
+ model.hasAs ? ` as?: React.ElementType;` : null,
276
+ model.hasChildren ? ` children?: React.ReactNode;` : null,
277
+ ...required.map((l) => ` ${l};`),
278
+ ...commonOptional.map((l) => ` ${l};`),
279
+ ].filter(Boolean).join('\n')}${ariaIndex}${dataIndex}\n};`
280
+ : '';
281
+ const destructure = [
282
+ model.hasAs ? 'as: Comp = "div"' : null,
283
+ model.hasChildren ? 'children' : null,
284
+ ...model.required,
285
+ ...model.common,
286
+ '...rest'
287
+ ].filter(Boolean).join(', ');
288
+ const jsxTag = model.hasAs ? '<Comp' : '<div';
289
+ const jsxClose = model.hasAs ? '</Comp>' : '</div>';
290
+ const content = isTs
291
+ ? `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`
292
+ : `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`;
293
+ await fsp.writeFile(filePath, content, 'utf8');
294
+ return { filePath, fileName: path.basename(filePath) };
295
+ };
296
+ const makePreviewSvg = (title, props) => {
297
+ const safeTitle = (title || '').replace(/[<>]/g, '');
298
+ const list = (props || []).slice(0, 14).map((p) => p.replace(/[<>]/g, '')).join(' • ');
299
+ const text = `${safeTitle}${list ? ' — ' + list : ''}`;
300
+ 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>`;
301
+ };
168
302
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
169
303
  const toolName = request.params.name;
170
304
  console.error(`[MCP] Handling tool call: ${toolName}`);
171
305
  try {
172
306
  switch (toolName) {
307
+ case "get_ui_components": {
308
+ const args = request.params.arguments;
309
+ const limit = args.limit || 1;
310
+ const status = args.status || 'pending';
311
+ const fetchUrl = new URL(`${BACKEND_URL}/api/mcp/components`);
312
+ fetchUrl.searchParams.append("limit", limit.toString());
313
+ fetchUrl.searchParams.append("status", status);
314
+ const response = await fetch(fetchUrl, {
315
+ headers: {
316
+ 'x-api-key': apiKey,
317
+ ...(personalKey ? { 'x-personal-key': personalKey } : {})
318
+ }
319
+ });
320
+ if (!response.ok) {
321
+ const errorBody = await response.text();
322
+ throw new Error(`Backend responded with ${response.status}: ${errorBody}`);
323
+ }
324
+ const data = await response.json();
325
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
326
+ }
327
+ case "generate_ui_components": {
328
+ const args = request.params.arguments;
329
+ const limit = Math.min(args.limit || 1, 10);
330
+ const fetchUrl = new URL(`${BACKEND_URL}/api/mcp/components`);
331
+ fetchUrl.searchParams.append("limit", limit.toString());
332
+ fetchUrl.searchParams.append("status", "pending");
333
+ const response = await fetch(fetchUrl, {
334
+ headers: {
335
+ 'x-api-key': apiKey,
336
+ ...(personalKey ? { 'x-personal-key': personalKey } : {})
337
+ }
338
+ });
339
+ if (!response.ok) {
340
+ const errorBody = await response.text();
341
+ throw new Error(`Backend responded with ${response.status}: ${errorBody}`);
342
+ }
343
+ const payload = (await response.json());
344
+ const items = payload?.data || [];
345
+ if (!Array.isArray(items) || items.length === 0) {
346
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, generated: 0, message: "No pending UI components." }, null, 2) }] };
347
+ }
348
+ const results = [];
349
+ for (const item of items) {
350
+ const componentType = item.componentType;
351
+ const requiredProps = (item.requiredProps || []);
352
+ const commonProps = (item.commonProps || []);
353
+ const model = buildPropModel(commonProps, requiredProps);
354
+ const fileInfo = await writeComponentFile(componentType, model);
355
+ // Fast preview generation (SVG placeholder) + upload
356
+ const svg = makePreviewSvg(componentType, requiredProps);
357
+ const svg64 = Buffer.from(svg, 'utf8').toString('base64');
358
+ const imageDataUrl = `data:image/svg+xml;base64,${svg64}`;
359
+ const uploadResp = await fetch(`${BACKEND_URL}/api/screenshots/upload`, {
360
+ method: 'POST',
361
+ headers: {
362
+ 'Content-Type': 'application/json',
363
+ 'x-api-key': apiKey,
364
+ ...(personalKey ? { 'x-personal-key': personalKey } : {})
365
+ },
366
+ body: JSON.stringify({ image: imageDataUrl })
367
+ });
368
+ if (!uploadResp.ok) {
369
+ const errorBody = await uploadResp.text();
370
+ throw new Error(`Preview upload failed (${uploadResp.status}): ${errorBody}`);
371
+ }
372
+ const uploaded = (await uploadResp.json());
373
+ const previewUrl = uploaded?.url || null;
374
+ const markResp = await fetch(`${BACKEND_URL}/api/mcp/components/${item._id}/generated`, {
375
+ method: 'PATCH',
376
+ headers: {
377
+ 'Content-Type': 'application/json',
378
+ 'x-api-key': apiKey,
379
+ ...(personalKey ? { 'x-personal-key': personalKey } : {})
380
+ },
381
+ body: JSON.stringify({ previewImage: previewUrl })
382
+ });
383
+ if (!markResp.ok) {
384
+ const errorBody = await markResp.text();
385
+ throw new Error(`Mark generated failed (${markResp.status}): ${errorBody}`);
386
+ }
387
+ const marked = (await markResp.json());
388
+ results.push({
389
+ componentType,
390
+ figmaUrl: item.figmaUrl,
391
+ requiredProps,
392
+ written: fileInfo,
393
+ previewImage: previewUrl,
394
+ db: marked?.data?._id || item._id
395
+ });
396
+ }
397
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, generated: results.length, results }, null, 2) }] };
398
+ }
173
399
  case "get_annotations": {
174
400
  const args = request.params.arguments;
175
401
  const limit = args.limit || 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viewgate-mcp",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "viewgate-mcp": "./dist/index.js"