@wevi/mcp 0.1.1 → 0.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.
package/dist/index.js CHANGED
@@ -6,6 +6,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import dotenv from "dotenv";
7
7
 
8
8
  // src/client/wevi-api-client.ts
9
+ var USER_AGENT = "Wevi-MCP-Server/0.2.1";
10
+ function toObject(value) {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
12
+ return value;
13
+ }
9
14
  var WeviApiClient = class {
10
15
  apiKey;
11
16
  baseUrl;
@@ -13,12 +18,16 @@ var WeviApiClient = class {
13
18
  this.apiKey = config.apiKey.trim();
14
19
  this.baseUrl = config.apiUrl.replace(/\/+$/, "");
15
20
  }
21
+ /** True when the configured key is a `wevi_test_` sandbox key. */
22
+ get isSandbox() {
23
+ return this.apiKey.startsWith("wevi_test_");
24
+ }
16
25
  get headers() {
17
26
  return {
18
27
  "Content-Type": "application/json",
19
28
  "X-API-Key": this.apiKey,
20
29
  Authorization: `Bearer ${this.apiKey}`,
21
- "User-Agent": "Wevi-MCP-Server/0.1.0"
30
+ "User-Agent": USER_AGENT
22
31
  };
23
32
  }
24
33
  async request(endpoint, options = {}) {
@@ -27,16 +36,11 @@ var WeviApiClient = class {
27
36
  try {
28
37
  response = await fetch(url, {
29
38
  ...options,
30
- headers: {
31
- ...this.headers,
32
- ...options.headers || {}
33
- }
39
+ headers: { ...this.headers, ...options.headers || {} }
34
40
  });
35
41
  } catch (err) {
36
42
  const msg = err instanceof Error ? err.message : String(err);
37
- throw new Error(
38
- `Failed to connect to Wevi API at ${this.baseUrl}: ${msg}`
39
- );
43
+ throw new Error(`Failed to connect to Wevi API at ${this.baseUrl}: ${msg}`);
40
44
  }
41
45
  if (!response.ok) {
42
46
  let errorBody = {};
@@ -45,18 +49,26 @@ var WeviApiClient = class {
45
49
  } catch {
46
50
  }
47
51
  const errObj = errorBody.error;
48
- const message = typeof errorBody.message === "string" && errorBody.message || typeof errObj === "string" && errObj || typeof errObj === "object" && errObj !== null && typeof errObj.message === "string" && errObj.message || `Wevi API error (${response.status} ${response.statusText})`;
52
+ const rawMessage = typeof errorBody.message === "string" && errorBody.message || Array.isArray(errorBody.message) && errorBody.message.join("; ") || typeof errObj === "string" && errObj || typeof errObj === "object" && errObj !== null && typeof errObj.message === "string" && errObj.message || `Wevi API error (${response.status} ${response.statusText})`;
53
+ const message = String(rawMessage);
49
54
  if (response.status === 402) {
55
+ const topUpUrl = typeof errorBody.topUpUrl === "string" ? errorBody.topUpUrl : "https://app.wevi.ai/app/profile?id=api-keys&topup=1";
56
+ const required = errorBody.creditsRequired;
57
+ const available = errorBody.creditsAvailable;
58
+ const detail = typeof required === "number" && typeof available === "number" ? ` Needs ${required} credit${required === 1 ? "" : "s"}, ${available} available.` : "";
50
59
  throw new Error(
51
- `[402 Payment Required] Render credit limit reached. Please upgrade your plan at https://app.wevi.ai/settings/billing to continue rendering.
60
+ `[402 Payment Required] Not enough API credits.${detail} Buy a credit pack or upgrade at ${topUpUrl}. Call wevi_get_credits to see the balance.
52
61
  Details: ${message}`
53
62
  );
54
63
  }
55
64
  if (response.status === 401) {
56
65
  throw new Error(
57
- `[401 Unauthorized] Invalid or revoked Wevi API key. Please check your WEVI_API_KEY in settings at https://app.wevi.ai/settings/api-keys.`
66
+ `[401 Unauthorized] Invalid or revoked Wevi API key. Check WEVI_API_KEY at https://app.wevi.ai/app/profile?id=api-keys.`
58
67
  );
59
68
  }
69
+ if (response.status === 429) {
70
+ throw new Error(`[429 Too Many Requests] Slow down: ${message}`);
71
+ }
60
72
  throw new Error(`[Wevi API Error ${response.status}] ${message}`);
61
73
  }
62
74
  if (response.status === 204) {
@@ -65,34 +77,89 @@ Details: ${message}`
65
77
  return await response.json();
66
78
  }
67
79
  /**
68
- * Lists published templates with optional filtering.
80
+ * Lists published, active templates. The API already restricts API-key
81
+ * callers to published templates; we also send `status=PUBLISHED` so the
82
+ * intent is explicit in logs.
69
83
  */
70
84
  async listTemplates(params) {
71
85
  const query = new URLSearchParams();
86
+ query.set("status", "PUBLISHED");
72
87
  if (params?.category) query.set("category", params.category);
73
88
  if (params?.aspectRatio) query.set("aspectRatio", params.aspectRatio);
74
89
  if (params?.search) query.set("search", params.search);
75
90
  if (params?.limit) query.set("limit", String(params.limit));
76
91
  if (params?.page) query.set("page", String(params.page));
77
- const qs = query.toString();
78
- const endpoint = `/templates${qs ? `?${qs}` : ""}`;
79
- const result = await this.request(endpoint);
80
- const rawData = result?.data ?? result;
81
- const items = Array.isArray(rawData) ? rawData : Array.isArray(rawData?.templates) ? rawData.templates : Array.isArray(rawData?.items) ? rawData.items : [];
82
- const total = rawData?.meta?.total ?? result?.total ?? rawData?.total ?? items.length;
83
- return { items, total };
92
+ const result = await this.request(`/templates?${query.toString()}`);
93
+ const data = toObject(result?.data ?? result);
94
+ const items = Array.isArray(data.templates) ? data.templates : [];
95
+ const meta = toObject(data.meta);
96
+ const total = typeof meta.total === "number" ? meta.total : items.length;
97
+ const categories = Array.isArray(data.categories) ? data.categories.filter((c) => typeof c === "string") : [];
98
+ return { items, total, categories };
84
99
  }
85
- /**
86
- * Retrieves full template details including editable dynamic layers.
87
- */
88
100
  async getTemplate(templateId) {
89
101
  const res = await this.request(
90
102
  `/templates/${encodeURIComponent(templateId)}`
91
103
  );
92
104
  return res?.data ?? res;
93
105
  }
106
+ /** Guidance an agent needs to place a template in the right scene role. */
107
+ static extractGuidance(template) {
108
+ const meta = template.semanticMeta ?? null;
109
+ const instructions = meta?.instructions ?? {};
110
+ const guide = template.smartGuide ?? meta?.smartGuide ?? null;
111
+ return {
112
+ purpose: instructions.purpose ?? null,
113
+ sceneIntent: instructions.designProtocol?.sceneIntent ?? [],
114
+ avoid: instructions.designProtocol?.avoid ?? [],
115
+ tone: instructions.scriptGuidelines?.tone ?? null,
116
+ onScreenTextFormat: instructions.scriptGuidelines?.onScreenTextFormat ?? null,
117
+ do: guide?.do ?? [],
118
+ dont: guide?.dont ?? []
119
+ };
120
+ }
121
+ /**
122
+ * Flattens the template's `layerMeta` into the editable parameters an agent
123
+ * can set. Hidden and protected layers are omitted.
124
+ */
125
+ static extractEditableLayers(template) {
126
+ const layerMeta = toObject(template.layerMeta);
127
+ const rawLayers = Array.isArray(template.layers) ? template.layers : [];
128
+ const orderedKeys = [];
129
+ for (const raw of rawLayers) {
130
+ const key = toObject(raw).key;
131
+ if (typeof key === "string" && key && !orderedKeys.includes(key)) orderedKeys.push(key);
132
+ }
133
+ for (const key of Object.keys(layerMeta)) {
134
+ if (!orderedKeys.includes(key)) orderedKeys.push(key);
135
+ }
136
+ const layers = [];
137
+ for (const key of orderedKeys) {
138
+ if (key.startsWith("__ui")) continue;
139
+ const meta = toObject(layerMeta[key]);
140
+ if (meta.hidden === true || meta.isProtected === true) continue;
141
+ const control = toObject(meta.control);
142
+ const options = Array.isArray(control.options) ? control.options.filter((o) => typeof o === "string") : [];
143
+ const validation = toObject(meta.validation);
144
+ const rawLayer = toObject(rawLayers.find((raw) => toObject(raw).key === key));
145
+ layers.push({
146
+ key,
147
+ label: String(meta.humanLabel || meta.label || rawLayer.label || key),
148
+ type: String(meta.type || rawLayer.type || "text").toLowerCase(),
149
+ role: typeof meta.role === "string" ? meta.role : void 0,
150
+ description: typeof meta.description === "string" ? meta.description : void 0,
151
+ aiHint: typeof meta.aiHint === "string" ? meta.aiHint : void 0,
152
+ required: meta.required === true,
153
+ defaultValue: meta.defaultValue ?? rawLayer.defaultValue ?? null,
154
+ ...options.length ? { options } : {},
155
+ ...Object.keys(validation).length ? { validation } : {}
156
+ });
157
+ }
158
+ return layers;
159
+ }
94
160
  /**
95
- * Creates a draft video project with template parameters.
161
+ * Creates a render-ready project. All scenes land in ONE project so the
162
+ * publish step exports a single concatenated video.
96
163
  */
97
164
  async createProject(data) {
98
165
  const res = await this.request("/projects", {
@@ -101,118 +168,133 @@ Details: ${message}`
101
168
  });
102
169
  return res?.data ?? res;
103
170
  }
104
- /**
105
- * Retrieves project details and current render state.
106
- */
107
171
  async getProject(projectId) {
108
- const res = await this.request(
109
- `/projects/${encodeURIComponent(projectId)}`
110
- );
111
- return res?.data?.project ?? res?.data ?? res;
172
+ const res = await this.request(`/projects/${encodeURIComponent(projectId)}`);
173
+ const data = toObject(res?.data ?? res);
174
+ return toObject(data.project) && Object.keys(toObject(data.project)).length ? toObject(data.project) : data;
112
175
  }
113
- /**
114
- * Triggers a high-definition video render export.
115
- */
116
- async triggerRender(projectId) {
176
+ /** Renders every scene and queues one concatenated export. */
177
+ async publishProject(projectId, options) {
117
178
  const res = await this.request(
118
- `/exports/projects/${encodeURIComponent(projectId)}`,
119
- {
120
- method: "POST",
121
- body: JSON.stringify({ quality: "1080p" })
122
- }
179
+ `/projects/${encodeURIComponent(projectId)}/publish`,
180
+ { method: "POST", body: JSON.stringify(options ?? {}) }
123
181
  );
124
- const exportData = res?.data?.export ?? res?.export ?? res?.data ?? res;
125
- const exportId = exportData?.id ?? exportData?.exportId ?? "";
126
- const status = exportData?.status ?? "QUEUED";
127
- return {
128
- id: exportId,
129
- exportId,
130
- status
131
- };
182
+ return res?.data ?? res;
132
183
  }
133
- /**
134
- * Checks the status of an ongoing render export.
135
- */
136
- async getRenderStatus(exportId) {
184
+ async getPublishStatus(projectId) {
137
185
  const res = await this.request(
138
- `/exports/${encodeURIComponent(exportId)}`
186
+ `/projects/${encodeURIComponent(projectId)}/publish/status`
139
187
  );
140
- return res?.data?.export ?? res?.data ?? res;
188
+ return res?.data ?? res;
141
189
  }
142
190
  /**
143
- * Polls render export status until complete (SUCCESS or FAILED) or timed out.
191
+ * Polls publish status until the video is ready, a failure is reported, or
192
+ * the time budget is spent. Returns the last status either way.
144
193
  */
145
- async pollRenderUntilComplete(exportId, timeoutSeconds = 90, pollIntervalMs = 3e3) {
146
- const startTime = Date.now();
147
- const maxDurationMs = timeoutSeconds * 1e3;
148
- while (Date.now() - startTime < maxDurationMs) {
149
- const status = await this.getRenderStatus(exportId);
150
- if (status.status === "SUCCESS" || status.status === "FAILED") {
151
- return status;
152
- }
194
+ async waitForPublish(projectId, waitSeconds, pollIntervalMs = 5e3) {
195
+ const deadline = Date.now() + waitSeconds * 1e3;
196
+ let status = await this.getPublishStatus(projectId);
197
+ while (Date.now() < deadline && (status.phase === "rendering" || status.phase === "exporting")) {
153
198
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
199
+ status = await this.getPublishStatus(projectId);
154
200
  }
155
- const lastStatus = await this.getRenderStatus(exportId);
156
- return lastStatus;
201
+ return status;
202
+ }
203
+ async getRenderStatus(exportId) {
204
+ const res = await this.request(`/exports/${encodeURIComponent(exportId)}`);
205
+ const data = toObject(res?.data ?? res);
206
+ return Object.keys(toObject(data.export)).length ? data.export : data;
207
+ }
208
+ /** Credit balance for live keys, plus the packs on sale and per-action costs. */
209
+ async getCredits() {
210
+ const res = await this.request("/billing/credits");
211
+ return res?.data ?? res;
157
212
  }
158
- /**
159
- * Generates an AI storyboard / copy for a template given a prompt.
160
- */
161
213
  async generateStoryboard(data) {
162
214
  const res = await this.request("/ai/storyboard/draft", {
163
215
  method: "POST",
164
216
  body: JSON.stringify(data)
165
217
  });
166
- return res?.data?.draft ?? res?.data ?? res;
218
+ const payload = toObject(res?.data ?? res);
219
+ return Object.keys(toObject(payload.draft)).length ? toObject(payload.draft) : payload;
167
220
  }
168
- /**
169
- * Captures a high-resolution screenshot from a live URL.
170
- */
171
221
  async captureWebUi(data) {
172
- const res = await this.request(
173
- "/browse/capture",
174
- {
175
- method: "POST",
176
- body: JSON.stringify(data)
177
- }
178
- );
179
- return res?.data ?? res;
222
+ const res = await this.request("/browse/capture", {
223
+ method: "POST",
224
+ body: JSON.stringify(data)
225
+ });
226
+ return toObject(res?.data ?? res);
180
227
  }
181
228
  };
182
229
 
183
230
  // src/tools/templates.tools.ts
184
231
  import { z } from "zod";
232
+
233
+ // src/types/index.ts
234
+ var WEVI_ASPECT_RATIOS = ["16:9", "16:12"];
235
+
236
+ // src/tools/templates.tools.ts
237
+ function errorResult(prefix, err) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ return {
240
+ isError: true,
241
+ content: [{ type: "text", text: `${prefix}: ${msg}` }]
242
+ };
243
+ }
185
244
  function registerTemplateTools(server2, client2) {
186
245
  server2.tool(
187
246
  "wevi_list_templates",
188
- "List and search available video templates on Wevi with category, aspect ratio, and keyword filters.",
247
+ [
248
+ "List PUBLISHED Wevi video templates (only published templates can be used in projects).",
249
+ "Filter by aspect ratio, category or keyword. Every scene in one project must share the same aspectRatio,",
250
+ "so pick all templates for a video from a single aspectRatio group.",
251
+ "Templates with requiresUiCapture=true need an interactive screen capture in the Wevi app and cannot be rendered from MCP yet."
252
+ ].join(" "),
189
253
  {
190
- category: z.string().optional().describe("Category filter (e.g., 'SaaS', 'E-commerce', 'Social Media', 'Product Promo')"),
191
- aspectRatio: z.enum(["16:9", "16:12", "4:3"]).optional().describe("Target video aspect ratio: '16:9' (Widescreen landscape 1920x1080) or '16:12' (Desktop app focus frame 1920x1440 / 4:3)"),
192
- search: z.string().optional().describe("Search keyword matching template titles, tags, or descriptions"),
193
- limit: z.number().min(1).max(50).optional().default(20).describe("Number of templates to return (default: 20)")
254
+ category: z.string().optional().describe("Category filter (e.g. 'SaaS', 'CTA', 'Problem', 'Solution', 'Motion')"),
255
+ aspectRatio: z.enum(WEVI_ASPECT_RATIOS).optional().describe(
256
+ "'16:9' = widescreen 1920x1080 (default for most videos). '16:12' = 1920x1440 desktop-app focus frame."
257
+ ),
258
+ search: z.string().optional().describe("Keyword matched against template names, slugs and descriptions"),
259
+ limit: z.number().min(1).max(50).optional().default(20).describe("Number of templates to return (default 20, max 50)"),
260
+ page: z.number().min(1).optional().describe("Page number for pagination (default 1)")
194
261
  },
195
262
  async (args) => {
196
263
  try {
197
264
  const result = await client2.listTemplates(args);
265
+ const usable = result.items.filter((t) => t.requiresUiCapture !== true);
198
266
  return {
199
267
  content: [
200
268
  {
201
269
  type: "text",
202
270
  text: JSON.stringify(
203
271
  {
204
- totalTemplates: result.total,
205
- templates: result.items.map((t) => ({
206
- id: t.id,
207
- name: t.name,
208
- slug: t.slug,
209
- description: t.description,
210
- aspectRatio: t.aspectRatio,
211
- durationSeconds: t.durationSeconds,
212
- tags: t.tags,
213
- previewVideoUrl: t.previewVideoUrl,
214
- thumbnailUrl: t.thumbnailUrl
215
- }))
272
+ totalPublishedTemplates: result.total,
273
+ returned: result.items.length,
274
+ usableFromMcp: usable.length,
275
+ categories: result.categories,
276
+ sandbox: client2.isSandbox,
277
+ templates: result.items.map((t) => {
278
+ const guidance = WeviApiClient.extractGuidance(t);
279
+ return {
280
+ id: t.id,
281
+ slug: t.slug,
282
+ name: t.displayName || t.name,
283
+ description: t.aiDescription || t.description || null,
284
+ purpose: guidance.purpose,
285
+ bestForScenes: guidance.sceneIntent,
286
+ avoid: guidance.avoid,
287
+ tone: guidance.tone,
288
+ category: t.category ?? null,
289
+ aspectRatio: t.aspectRatio,
290
+ durationSeconds: t.durationSeconds ?? null,
291
+ requiresUiCapture: t.requiresUiCapture === true,
292
+ tags: t.tags ?? [],
293
+ previewVideoUrl: t.previewUrl ?? null,
294
+ thumbnailUrl: t.thumbnailUrl ?? null
295
+ };
296
+ }),
297
+ hint: "Match bestForScenes to the video's structure (hook \u2192 feature/solution \u2192 proof \u2192 cta). Respect each template's avoid list. Keep every scene in one aspectRatio."
216
298
  },
217
299
  null,
218
300
  2
@@ -221,23 +303,24 @@ function registerTemplateTools(server2, client2) {
221
303
  ]
222
304
  };
223
305
  } catch (err) {
224
- const msg = err instanceof Error ? err.message : String(err);
225
- return {
226
- isError: true,
227
- content: [{ type: "text", text: `Error listing templates: ${msg}` }]
228
- };
306
+ return errorResult("Error listing templates", err);
229
307
  }
230
308
  }
231
309
  );
232
310
  server2.tool(
233
311
  "wevi_get_template_schema",
234
- "Inspect the dynamic layer variables for a specific Wevi video template (e.g. text variables, color variables, logos, background videos). Use this before creating a project to know what parameters the template expects.",
312
+ [
313
+ "Inspect the editable parameters (layer keys) of a published template: text, colors, sliders, toggles, dropdown options, media.",
314
+ "Call this before wevi_create_project so the `parameters` you pass use exact layer keys and valid values."
315
+ ].join(" "),
235
316
  {
236
- templateId: z.string().describe("The unique ID or slug of the Wevi video template")
317
+ templateId: z.string().describe("Template ID or slug")
237
318
  },
238
319
  async (args) => {
239
320
  try {
240
321
  const template = await client2.getTemplate(args.templateId);
322
+ const editableLayers = WeviApiClient.extractEditableLayers(template);
323
+ const guidance = WeviApiClient.extractGuidance(template);
241
324
  return {
242
325
  content: [
243
326
  {
@@ -245,12 +328,17 @@ function registerTemplateTools(server2, client2) {
245
328
  text: JSON.stringify(
246
329
  {
247
330
  id: template.id,
248
- name: template.name,
249
331
  slug: template.slug,
332
+ name: template.displayName || template.name,
333
+ description: template.aiDescription || template.description || null,
334
+ category: template.category ?? null,
250
335
  aspectRatio: template.aspectRatio,
251
- durationSeconds: template.durationSeconds,
252
- description: template.description,
253
- editableLayers: template.layers || []
336
+ durationSeconds: template.durationSeconds ?? null,
337
+ requiresUiCapture: template.requiresUiCapture === true,
338
+ usableFromMcp: template.requiresUiCapture !== true,
339
+ guidance,
340
+ editableLayers,
341
+ usage: "Pass values as { parameters: { [layerKey]: value } } inside a scene of wevi_create_project. Colors are hex strings, sliders are numbers within validation.min/max, toggles are booleans, dropdowns must use one of `options`."
254
342
  },
255
343
  null,
256
344
  2
@@ -259,11 +347,7 @@ function registerTemplateTools(server2, client2) {
259
347
  ]
260
348
  };
261
349
  } catch (err) {
262
- const msg = err instanceof Error ? err.message : String(err);
263
- return {
264
- isError: true,
265
- content: [{ type: "text", text: `Error fetching template schema: ${msg}` }]
266
- };
350
+ return errorResult("Error fetching template schema", err);
267
351
  }
268
352
  }
269
353
  );
@@ -271,14 +355,26 @@ function registerTemplateTools(server2, client2) {
271
355
 
272
356
  // src/tools/projects.tools.ts
273
357
  import { z as z2 } from "zod";
358
+ var parameterValue = z2.union([z2.string(), z2.number(), z2.boolean(), z2.null()]);
359
+ var sceneSchema = z2.object({
360
+ templateId: z2.string().describe("Published template ID or slug for this scene"),
361
+ title: z2.string().max(120).optional().describe("Optional scene title"),
362
+ voiceoverText: z2.string().max(600).optional().describe("Optional narration for this scene (voiceover is generated at export when enabled)"),
363
+ parameters: z2.record(z2.string(), parameterValue).optional().describe("Layer key \u2192 value map from wevi_get_template_schema (e.g. { TXTTYPINGVAR: 'Ship faster', CLRTEXTTYPINGVAR: '#F8FAFC' })")
364
+ });
274
365
  function registerProjectTools(server2, client2) {
275
366
  server2.tool(
276
367
  "wevi_create_project",
277
- "Create a new video project on Wevi using a chosen template ID and initial layer parameters (such as headlines, descriptions, colors, logos, and animation settings).",
368
+ [
369
+ "Create a Wevi video project from one or more published templates.",
370
+ "Pass ALL scenes of the video in `scenes` (in order) \u2014 they are rendered and exported together as ONE concatenated video.",
371
+ "Do not create one project per scene. All templates must share the same aspectRatio and must not require UI capture.",
372
+ "The aspect ratio is taken from the templates automatically."
373
+ ].join(" "),
278
374
  {
279
- templateId: z2.string().describe("The ID of the template to base the project on"),
280
- title: z2.string().optional().describe("Optional project title (e.g., 'Product Promo Q3')"),
281
- parameters: z2.record(z2.string(), z2.any()).optional().describe("Dynamic parameter key-value pairs matching the template's editable layers (e.g. { TXTTYPINGVAR: 'New AI Features', CLRTEXTTYPINGVAR: '#F8FAFC' })")
375
+ title: z2.string().max(160).optional().describe("Project title (e.g. 'Q3 Product Launch')"),
376
+ scenes: z2.array(sceneSchema).min(1).max(12).describe("Ordered scenes; each maps to one template with its parameters"),
377
+ brandId: z2.string().uuid().optional().describe("Optional Wevi brand ID. Defaults to your latest brand.")
282
378
  },
283
379
  async (args) => {
284
380
  try {
@@ -289,12 +385,14 @@ function registerProjectTools(server2, client2) {
289
385
  type: "text",
290
386
  text: JSON.stringify(
291
387
  {
292
- message: "Project created successfully",
388
+ message: `Project created with ${project.sceneCount ?? project.scenes?.length ?? 0} scene(s).`,
293
389
  projectId: project.id,
294
390
  title: project.title,
295
391
  status: project.status,
296
392
  aspectRatio: project.aspectRatio,
297
- parameters: project.parameters
393
+ sandbox: project.sandbox === true,
394
+ scenes: project.scenes ?? [],
395
+ nextStep: project.nextStep ?? "Call wevi_trigger_render with this projectId to render and export one video."
298
396
  },
299
397
  null,
300
398
  2
@@ -313,20 +411,15 @@ function registerProjectTools(server2, client2) {
313
411
  );
314
412
  server2.tool(
315
413
  "wevi_get_project",
316
- "Inspect an existing Wevi project's details, configuration parameters, and status.",
414
+ "Inspect an existing Wevi project: scenes, layer values, lifecycle status and render state.",
317
415
  {
318
- projectId: z2.string().describe("The unique ID of the Wevi project")
416
+ projectId: z2.string().describe("Wevi project ID")
319
417
  },
320
418
  async (args) => {
321
419
  try {
322
420
  const project = await client2.getProject(args.projectId);
323
421
  return {
324
- content: [
325
- {
326
- type: "text",
327
- text: JSON.stringify(project, null, 2)
328
- }
329
- ]
422
+ content: [{ type: "text", text: JSON.stringify(project, null, 2) }]
330
423
  };
331
424
  } catch (err) {
332
425
  const msg = err instanceof Error ? err.message : String(err);
@@ -341,75 +434,137 @@ function registerProjectTools(server2, client2) {
341
434
 
342
435
  // src/tools/renders.tools.ts
343
436
  import { z as z3 } from "zod";
437
+ var DEFAULT_WAIT_SECONDS = 45;
438
+ var MAX_WAIT_SECONDS = 55;
439
+ function formatPublishStatus(status, projectId) {
440
+ const base = {
441
+ projectId,
442
+ phase: status.phase,
443
+ message: status.message,
444
+ sandbox: status.sandbox,
445
+ render: status.render ? {
446
+ state: status.render.state,
447
+ progressPercent: status.render.progressPercent,
448
+ doneScenes: status.render.doneScenes,
449
+ sceneCount: status.render.sceneCount,
450
+ failedScenes: status.render.failedScenes,
451
+ failures: status.render.failures
452
+ } : null,
453
+ export: status.export ? {
454
+ id: status.export.id,
455
+ status: status.export.status,
456
+ progress: status.export.progress,
457
+ currentStep: status.export.currentStep,
458
+ quality: status.export.quality,
459
+ errorMessage: status.export.errorMessage
460
+ } : null
461
+ };
462
+ if (status.phase === "completed") {
463
+ return {
464
+ ...base,
465
+ videoUrl: status.videoUrl,
466
+ thumbnailUrl: status.thumbnailUrl,
467
+ durationSeconds: status.export?.durationSeconds ?? null,
468
+ note: status.sandbox ? "Sandbox (wevi_test_ key): this video is watermarked and capped at 720p. Use a wevi_live_ key for production output." : void 0
469
+ };
470
+ }
471
+ if (status.phase === "rendering" || status.phase === "exporting") {
472
+ return {
473
+ ...base,
474
+ nextStep: `Still ${status.phase}. Call wevi_get_render_status with projectId "${projectId}" again in ~10 seconds.`
475
+ };
476
+ }
477
+ return base;
478
+ }
344
479
  function registerRenderTools(server2, client2) {
345
480
  server2.tool(
346
481
  "wevi_trigger_render",
347
- "Trigger high-quality video rendering for a Wevi project. Supports auto-polling so the tool can wait and return the final MP4 video link directly in one step.",
482
+ [
483
+ "Render every scene of a project and export them as ONE concatenated MP4 (music + voiceover mixed in).",
484
+ "Waits up to `waitSeconds` for completion; if the video is not ready yet it returns the current phase and you should poll wevi_get_render_status.",
485
+ "Sandbox keys (wevi_test_) produce watermarked 720p videos without using credits, capped at 20 scene renders and 5 exports per key per UTC day and 4 scenes per project."
486
+ ].join(" "),
348
487
  {
349
- projectId: z3.string().describe("The ID of the project to render"),
350
- pollUntilComplete: z3.boolean().optional().default(true).describe("If true (default), waits for the render to complete before returning the final MP4 download URL."),
351
- timeoutSeconds: z3.number().min(10).max(300).optional().default(90).describe("Maximum seconds to wait if pollUntilComplete is true (default: 90)")
488
+ projectId: z3.string().describe("Project ID from wevi_create_project"),
489
+ quality: z3.enum(["720p", "1080p", "4K"]).optional().default("1080p").describe("Export quality. Free plans and sandbox keys are capped at 720p."),
490
+ includeMusic: z3.boolean().optional().default(true).describe("Mix background music"),
491
+ includeVoiceover: z3.boolean().optional().default(true).describe("Generate and mix voiceover from each scene's voiceoverText"),
492
+ waitSeconds: z3.number().min(0).max(MAX_WAIT_SECONDS).optional().default(DEFAULT_WAIT_SECONDS).describe(`Seconds to wait in this call before returning progress (0-${MAX_WAIT_SECONDS}, default ${DEFAULT_WAIT_SECONDS})`)
352
493
  },
353
494
  async (args) => {
354
495
  try {
355
- const triggerRes = await client2.triggerRender(args.projectId);
356
- const exportId = triggerRes.exportId || triggerRes.id;
357
- if (!exportId) {
496
+ let status = await client2.publishProject(args.projectId, {
497
+ quality: args.quality,
498
+ includeMusic: args.includeMusic,
499
+ includeVoiceover: args.includeVoiceover
500
+ });
501
+ if (args.waitSeconds > 0 && (status.phase === "rendering" || status.phase === "exporting")) {
502
+ status = await client2.waitForPublish(args.projectId, args.waitSeconds);
503
+ }
504
+ const isFailure = status.phase === "failed" || status.phase === "render_failed";
505
+ return {
506
+ ...isFailure ? { isError: true } : {},
507
+ content: [
508
+ {
509
+ type: "text",
510
+ text: JSON.stringify(formatPublishStatus(status, args.projectId), null, 2)
511
+ }
512
+ ]
513
+ };
514
+ } catch (err) {
515
+ const msg = err instanceof Error ? err.message : String(err);
516
+ return {
517
+ isError: true,
518
+ content: [{ type: "text", text: `Error triggering render: ${msg}` }]
519
+ };
520
+ }
521
+ }
522
+ );
523
+ server2.tool(
524
+ "wevi_get_render_status",
525
+ [
526
+ "Check render + export progress for a project started with wevi_trigger_render and get the final video URL when ready.",
527
+ "Optionally waits up to `waitSeconds` before returning. Pass `exportId` instead to look up a single export record."
528
+ ].join(" "),
529
+ {
530
+ projectId: z3.string().optional().describe("Project ID (preferred)"),
531
+ exportId: z3.string().optional().describe("Export ID (legacy lookup of one export record)"),
532
+ waitSeconds: z3.number().min(0).max(MAX_WAIT_SECONDS).optional().default(0).describe(`Seconds to wait for completion before returning (0-${MAX_WAIT_SECONDS})`)
533
+ },
534
+ async (args) => {
535
+ try {
536
+ if (args.projectId) {
537
+ const status = args.waitSeconds > 0 ? await client2.waitForPublish(args.projectId, args.waitSeconds) : await client2.getPublishStatus(args.projectId);
538
+ const isFailure = status.phase === "failed" || status.phase === "render_failed";
358
539
  return {
540
+ ...isFailure ? { isError: true } : {},
359
541
  content: [
360
542
  {
361
543
  type: "text",
362
- text: JSON.stringify(triggerRes, null, 2)
544
+ text: JSON.stringify(formatPublishStatus(status, args.projectId), null, 2)
363
545
  }
364
546
  ]
365
547
  };
366
548
  }
367
- if (args.pollUntilComplete) {
368
- const finalStatus = await client2.pollRenderUntilComplete(
369
- exportId,
370
- args.timeoutSeconds
371
- );
372
- if (finalStatus.status === "SUCCESS") {
373
- return {
374
- content: [
375
- {
376
- type: "text",
377
- text: JSON.stringify(
378
- {
379
- status: "SUCCESS",
380
- message: "Video rendered successfully!",
381
- exportId: finalStatus.id,
382
- videoUrl: finalStatus.videoUrl,
383
- thumbnailUrl: finalStatus.thumbnailUrl,
384
- completedAt: finalStatus.completedAt
385
- },
386
- null,
387
- 2
388
- )
389
- }
390
- ]
391
- };
392
- }
393
- if (finalStatus.status === "FAILED") {
394
- return {
395
- isError: true,
396
- content: [
397
- {
398
- type: "text",
399
- text: `Render failed: ${finalStatus.errorMessage || "Unknown render error"}`
400
- }
401
- ]
402
- };
403
- }
549
+ if (args.exportId) {
550
+ const record = await client2.getRenderStatus(args.exportId);
404
551
  return {
405
552
  content: [
406
553
  {
407
554
  type: "text",
408
555
  text: JSON.stringify(
409
556
  {
410
- status: finalStatus.status,
411
- message: "Render is still processing in background. You can check status later using wevi_get_render_status.",
412
- exportId: finalStatus.id
557
+ exportId: record.id,
558
+ projectId: record.projectId,
559
+ status: record.status,
560
+ progress: record.progress ?? null,
561
+ currentStep: record.currentStep ?? null,
562
+ quality: record.quality ?? null,
563
+ sandbox: record.sandbox === true,
564
+ videoUrl: record.status === "SUCCESS" ? record.outputUrl ?? null : null,
565
+ thumbnailUrl: record.thumbnailUrl ?? null,
566
+ errorMessage: record.errorMessage ?? null,
567
+ completedAt: record.completedAt ?? null
413
568
  },
414
569
  null,
415
570
  2
@@ -419,79 +574,156 @@ function registerRenderTools(server2, client2) {
419
574
  };
420
575
  }
421
576
  return {
422
- content: [
423
- {
424
- type: "text",
425
- text: JSON.stringify(
426
- {
427
- message: "Render queued successfully",
428
- exportId,
429
- status: triggerRes.status || "QUEUED"
430
- },
431
- null,
432
- 2
433
- )
434
- }
435
- ]
577
+ isError: true,
578
+ content: [{ type: "text", text: "Provide projectId (preferred) or exportId." }]
436
579
  };
437
580
  } catch (err) {
438
581
  const msg = err instanceof Error ? err.message : String(err);
439
582
  return {
440
583
  isError: true,
441
- content: [{ type: "text", text: `Error triggering render: ${msg}` }]
584
+ content: [{ type: "text", text: `Error checking render status: ${msg}` }]
442
585
  };
443
586
  }
444
587
  }
445
588
  );
589
+ }
590
+
591
+ // src/tools/ai.tools.ts
592
+ import { z as z4 } from "zod";
593
+
594
+ // src/capabilities.ts
595
+ var WEVI_CAPABILITIES = {
596
+ summary: "Wevi turns published motion templates into short marketing videos for software products: launch videos, feature explainers, product showcases, social promos, and call-to-action outros. Each scene is a pre-designed motion template (roughly 3 to 15 seconds) whose text, colors, logos, sliders and image slots you fill in. Wevi renders every scene in the cloud and joins them into one MP4 with background music and an AI voiceover.",
597
+ canDo: [
598
+ "Multi-scene videos where every scene is a published Wevi template with your copy, brand colors and logo.",
599
+ "16:9 (1920x1080) widescreen videos and 16:12 (1920x1440) desktop-app frame videos. All scenes in one video must share one ratio.",
600
+ "AI voiceover generated from each scene's voiceoverText, mixed with a music track at export.",
601
+ "Web screenshots (wevi_capture_web_ui) used as image layers in templates that accept an image.",
602
+ "AI storyboard drafts (wevi_generate_storyboard) to plan scenes, copy and narration from a brief.",
603
+ "Exports at 720p, 1080p or 4K, quality permitting by plan."
604
+ ],
605
+ cannotDo: [
606
+ "Generate free-form or generative video: no dance videos, people, characters, animals, scenery, or anything not built from a template.",
607
+ "Upload or edit the user's own video footage, or stitch external clips.",
608
+ "Change a template's animation, layout, duration or camera motion; only its exposed layers are editable.",
609
+ "Render templates that need an interactive screen capture (requiresUiCapture = true) from the API. Those work only in the Wevi web app.",
610
+ "Mix aspect ratios in one video, or output vertical 9:16 video.",
611
+ "Clone a specific voice; voiceover uses Wevi's voice library."
612
+ ],
613
+ askBeforeBuilding: [
614
+ "What is the product or brand, and is there a website to pull colors and a logo from?",
615
+ "What is the goal of the video: launch, feature explainer, social promo, or a call to action?",
616
+ "Who is the audience and what tone fits (confident, playful, technical)?",
617
+ "How long, or how many scenes? Typical videos are 3 to 6 scenes, 15 to 45 seconds.",
618
+ "Key messages or on-screen text for each scene, if the user has them; otherwise draft with wevi_generate_storyboard and confirm.",
619
+ "Should there be a voiceover, and any brand colors (hex) to apply?",
620
+ "Which aspect ratio: 16:9 for general use, 16:12 for desktop-app focused frames."
621
+ ],
622
+ workflow: [
623
+ "1. wevi_list_templates to see what exists; group by aspect ratio and read purpose, sceneIntent and avoid to match scenes to roles (hook, feature, proof, CTA).",
624
+ "2. wevi_get_template_schema for each chosen template to get exact layer keys and constraints.",
625
+ "3. wevi_create_project with ALL scenes in one project. Never one project per scene.",
626
+ "4. wevi_trigger_render, then wevi_get_render_status until phase is completed; share videoUrl.",
627
+ "Before large batches, wevi_get_credits. Sandbox keys (wevi_test_) are free, watermarked, 720p, and capped daily."
628
+ ],
629
+ limits: {
630
+ aspectRatios: ["16:9", "16:12"],
631
+ maxScenesPerProject: 12,
632
+ sandbox: { sceneRendersPerDay: 20, exportsPerDay: 5, maxScenesPerProject: 4 },
633
+ credits: { sceneRender: 1, export720p: 1, export1080p: 2, export4K: 4, storyboardDraft: 1, webCapture: 1 }
634
+ }
635
+ };
636
+ function buildServerInstructions() {
637
+ const c = WEVI_CAPABILITIES;
638
+ return [
639
+ c.summary,
640
+ "",
641
+ "Wevi CAN: " + c.canDo.map((line) => `- ${line}`).join("\n"),
642
+ "",
643
+ "Wevi CANNOT: " + c.cannotDo.map((line) => `- ${line}`).join("\n"),
644
+ "",
645
+ "If a request is outside these capabilities (e.g. a dance video, editing the user's footage, vertical video), say so plainly and offer what Wevi can do instead. Do not attempt it.",
646
+ "",
647
+ "Before building, ask the user what is missing from: " + c.askBeforeBuilding.map((q) => `- ${q}`).join("\n"),
648
+ "",
649
+ "Workflow: " + c.workflow.map((step) => `- ${step}`).join("\n")
650
+ ].join("\n");
651
+ }
652
+
653
+ // src/tools/ai.tools.ts
654
+ function registerAiTools(server2, client2) {
655
+ server2.tool(
656
+ "wevi_get_capabilities",
657
+ [
658
+ "What Wevi can and cannot make, the questions to ask the user before building, supported aspect ratios, sandbox limits and credit costs.",
659
+ "Call this first in a new conversation, and whenever a request sounds outside Wevi's scope (e.g. 'make a dance video', 'edit my footage', 'vertical video'). No API call is made."
660
+ ].join(" "),
661
+ {},
662
+ async () => ({
663
+ content: [{ type: "text", text: JSON.stringify({ ...WEVI_CAPABILITIES, sandboxKey: client2.isSandbox }, null, 2) }]
664
+ })
665
+ );
446
666
  server2.tool(
447
- "wevi_get_render_status",
448
- "Check the real-time status and download URL of an active or completed video export.",
449
- {
450
- exportId: z3.string().describe("The unique ID of the export render")
451
- },
452
- async (args) => {
667
+ "wevi_get_credits",
668
+ [
669
+ "Show the API credit balance for this key: plan allowance used/remaining, purchased credits, what each action costs, and the top-up link.",
670
+ "Call it before a large batch, or when a tool fails with 402. Sandbox keys (wevi_test_) do not use credits."
671
+ ].join(" "),
672
+ {},
673
+ async () => {
453
674
  try {
454
- const status = await client2.getRenderStatus(args.exportId);
675
+ const balance = await client2.getCredits();
676
+ const unlimited = balance.totalAvailable === null;
455
677
  return {
456
678
  content: [
457
679
  {
458
680
  type: "text",
459
- text: JSON.stringify(status, null, 2)
681
+ text: JSON.stringify(
682
+ {
683
+ sandboxKey: client2.isSandbox,
684
+ plan: balance.plan,
685
+ purchasedCredits: balance.purchased.balance,
686
+ totalAvailable: unlimited ? "unlimited" : balance.totalAvailable,
687
+ costs: balance.costs,
688
+ packs: balance.packs.map((pack) => ({
689
+ id: pack.id,
690
+ name: pack.name,
691
+ credits: pack.credits,
692
+ price: `$${(pack.priceCents / 100).toFixed(2)}`
693
+ })),
694
+ topUpUrl: balance.topUpUrl,
695
+ note: client2.isSandbox ? "This is a sandbox key: renders are free, watermarked and capped daily. Credits shown apply to your live keys." : "A 3-scene 1080p video costs 5 credits (3 renders + 2 export)."
696
+ },
697
+ null,
698
+ 2
699
+ )
460
700
  }
461
701
  ]
462
702
  };
463
703
  } catch (err) {
464
704
  const msg = err instanceof Error ? err.message : String(err);
465
- return {
466
- isError: true,
467
- content: [{ type: "text", text: `Error checking render status: ${msg}` }]
468
- };
705
+ return { isError: true, content: [{ type: "text", text: `Error fetching credits: ${msg}` }] };
469
706
  }
470
707
  }
471
708
  );
472
- }
473
-
474
- // src/tools/ai.tools.ts
475
- import { z as z4 } from "zod";
476
- function registerAiTools(server2, client2) {
477
709
  server2.tool(
478
710
  "wevi_generate_storyboard",
479
- "Generate tailored video script, headline, and copywriting variables for a Wevi template based on a user prompt or product URL.",
711
+ [
712
+ "Draft a multi-scene storyboard (scene titles, purposes, on-screen text and voiceover) from a brief.",
713
+ "Use the draft to choose templates with wevi_list_templates and fill their parameters in wevi_create_project."
714
+ ].join(" "),
480
715
  {
481
- prompt: z4.string().describe("The marketing goal, product description, or topic for the video (e.g. '30-second SaaS promo highlighting AI-powered email writing')"),
482
- templateId: z4.string().optional().describe("Optional template ID to shape the storyboard to specific scene layers"),
483
- brandUrl: z4.string().url().optional().describe("Optional website URL to extract brand context from")
716
+ prompt: z4.string().describe("Marketing goal, product description, audience and tone (e.g. '30-second SaaS promo for an AI email writer')"),
717
+ brandName: z4.string().optional().describe("Brand or product name"),
718
+ brandUrl: z4.string().url().optional().describe("Website URL used for brand context"),
719
+ brandVoice: z4.string().optional().describe("Tone of voice (e.g. 'confident, modern, energetic')"),
720
+ maxScenes: z4.number().int().min(3).max(12).optional().describe("Soft cap on scene count (3-12)")
484
721
  },
485
722
  async (args) => {
486
723
  try {
487
724
  const result = await client2.generateStoryboard(args);
488
725
  return {
489
- content: [
490
- {
491
- type: "text",
492
- text: JSON.stringify(result, null, 2)
493
- }
494
- ]
726
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
495
727
  };
496
728
  } catch (err) {
497
729
  const msg = err instanceof Error ? err.message : String(err);
@@ -559,10 +791,22 @@ var client = new WeviApiClient({
559
791
  apiKey: apiKey || "",
560
792
  apiUrl
561
793
  });
562
- var server = new McpServer({
563
- name: "wevi",
564
- version: "0.1.0"
565
- });
794
+ if (client.isSandbox) {
795
+ process.stderr.write(
796
+ "[Wevi MCP] Sandbox key detected (wevi_test_): renders are watermarked, capped at 720p and do not use credits.\n"
797
+ );
798
+ }
799
+ var server = new McpServer(
800
+ {
801
+ name: "wevi",
802
+ version: "0.2.1"
803
+ },
804
+ {
805
+ // Sent to the client on connect; assistants that honour MCP instructions
806
+ // learn what Wevi can and cannot make before the first tool call.
807
+ instructions: buildServerInstructions()
808
+ }
809
+ );
566
810
  registerAllTools(server, client);
567
811
  async function main() {
568
812
  const transport = new StdioServerTransport();