vibedsgn-mcp 1.2.0 → 1.2.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.
@@ -0,0 +1,518 @@
1
+ // src/server.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+
5
+ // src/auth.ts
6
+ function getAuthHeader() {
7
+ const apiKey = process.env.VIBEDSGN_API_KEY;
8
+ if (apiKey) return `Bearer ${apiKey}`;
9
+ const sessionToken = process.env.VIBEDSGN_SESSION_TOKEN;
10
+ if (sessionToken) return `Bearer ${sessionToken}`;
11
+ throw new Error(
12
+ "Missing authentication: set VIBEDSGN_API_KEY or VIBEDSGN_SESSION_TOKEN environment variable"
13
+ );
14
+ }
15
+ function getApiBase() {
16
+ return process.env.VIBEDSGN_API_URL ?? "https://vibedsgn.com";
17
+ }
18
+
19
+ // src/client.ts
20
+ var VibedsgnApiError = class extends Error {
21
+ code;
22
+ status;
23
+ details;
24
+ constructor(code, status, message, details) {
25
+ super(message);
26
+ this.code = code;
27
+ this.status = status;
28
+ this.details = details;
29
+ }
30
+ };
31
+ var VibedsgnClient = class {
32
+ apiBase;
33
+ constructor() {
34
+ this.apiBase = getApiBase();
35
+ }
36
+ // Lazy auth: don't read the env var until a request actually needs it.
37
+ // This lets the MCP server start (and advertise its tools) even when the
38
+ // user hasn't configured VIBEDSGN_API_KEY yet — they'll get a structured
39
+ // UNAUTHORIZED error from the first authenticated tool call instead of a
40
+ // hard process exit at startup that looks like "MCP failed to connect".
41
+ get authHeader() {
42
+ try {
43
+ return getAuthHeader();
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : "Missing authentication";
46
+ throw new VibedsgnApiError("UNAUTHORIZED", 401, message);
47
+ }
48
+ }
49
+ async parseError(res) {
50
+ let body = null;
51
+ try {
52
+ body = await res.json();
53
+ } catch {
54
+ body = null;
55
+ }
56
+ if (body && typeof body === "object" && "error" in body && body.error && typeof body.error === "object") {
57
+ const err = body.error;
58
+ return new VibedsgnApiError(err.code ?? "INTERNAL", res.status, err.message ?? res.statusText, err.details);
59
+ }
60
+ const flatMsg = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : res.statusText;
61
+ return new VibedsgnApiError("INTERNAL", res.status, flatMsg);
62
+ }
63
+ /**
64
+ * Verify the current API key and return the authenticated user.
65
+ * Useful for agents to fail fast with a clear message before doing real work.
66
+ */
67
+ async whoami() {
68
+ const res = await fetch(`${this.apiBase}/api/mcp/whoami`, {
69
+ method: "GET",
70
+ headers: { Authorization: this.authHeader }
71
+ });
72
+ if (!res.ok) throw await this.parseError(res);
73
+ const data = await res.json();
74
+ return data.user;
75
+ }
76
+ /**
77
+ * Upload a base64-encoded image to Vercel Blob via the MCP upload route.
78
+ * Returns the public blob URL.
79
+ */
80
+ async uploadBase64Image(base64, filename, contentType) {
81
+ const res = await fetch(`${this.apiBase}/api/mcp/upload-base64`, {
82
+ method: "POST",
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ Authorization: this.authHeader
86
+ },
87
+ body: JSON.stringify({
88
+ image: base64,
89
+ filename: filename ?? `mcp-upload-${Date.now()}.png`,
90
+ contentType: contentType ?? "image/png"
91
+ })
92
+ });
93
+ if (!res.ok) throw await this.parseError(res);
94
+ const data = await res.json();
95
+ return data.url;
96
+ }
97
+ /**
98
+ * Fetch a public image URL, base64-encode it, and upload via uploadBase64Image.
99
+ * Lets agents skip the encode-and-stream step when they already have a hosted image.
100
+ * Caps at 4MB to match the upload route's limit.
101
+ */
102
+ async uploadImageFromUrl(url, filename) {
103
+ const res = await fetch(url);
104
+ if (!res.ok) {
105
+ throw new VibedsgnApiError(
106
+ "UPLOAD_FETCH_FAILED",
107
+ res.status,
108
+ `Failed to fetch image from ${url}: ${res.status} ${res.statusText}`
109
+ );
110
+ }
111
+ const contentType = res.headers.get("content-type") ?? "image/png";
112
+ if (!contentType.startsWith("image/")) {
113
+ throw new VibedsgnApiError(
114
+ "UPLOAD_INVALID_TYPE",
115
+ 400,
116
+ `URL did not return an image (content-type: ${contentType})`
117
+ );
118
+ }
119
+ const buffer = await res.arrayBuffer();
120
+ if (buffer.byteLength > 4 * 1024 * 1024) {
121
+ throw new VibedsgnApiError(
122
+ "UPLOAD_TOO_LARGE",
123
+ 413,
124
+ `Image at ${url} is ${(buffer.byteLength / 1024 / 1024).toFixed(2)} MB (max 4 MB)`
125
+ );
126
+ }
127
+ const base64 = Buffer.from(buffer).toString("base64");
128
+ const ext = contentType.split("/")[1]?.split(";")[0] ?? "png";
129
+ return this.uploadBase64Image(base64, filename ?? `mcp-fetched-${Date.now()}.${ext}`, contentType);
130
+ }
131
+ /**
132
+ * Create and publish a vibe via the MCP vibes route.
133
+ * Publishes immediately, no draft step. Returns absolute URL.
134
+ */
135
+ async createVibe(params) {
136
+ const res = await fetch(`${this.apiBase}/api/mcp/vibes`, {
137
+ method: "POST",
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ Authorization: this.authHeader
141
+ },
142
+ body: JSON.stringify(params)
143
+ });
144
+ if (!res.ok) throw await this.parseError(res);
145
+ return res.json();
146
+ }
147
+ /**
148
+ * Delete a vibe owned by the authenticated user. Returns 404 if not found
149
+ * or owned by a different user (we don't distinguish, to avoid info leak).
150
+ */
151
+ async deleteVibe(id) {
152
+ const res = await fetch(`${this.apiBase}/api/mcp/vibes/${encodeURIComponent(id)}`, {
153
+ method: "DELETE",
154
+ headers: { Authorization: this.authHeader }
155
+ });
156
+ if (!res.ok) throw await this.parseError(res);
157
+ return res.json();
158
+ }
159
+ /**
160
+ * List available design tools, optionally filtered.
161
+ * - category: exact match on category slug
162
+ * - slug: exact match on tool slug (case-insensitive)
163
+ * - q: fuzzy substring match across name/slug/description
164
+ * Multiple filters combine with AND.
165
+ */
166
+ async listDesignTools(opts) {
167
+ const params = new URLSearchParams();
168
+ if (opts?.category) params.set("category", opts.category);
169
+ if (opts?.slug) params.set("slug", opts.slug);
170
+ if (opts?.q) params.set("q", opts.q);
171
+ const qs = params.toString();
172
+ const url = `${this.apiBase}/api/mcp/tools${qs ? `?${qs}` : ""}`;
173
+ const res = await fetch(url, {
174
+ method: "GET",
175
+ headers: { Authorization: this.authHeader }
176
+ });
177
+ if (!res.ok) throw await this.parseError(res);
178
+ const data = await res.json();
179
+ return data.tools;
180
+ }
181
+ };
182
+
183
+ // src/tools/whoami.ts
184
+ import { z } from "zod";
185
+ function registerWhoami(server, client) {
186
+ server.registerTool(
187
+ "whoami",
188
+ {
189
+ title: "Whoami",
190
+ description: "Verify the current API key and return the authenticated Vibedsgn user. Call this FIRST before doing any real work \u2014 it confirms the key is valid and tells you which account vibes will be posted under. Cheap (no DB writes), and lets you fail fast with a clear error if auth is misconfigured.",
191
+ inputSchema: z.object({}).shape
192
+ },
193
+ async () => {
194
+ try {
195
+ const user = await client.whoami();
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text",
200
+ text: JSON.stringify({
201
+ success: true,
202
+ user: {
203
+ id: user.id,
204
+ name: user.name,
205
+ handle: user.handle,
206
+ image: user.image
207
+ }
208
+ })
209
+ }
210
+ ]
211
+ };
212
+ } catch (err) {
213
+ const apiErr = err instanceof VibedsgnApiError ? err : null;
214
+ const code = apiErr?.code ?? "INTERNAL";
215
+ const message = err instanceof Error ? err.message : "Unknown error";
216
+ console.error(`[vibedsgn-mcp] whoami error (${code}): ${message}`);
217
+ return {
218
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: { code, message } }) }],
219
+ isError: true
220
+ };
221
+ }
222
+ }
223
+ );
224
+ }
225
+
226
+ // src/tools/list-design-tools.ts
227
+ import { z as z2 } from "zod";
228
+ var ListDesignToolsInputSchema = z2.object({
229
+ category: z2.string().optional().describe(
230
+ "Filter to a single tool category. Common values: 'ai-design', 'ai-agents', 'design-skills', 'ai-stack'."
231
+ ),
232
+ slug: z2.string().optional().describe("Look up a single tool by slug (case-insensitive). Use this when you already know the exact tool."),
233
+ q: z2.string().optional().describe(
234
+ "Fuzzy substring match across name, slug, and description. Use this when you have a hint like 'cursor', 'midjourney', or 'next.js'."
235
+ )
236
+ });
237
+ function registerListDesignTools(server, client) {
238
+ server.registerTool(
239
+ "list_design_tools",
240
+ {
241
+ title: "List Design Tools",
242
+ description: "List the design tool registry on Vibedsgn so you can pick a valid `primaryToolId` for `post_vibe`. Returns id, name, slug, category, and description for each tool. Always call this BEFORE `post_vibe` unless you already have a tool id from a previous call. Use the `q` filter when you know the tool name (e.g. `q: 'cursor'`), the `category` filter to browse a section, or `slug` for an exact lookup. If you're unsure, call without arguments to see all tools.",
243
+ inputSchema: ListDesignToolsInputSchema.shape
244
+ },
245
+ async (args) => {
246
+ try {
247
+ const tools = await client.listDesignTools({
248
+ category: args.category,
249
+ slug: args.slug,
250
+ q: args.q
251
+ });
252
+ return {
253
+ content: [
254
+ {
255
+ type: "text",
256
+ text: JSON.stringify({
257
+ success: true,
258
+ count: tools.length,
259
+ tools: tools.map((t) => ({
260
+ id: t.id,
261
+ name: t.name,
262
+ slug: t.slug,
263
+ category: t.category,
264
+ description: t.description
265
+ }))
266
+ })
267
+ }
268
+ ]
269
+ };
270
+ } catch (err) {
271
+ const apiErr = err instanceof VibedsgnApiError ? err : null;
272
+ const code = apiErr?.code ?? "INTERNAL";
273
+ const message = err instanceof Error ? err.message : "Unknown error";
274
+ console.error(`[vibedsgn-mcp] list_design_tools error (${code}): ${message}`);
275
+ return {
276
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: { code, message } }) }],
277
+ isError: true
278
+ };
279
+ }
280
+ }
281
+ );
282
+ }
283
+
284
+ // src/tools/post-vibe.ts
285
+ import { z as z3 } from "zod";
286
+ var AiStackSchema = z3.object({
287
+ codingAgents: z3.array(z3.string()).optional().default([]),
288
+ imageGen: z3.array(z3.string()).optional().default([]),
289
+ videoGen: z3.array(z3.string()).optional().default([]),
290
+ other: z3.array(z3.string()).optional().default([])
291
+ }).optional().describe("AI tools used in the project. All inner arrays are optional \u2014 pass only what you used.");
292
+ var DesignSystemSchema = z3.object({
293
+ colors: z3.array(z3.string()).optional().default([]),
294
+ typography: z3.string().optional().default("")
295
+ }).optional().describe("Design system info. All fields optional.");
296
+ var PostVibeInputSchema = z3.object({
297
+ title: z3.string().min(1).describe("Project name (required, max 200 chars)."),
298
+ // Image: must provide ONE of desktopImageUrl (preferred) or desktopImage (base64).
299
+ desktopImageUrl: z3.string().url().optional().describe(
300
+ "PREFERRED: a public URL to the desktop screenshot. The MCP server fetches it, base64-encodes, and uploads. Faster than sending base64 over stdio. Must be a publicly accessible image URL (https://...). Max 4MB."
301
+ ),
302
+ desktopImage: z3.string().optional().describe(
303
+ "FALLBACK: a base64-encoded desktop screenshot (max 4MB decoded). Use this only if you don't have a public URL for the image. Slower than `desktopImageUrl` because base64 doubles the payload size."
304
+ ),
305
+ primaryToolId: z3.string().min(1).describe("Tool ID from `list_design_tools`. Must match the `id` field exactly, not the slug or name."),
306
+ description: z3.string().optional().describe("Short project description (1-2 sentences ideal)."),
307
+ mobileImageUrl: z3.string().url().optional().describe("Optional public URL for the mobile screenshot."),
308
+ mobileImage: z3.string().optional().describe("Optional base64-encoded mobile screenshot. Prefer mobileImageUrl."),
309
+ tags: z3.array(z3.string()).optional().describe("Free-form curation tags (e.g. ['saas', 'dashboard', 'ai-tooling'])."),
310
+ systemPrompt: z3.string().optional().describe("The system/generation prompt used to build the design."),
311
+ processNotes: z3.string().optional().describe("Notes about the build process \u2014 what worked, what didn't."),
312
+ aiStack: AiStackSchema,
313
+ designSystem: DesignSystemSchema,
314
+ liveUrl: z3.string().url().optional().describe("Live URL of the project, if hosted somewhere."),
315
+ githubUrl: z3.string().url().optional().describe("GitHub repository URL, if open source."),
316
+ dryRun: z3.boolean().optional().default(false).describe(
317
+ "When true, validate inputs and return what would be published (slug, url, primary tool name) WITHOUT actually uploading the screenshot or creating the vibe. Use this to preview-confirm with the user before committing. Default false."
318
+ )
319
+ }).refine(
320
+ (v) => Boolean(v.desktopImageUrl) || Boolean(v.desktopImage) || v.dryRun === true,
321
+ {
322
+ message: "Either `desktopImageUrl` (preferred) or `desktopImage` (base64) is required.",
323
+ path: ["desktopImageUrl"]
324
+ }
325
+ );
326
+ function registerPostVibe(server, client) {
327
+ server.registerTool(
328
+ "post_vibe",
329
+ {
330
+ title: "Post Vibe",
331
+ description: "Publish a design project to Vibedsgn. WORKFLOW: (1) Call `whoami` first to confirm the API key is valid. (2) Call `list_design_tools` (optionally with `q` or `category`) to pick a `primaryToolId`. (3) Call this tool with the title, primaryToolId, and EITHER `desktopImageUrl` (a public URL \u2014 preferred, faster) OR `desktopImage` (base64-encoded PNG, max 4MB). (4) Optional fields like `tags`, `description`, `aiStack`, `designSystem` improve discoverability but aren't required. (5) For preview-without-publish, set `dryRun: true` \u2014 returns the predicted slug and primary tool without uploading or creating anything. Returns the absolute URL of the published vibe and an `id` you can pass to `delete_vibe` if you need to clean up. Vibes are published immediately \u2014 there is no draft step.",
332
+ inputSchema: PostVibeInputSchema.shape
333
+ },
334
+ async (rawArgs) => {
335
+ try {
336
+ const args = PostVibeInputSchema.parse(rawArgs);
337
+ if (args.dryRun) {
338
+ console.error(`[vibedsgn-mcp] Dry-run post_vibe "${args.title}" \u2014 no upload, no DB write.`);
339
+ const placeholderImageUrl = args.desktopImageUrl ?? "dry-run://placeholder.png";
340
+ const dryResult = await client.createVibe({
341
+ title: args.title,
342
+ desktopImageUrl: placeholderImageUrl,
343
+ primaryToolId: args.primaryToolId,
344
+ description: args.description,
345
+ tags: args.tags,
346
+ systemPrompt: args.systemPrompt,
347
+ processNotes: args.processNotes,
348
+ aiStack: args.aiStack,
349
+ designSystem: args.designSystem,
350
+ liveUrl: args.liveUrl,
351
+ githubUrl: args.githubUrl,
352
+ dryRun: true
353
+ });
354
+ if (!("dryRun" in dryResult)) {
355
+ throw new Error("Expected dry-run response from backend, got a live create result.");
356
+ }
357
+ return {
358
+ content: [
359
+ {
360
+ type: "text",
361
+ text: JSON.stringify({
362
+ success: true,
363
+ dryRun: true,
364
+ wouldCreate: dryResult.wouldCreate,
365
+ message: "Dry run only \u2014 no vibe was created. Call again with dryRun: false to publish."
366
+ })
367
+ }
368
+ ]
369
+ };
370
+ }
371
+ let desktopImageUrl;
372
+ if (args.desktopImageUrl) {
373
+ console.error(`[vibedsgn-mcp] Fetching desktop image from URL: ${args.desktopImageUrl}`);
374
+ desktopImageUrl = await client.uploadImageFromUrl(
375
+ args.desktopImageUrl,
376
+ `${args.title.replace(/\s+/g, "-").toLowerCase()}-desktop.png`
377
+ );
378
+ } else if (args.desktopImage) {
379
+ console.error(`[vibedsgn-mcp] Uploading base64 desktop image (${args.desktopImage.length} chars)...`);
380
+ desktopImageUrl = await client.uploadBase64Image(
381
+ args.desktopImage,
382
+ `${args.title.replace(/\s+/g, "-").toLowerCase()}-desktop.png`
383
+ );
384
+ } else {
385
+ throw new Error("Either desktopImageUrl or desktopImage is required.");
386
+ }
387
+ let mobileImageUrl;
388
+ if (args.mobileImageUrl) {
389
+ console.error(`[vibedsgn-mcp] Fetching mobile image from URL...`);
390
+ mobileImageUrl = await client.uploadImageFromUrl(
391
+ args.mobileImageUrl,
392
+ `${args.title.replace(/\s+/g, "-").toLowerCase()}-mobile.png`
393
+ );
394
+ } else if (args.mobileImage) {
395
+ console.error(`[vibedsgn-mcp] Uploading base64 mobile image...`);
396
+ mobileImageUrl = await client.uploadBase64Image(
397
+ args.mobileImage,
398
+ `${args.title.replace(/\s+/g, "-").toLowerCase()}-mobile.png`
399
+ );
400
+ }
401
+ console.error(`[vibedsgn-mcp] Creating vibe "${args.title}"...`);
402
+ const result = await client.createVibe({
403
+ title: args.title,
404
+ desktopImageUrl,
405
+ primaryToolId: args.primaryToolId,
406
+ description: args.description,
407
+ mobileImageUrl,
408
+ tags: args.tags,
409
+ systemPrompt: args.systemPrompt,
410
+ processNotes: args.processNotes,
411
+ aiStack: args.aiStack,
412
+ designSystem: args.designSystem,
413
+ liveUrl: args.liveUrl,
414
+ githubUrl: args.githubUrl
415
+ });
416
+ if ("dryRun" in result) {
417
+ throw new Error("Unexpected dry-run response from backend on a live post_vibe call.");
418
+ }
419
+ console.error(`[vibedsgn-mcp] Vibe published: ${result.url}`);
420
+ return {
421
+ content: [
422
+ {
423
+ type: "text",
424
+ text: JSON.stringify({
425
+ success: true,
426
+ id: result.id,
427
+ slug: result.slug,
428
+ url: result.url,
429
+ // absolute URL — server guarantees full https://...
430
+ message: `Vibe "${args.title}" published. To remove it, call \`delete_vibe\` with id "${result.id}".`
431
+ })
432
+ }
433
+ ]
434
+ };
435
+ } catch (err) {
436
+ const apiErr = err instanceof VibedsgnApiError ? err : null;
437
+ const code = apiErr?.code ?? (err instanceof z3.ZodError ? "VALIDATION_ERROR" : "INTERNAL");
438
+ const message = err instanceof Error ? err.message : "Unknown error";
439
+ const details = apiErr?.details ?? (err instanceof z3.ZodError ? { issues: err.issues } : void 0);
440
+ console.error(`[vibedsgn-mcp] post_vibe error (${code}): ${message}`);
441
+ return {
442
+ content: [
443
+ {
444
+ type: "text",
445
+ text: JSON.stringify({ success: false, error: { code, message, details } })
446
+ }
447
+ ],
448
+ isError: true
449
+ };
450
+ }
451
+ }
452
+ );
453
+ }
454
+
455
+ // src/tools/delete-vibe.ts
456
+ import { z as z4 } from "zod";
457
+ var DeleteVibeInputSchema = z4.object({
458
+ id: z4.string().min(1).describe(
459
+ "The vibe id to delete (returned from `post_vibe`). You can ONLY delete vibes owned by the authenticated user \u2014 attempts to delete someone else's vibe return a not-found error."
460
+ )
461
+ });
462
+ function registerDeleteVibe(server, client) {
463
+ server.registerTool(
464
+ "delete_vibe",
465
+ {
466
+ title: "Delete Vibe",
467
+ description: "Permanently delete a vibe you own (cascades to likes, bookmarks, comments, and notifications). Use the `id` returned by `post_vibe`. If the vibe doesn't exist OR is owned by a different user, returns NOT_FOUND (we don't distinguish, to avoid leaking ownership). This is irreversible \u2014 there is no soft-delete or restore.",
468
+ inputSchema: DeleteVibeInputSchema.shape
469
+ },
470
+ async (args) => {
471
+ try {
472
+ const result = await client.deleteVibe(args.id);
473
+ return {
474
+ content: [
475
+ {
476
+ type: "text",
477
+ text: JSON.stringify({
478
+ success: true,
479
+ id: result.id,
480
+ message: `Vibe ${result.id} deleted.`
481
+ })
482
+ }
483
+ ]
484
+ };
485
+ } catch (err) {
486
+ const apiErr = err instanceof VibedsgnApiError ? err : null;
487
+ const code = apiErr?.code ?? "INTERNAL";
488
+ const message = err instanceof Error ? err.message : "Unknown error";
489
+ console.error(`[vibedsgn-mcp] delete_vibe error (${code}): ${message}`);
490
+ return {
491
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: { code, message } }) }],
492
+ isError: true
493
+ };
494
+ }
495
+ }
496
+ );
497
+ }
498
+
499
+ // src/server.ts
500
+ console.log = console.error;
501
+ async function startServer() {
502
+ const server = new McpServer({
503
+ name: "vibedsgn-mcp",
504
+ version: "1.2.1"
505
+ });
506
+ const client = new VibedsgnClient();
507
+ registerWhoami(server, client);
508
+ registerListDesignTools(server, client);
509
+ registerPostVibe(server, client);
510
+ registerDeleteVibe(server, client);
511
+ const transport = new StdioServerTransport();
512
+ await server.connect(transport);
513
+ console.error("[vibedsgn-mcp] Server started on stdio transport (v1.2.1)");
514
+ }
515
+
516
+ export {
517
+ startServer
518
+ };