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.
- package/dist/index.js +226 -0
- 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;
|