@mixio-pro/kalaasetu-mcp 2.0.4-beta → 2.0.7-beta

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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mixio-pro/kalaasetu-mcp",
3
- "version": "2.0.4-beta",
3
+ "version": "2.0.7-beta",
4
4
  "description": "A powerful Model Context Protocol server providing AI tools for content generation and analysis",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
7
7
  "main": "src/index.ts",
8
8
  "bin": {
9
- "kalaasetu-mcp": "./bin/cli.js"
9
+ "kalaasetu-mcp": "bin/cli.js"
10
10
  },
11
11
  "files": [
12
12
  "src",
@@ -35,7 +35,7 @@
35
35
  "license": "MIT",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "https://github.com/mixiopro/kalaasetu-mcp.git"
38
+ "url": "git+https://github.com/mixiopro/kalaasetu-mcp.git"
39
39
  },
40
40
  "bugs": {
41
41
  "url": "https://github.com/mixiopro/kalaasetu-mcp/issues"
@@ -58,4 +58,4 @@
58
58
  "wav": "^1.0.2",
59
59
  "zod": "^4.1.12"
60
60
  }
61
- }
61
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
  import { FastMCP } from "fastmcp";
3
3
  import pkg from "../package.json";
4
- import { geminiEditImage, geminiTextToImage } from "./tools/gemini";
5
- import { imageToVideo } from "./tools/image-to-video";
6
4
  import {
7
5
  falListPresets,
8
6
  falGetPresetDetails,
9
- falGenerate,
10
7
  falUploadFile,
11
8
  } from "./tools/fal";
9
+ import { createAllFalTools } from "./tools/fal/dynamic-tools";
10
+ import { geminiEditImage, geminiTextToImage } from "./tools/gemini";
11
+ import { imageToVideo } from "./tools/image-to-video";
12
+ import { getGenerationStatus } from "./tools/get-status";
12
13
 
13
14
  const server = new FastMCP({
14
15
  name: "Kalaasetu MCP Server",
@@ -36,13 +37,17 @@ server.addTool(imageToVideo);
36
37
  // server.addTool(perplexityImages);
37
38
  // server.addTool(perplexityVideos);
38
39
 
39
- import { getGenerationStatus } from "./tools/get-status";
40
-
41
- // Fal AI Tools
40
+ // Discovery and Utility Tools
42
41
  server.addTool(falListPresets);
43
- server.addTool(falGenerate);
42
+ server.addTool(falGetPresetDetails);
44
43
  server.addTool(falUploadFile);
45
44
 
45
+ // Dynamic FAL AI Tools - each preset becomes a separate tool (prefixed with fal_)
46
+ const falTools = createAllFalTools();
47
+ for (const tool of falTools) {
48
+ server.addTool(tool);
49
+ }
50
+
46
51
  // Unified Status Tool (works with both FAL and Vertex AI)
47
52
  server.addTool(getGenerationStatus);
48
53
 
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Dynamic FAL Tools Generator
3
+ *
4
+ * Creates MCP tools dynamically from preset configurations.
5
+ * Each preset becomes a first-class MCP tool with its own Zod schema.
6
+ */
7
+
8
+ import { z, type ZodTypeAny } from "zod";
9
+ import { safeToolExecute } from "../../utils/tool-wrapper";
10
+ import { resolveEnhancer } from "../../utils/prompt-enhancer-presets";
11
+ import { sanitizeResponse } from "../../utils/sanitize";
12
+ import {
13
+ FAL_QUEUE_URL,
14
+ AUTHENTICATED_TIMEOUT,
15
+ getApiKey,
16
+ loadFalConfig,
17
+ type FalPresetConfig,
18
+ } from "./config";
19
+
20
+ /**
21
+ * Helper to wait for a specified duration.
22
+ */
23
+ async function wait(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ /**
28
+ * Make an authenticated request to fal.ai API.
29
+ */
30
+ async function authenticatedRequest(
31
+ url: string,
32
+ method: "GET" | "POST" | "PUT" = "GET",
33
+ jsonData?: Record<string, any>
34
+ ): Promise<any> {
35
+ const headers: Record<string, string> = {
36
+ Authorization: `Key ${getApiKey()}`,
37
+ "Content-Type": "application/json",
38
+ };
39
+
40
+ const options: RequestInit = {
41
+ method,
42
+ headers,
43
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
44
+ };
45
+
46
+ if (jsonData && (method === "POST" || method === "PUT")) {
47
+ options.body = JSON.stringify(jsonData);
48
+ }
49
+
50
+ const response = await fetch(url, options);
51
+
52
+ if (!response.ok) {
53
+ const errorText = await response.text();
54
+ if (response.status === 404) {
55
+ return {
56
+ status: "IN_PROGRESS_WAITING",
57
+ detail: "Request not yet available in queue.",
58
+ };
59
+ }
60
+ throw new Error(`[${response.status}] API error: ${errorText}`);
61
+ }
62
+
63
+ return response.json();
64
+ }
65
+
66
+ /**
67
+ * Sanitize parameters by removing null/undefined values.
68
+ */
69
+ function sanitizeParameters(
70
+ parameters: Record<string, any>
71
+ ): Record<string, any> {
72
+ return Object.fromEntries(
73
+ Object.entries(parameters).filter(([_, v]) => v != null)
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Progress reporter interface for MCP context compatibility.
79
+ */
80
+ interface ProgressContext {
81
+ reportProgress?: (progress: {
82
+ progress: number;
83
+ total: number;
84
+ }) => Promise<void>;
85
+ streamContent?: (
86
+ content: { type: "text"; text: string } | { type: "text"; text: string }[]
87
+ ) => Promise<void>;
88
+ log?: {
89
+ info: (message: string, data?: any) => void;
90
+ debug: (message: string, data?: any) => void;
91
+ };
92
+ }
93
+
94
+
95
+ /**
96
+ * Build a Zod object schema from preset input_schema using built-in z.fromJSONSchema.
97
+ */
98
+ function buildZodSchema(
99
+ inputSchema: Record<string, any> | undefined,
100
+ defaultParams?: Record<string, any>
101
+ ): z.ZodObject<any> {
102
+ // Construct a properties object for JSON schema
103
+ const properties: Record<string, any> = {};
104
+
105
+ if (inputSchema) {
106
+ for (const [key, schema] of Object.entries(inputSchema)) {
107
+ properties[key] = { ...schema };
108
+ // Override default if specified in defaultParams
109
+ if (defaultParams && defaultParams[key] !== undefined) {
110
+ properties[key].default = defaultParams[key];
111
+ }
112
+ }
113
+ }
114
+
115
+ // Create full JSON schema object
116
+ const jsonSchema = {
117
+ type: "object",
118
+ properties,
119
+ };
120
+
121
+ // Convert to Zod using Zod 4's fromJSONSchema (experimental)
122
+ let zodSchema = (z as any).fromJSONSchema(jsonSchema) as z.ZodObject<any>;
123
+
124
+ // Always add resume_endpoint for resumable operations
125
+ zodSchema = zodSchema.extend({
126
+ resume_endpoint: z
127
+ .string()
128
+ .optional()
129
+ .describe(
130
+ "If provided, resume polling for an existing request instead of starting a new one. " +
131
+ "Use the 'resume_endpoint' returned in an 'IN_PROGRESS' response."
132
+ ),
133
+ auto_enhance: z
134
+ .boolean()
135
+ .default(true) // Our custom default
136
+ .describe(
137
+ "Whether to automatically enhance the prompt. Set to false to disable."
138
+ ),
139
+ });
140
+
141
+ return zodSchema;
142
+ }
143
+
144
+ /**
145
+ * Create an MCP tool from a preset configuration.
146
+ */
147
+ export function createToolFromPreset(preset: FalPresetConfig) {
148
+ const zodSchema = buildZodSchema(
149
+ preset.input_schema as Record<string, any>,
150
+ preset.defaultParams
151
+ );
152
+
153
+ const toolName = preset.presetName.startsWith("fal_")
154
+ ? preset.presetName
155
+ : `fal_${preset.presetName}`;
156
+
157
+ return {
158
+ name: toolName,
159
+ description:
160
+ preset.intent +
161
+ (preset.description ? ` ${preset.description}` : "") +
162
+ ` [Model: ${preset.modelId}]`,
163
+ parameters: zodSchema,
164
+ timeoutMs: 90000, // 90 seconds MCP timeout
165
+
166
+ execute: async (
167
+ args: Record<string, any>,
168
+ context?: ProgressContext
169
+ ) => {
170
+ return safeToolExecute(async () => {
171
+ let statusUrl: string;
172
+ let responseUrl: string;
173
+ let requestId: string;
174
+
175
+ // Handle resume flow
176
+ if (args.resume_endpoint) {
177
+ if (args.resume_endpoint.startsWith("http")) {
178
+ statusUrl = args.resume_endpoint;
179
+ responseUrl = args.resume_endpoint.replace(/\/status$/, "");
180
+ const urlParts = args.resume_endpoint.split("/");
181
+ const lastPart = urlParts[urlParts.length - 1] || "";
182
+ requestId =
183
+ lastPart.replace("/status", "") ||
184
+ urlParts[urlParts.length - 2] ||
185
+ "unknown";
186
+ context?.log?.info(`Resuming with FAL URL: ${args.resume_endpoint}`);
187
+ } else {
188
+ // Legacy UUID format - reconstruct URL
189
+ requestId = args.resume_endpoint;
190
+ statusUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}/status`;
191
+ responseUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}`;
192
+ context?.log?.info(
193
+ `Resuming polling for ${preset.modelId} request: ${requestId}`
194
+ );
195
+ }
196
+ } else {
197
+ // New request - start generation
198
+ try {
199
+ const apiKey = getApiKey();
200
+ if (context?.streamContent) {
201
+ await context.streamContent({
202
+ type: "text" as const,
203
+ text: `[FAL] ✓ API key found (${apiKey.slice(0, 8)}...). Using model: ${preset.modelId}`,
204
+ });
205
+ }
206
+ } catch (keyError: any) {
207
+ throw keyError;
208
+ }
209
+
210
+ // Build parameters: input_schema defaults → defaultParams → user args
211
+ // Extract only the model parameters (exclude our internal fields)
212
+ const { resume_endpoint, auto_enhance, ...userParams } = args;
213
+
214
+ // Start with defaults from input_schema
215
+ const schemaDefaults: Record<string, any> = {};
216
+ if (preset.input_schema) {
217
+ for (const [key, paramSchema] of Object.entries(preset.input_schema)) {
218
+ if ((paramSchema as any).default !== undefined) {
219
+ schemaDefaults[key] = (paramSchema as any).default;
220
+ }
221
+ }
222
+ }
223
+
224
+ // Merge: schema defaults → preset defaultParams → user params
225
+ const mergedParams = {
226
+ ...schemaDefaults,
227
+ ...(preset.defaultParams || {}),
228
+ ...userParams,
229
+ };
230
+
231
+ // Apply prompt enhancement if enabled
232
+ const shouldEnhance = auto_enhance !== false;
233
+ if (shouldEnhance && preset.promptEnhancer && mergedParams.prompt) {
234
+ const enhancerName =
235
+ typeof preset.promptEnhancer === "string"
236
+ ? preset.promptEnhancer
237
+ : null;
238
+
239
+ if (enhancerName === "ltx2") {
240
+ const { enhancePromptWithLLM, isLLMEnhancerAvailable } =
241
+ await import("../../utils/llm-prompt-enhancer");
242
+ if (isLLMEnhancerAvailable()) {
243
+ try {
244
+ const originalPrompt = mergedParams.prompt;
245
+ mergedParams.prompt = await enhancePromptWithLLM(
246
+ mergedParams.prompt,
247
+ "ltx2"
248
+ );
249
+ context?.log?.info(
250
+ `LLM-enhanced prompt: "${originalPrompt}" → "${mergedParams.prompt}"`
251
+ );
252
+ } catch (err) {
253
+ context?.log?.info(
254
+ `LLM enhancement failed, using original prompt`
255
+ );
256
+ }
257
+ }
258
+ } else if (preset.promptEnhancer) {
259
+ const enhancer = resolveEnhancer(preset.promptEnhancer);
260
+ if (enhancer.hasTransformations()) {
261
+ mergedParams.prompt = enhancer.enhance(mergedParams.prompt);
262
+ const negatives = enhancer.getNegativeElements();
263
+ if (negatives && !mergedParams.negative_prompt) {
264
+ mergedParams.negative_prompt = negatives;
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ const sanitizedParams = sanitizeParameters(mergedParams);
271
+ const url = `${FAL_QUEUE_URL}/${preset.modelId}`;
272
+
273
+ if (context?.streamContent) {
274
+ await context.streamContent({
275
+ type: "text" as const,
276
+ text: `[FAL] Submitting generation request to ${preset.modelId}...`,
277
+ });
278
+ }
279
+
280
+ const queueRes = await authenticatedRequest(
281
+ url,
282
+ "POST",
283
+ sanitizedParams
284
+ );
285
+
286
+ if (!queueRes.request_id && !queueRes.status_url) {
287
+ return JSON.stringify(sanitizeResponse(queueRes));
288
+ }
289
+
290
+ requestId =
291
+ queueRes.request_id || queueRes.status_url?.split("/").pop() || "";
292
+
293
+ if (!requestId) {
294
+ throw new Error("Could not extract request ID from response");
295
+ }
296
+
297
+ statusUrl =
298
+ queueRes.status_url ||
299
+ `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}/status`;
300
+ responseUrl =
301
+ queueRes.response_url ||
302
+ `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}`;
303
+
304
+ if (context?.streamContent) {
305
+ await context.streamContent({
306
+ type: "text" as const,
307
+ text: `[FAL] Generation started. resume_endpoint: ${statusUrl}`,
308
+ });
309
+ }
310
+ }
311
+
312
+ // Stream message for resume calls
313
+ if (args.resume_endpoint && context?.streamContent) {
314
+ await context.streamContent({
315
+ type: "text" as const,
316
+ text: `[FAL] Resuming status check for job: ${requestId}`,
317
+ });
318
+ }
319
+
320
+ // Poll for completion
321
+ const startTime = Date.now();
322
+ const MAX_POLL_TIME = 60000; // 60 seconds internal timeout
323
+ let pollCount = 0;
324
+ const POLL_INTERVAL = 3000;
325
+
326
+ while (Date.now() - startTime < MAX_POLL_TIME) {
327
+ pollCount++;
328
+ let res;
329
+ try {
330
+ res = await authenticatedRequest(statusUrl, "GET");
331
+ } catch (e: any) {
332
+ if (`${e}`.includes("405")) {
333
+ context?.log?.info(
334
+ `Status check 405 on ${statusUrl}, trying fallback...`
335
+ );
336
+ res = await authenticatedRequest(responseUrl, "GET");
337
+ statusUrl = responseUrl;
338
+ } else {
339
+ throw e;
340
+ }
341
+ }
342
+
343
+ if (res.status_url) statusUrl = res.status_url;
344
+ if (res.response_url) responseUrl = res.response_url;
345
+
346
+ if (context?.reportProgress) {
347
+ const elapsed = Date.now() - startTime;
348
+ const progressPercent = Math.min(
349
+ Math.round((elapsed / MAX_POLL_TIME) * 100),
350
+ 99
351
+ );
352
+ await context.reportProgress({
353
+ progress: progressPercent,
354
+ total: 100,
355
+ });
356
+ }
357
+
358
+ if (context?.streamContent && pollCount % 5 === 0) {
359
+ await context.streamContent({
360
+ type: "text" as const,
361
+ text: `[FAL] Still processing... (${Math.round(
362
+ (Date.now() - startTime) / 1000
363
+ )}s elapsed, status: ${res.status})`,
364
+ });
365
+ }
366
+
367
+ if (res.status === "COMPLETED") {
368
+ if (context?.reportProgress) {
369
+ await context.reportProgress({ progress: 100, total: 100 });
370
+ }
371
+ const finalResult = await authenticatedRequest(responseUrl, "GET");
372
+ return JSON.stringify(sanitizeResponse(finalResult));
373
+ }
374
+
375
+ if (res.status === "FAILED") {
376
+ throw new Error(
377
+ `Generation failed: ${JSON.stringify(res.error || res)}`
378
+ );
379
+ }
380
+
381
+ await wait(POLL_INTERVAL);
382
+ }
383
+
384
+ // Timeout - return resume_endpoint
385
+ return JSON.stringify({
386
+ status: "IN_PROGRESS",
387
+ request_id: requestId,
388
+ resume_endpoint: statusUrl,
389
+ status_url: statusUrl,
390
+ response_url: responseUrl,
391
+ message:
392
+ "The generation is still in progress. Call this tool again with resume_endpoint to continue polling.",
393
+ });
394
+ }, preset.presetName);
395
+ },
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Create all FAL tools from the configuration.
401
+ * Each preset becomes a separate MCP tool.
402
+ */
403
+ export function createAllFalTools(): ReturnType<typeof createToolFromPreset>[] {
404
+ const config = loadFalConfig();
405
+ return config.presets.map((preset) => createToolFromPreset(preset));
406
+ }
@@ -92,7 +92,7 @@ export const falGenerate = {
92
92
  description:
93
93
  "The primary tool for generating AI content (images, videos, etc.) using fal.ai. " +
94
94
  "This tool handles polling internally and streams progress updates to the client. " +
95
- "If the generation takes too long (timeout or error), it returns a 'resume_id' that you can use to resume polling. " +
95
+ "If the generation takes too long (timeout or error), it returns a 'resume_endpoint' that you can use to resume polling. " +
96
96
  "Use 'fal_list_presets' to discover available intents and names. " +
97
97
  "PREREQUISITE: If using local files as parameters, you MUST upload them first using 'fal_upload_file' and use the resulting CDN URL. " +
98
98
  "ONLY USE WHEN WORKING WITH FAL MODELS/PRESETS.",
@@ -105,18 +105,17 @@ export const falGenerate = {
105
105
  ),
106
106
  parameters: z
107
107
  .record(z.string(), z.any())
108
- .optional()
109
108
  .describe(
110
109
  "A dictionary of model-specific parameters (e.g., { 'prompt': '...', 'image_url': '...' }). " +
111
110
  "These override the default values defined in the preset. " +
112
111
  "NOTE: For image-to-video or video-to-video tasks, use 'fal_upload_file' first and pass the resulting CDN URL here."
113
112
  ),
114
- resume_id: z
113
+ resume_endpoint: z
115
114
  .string()
116
115
  .optional()
117
116
  .describe(
118
117
  "If provided, the tool will resume polling for an existing request instead of starting a new one. " +
119
- "Use the 'request_id' returned in an 'IN_PROGRESS' response or after a timeout error."
118
+ "Use the 'resume_endpoint' returned in an 'IN_PROGRESS' response or after a timeout error."
120
119
  ),
121
120
  }),
122
121
  timeoutMs: 90000, // 90 seconds MCP timeout (internal timeout is 60s)
@@ -124,7 +123,7 @@ export const falGenerate = {
124
123
  args: {
125
124
  preset_name?: string;
126
125
  parameters?: Record<string, any>;
127
- resume_id?: string;
126
+ resume_endpoint?: string;
128
127
  auto_enhance?: boolean;
129
128
  },
130
129
  context?: ProgressContext
@@ -135,21 +134,21 @@ export const falGenerate = {
135
134
  let requestId: string;
136
135
  const config = loadFalConfig();
137
136
 
138
- if (args.resume_id) {
139
- // Check if resume_id is a full URL (new format) or legacy ID
140
- if (args.resume_id.startsWith("http")) {
141
- // NEW: resume_id IS the status/response URL
142
- statusUrl = args.resume_id;
137
+ if (args.resume_endpoint) {
138
+ // Check if resume_endpoint is a full URL (new format) or legacy ID
139
+ if (args.resume_endpoint.startsWith("http")) {
140
+ // NEW: resume_endpoint IS the status/response URL
141
+ statusUrl = args.resume_endpoint;
143
142
  // Derive responseUrl by removing /status suffix if present
144
- responseUrl = args.resume_id.replace(/\/status$/, "");
143
+ responseUrl = args.resume_endpoint.replace(/\/status$/, "");
145
144
  // Extract requestId from URL for logging
146
- const urlParts = args.resume_id.split("/");
145
+ const urlParts = args.resume_endpoint.split("/");
147
146
  const lastPart = urlParts[urlParts.length - 1] || "";
148
147
  requestId =
149
148
  lastPart.replace("/status", "") ||
150
149
  urlParts[urlParts.length - 2] ||
151
150
  "unknown";
152
- context?.log?.info(`Resuming with FAL URL: ${args.resume_id}`);
151
+ context?.log?.info(`Resuming with FAL URL: ${args.resume_endpoint}`);
153
152
  } else {
154
153
  // LEGACY: Try to resolve model from preset_name or parse modelId::requestId
155
154
  let modelIdFromPreset: string | undefined;
@@ -165,8 +164,8 @@ export const falGenerate = {
165
164
  }
166
165
  }
167
166
 
168
- if (args.resume_id.includes("::")) {
169
- const parts = args.resume_id.split("::");
167
+ if (args.resume_endpoint.includes("::")) {
168
+ const parts = args.resume_endpoint.split("::");
170
169
  const mId = parts[0];
171
170
  const rId = parts[1] || "";
172
171
 
@@ -181,7 +180,7 @@ export const falGenerate = {
181
180
  );
182
181
  } else {
183
182
  // Legacy/Fallback for raw UUIDs
184
- requestId = args.resume_id;
183
+ requestId = args.resume_endpoint;
185
184
 
186
185
  if (modelIdFromPreset) {
187
186
  // Best case: User provided the preset name!
@@ -192,8 +191,8 @@ export const falGenerate = {
192
191
  );
193
192
  } else {
194
193
  // Worst case: No preset, no model in ID. Try legacy generic URL
195
- statusUrl = `${FAL_QUEUE_URL}/requests/${args.resume_id}/status`;
196
- responseUrl = `${FAL_QUEUE_URL}/requests/${args.resume_id}`;
194
+ statusUrl = `${FAL_QUEUE_URL}/requests/${args.resume_endpoint}/status`;
195
+ responseUrl = `${FAL_QUEUE_URL}/requests/${args.resume_endpoint}`;
197
196
 
198
197
  // Verify/Recovery: Check if generic URL works, if not try to guess model
199
198
  // ... (Smart recovery logic below)
@@ -230,7 +229,7 @@ export const falGenerate = {
230
229
  }
231
230
  }
232
231
  context?.log?.info(
233
- `Resuming polling for request: ${args.resume_id}`
232
+ `Resuming polling for request: ${args.resume_endpoint}`
234
233
  );
235
234
  }
236
235
  } // Close the LEGACY else block (line 149)
@@ -348,13 +347,13 @@ export const falGenerate = {
348
347
  if (context?.streamContent) {
349
348
  await context.streamContent({
350
349
  type: "text" as const,
351
- text: `[FAL] Generation started. resume_id: ${statusUrl} (use this URL to check status)`,
350
+ text: `[FAL] Generation started. resume_endpoint: ${statusUrl} (use this URL to check status)`,
352
351
  });
353
352
  }
354
353
  }
355
354
 
356
355
  // Stream message for resume calls
357
- if (args.resume_id && context?.streamContent) {
356
+ if (args.resume_endpoint && context?.streamContent) {
358
357
  await context.streamContent({
359
358
  type: "text" as const,
360
359
  text: `[FAL] Resuming status check for job: ${requestId}`,
@@ -362,7 +361,7 @@ export const falGenerate = {
362
361
  }
363
362
 
364
363
  const startTime = Date.now();
365
- const MAX_POLL_TIME = 60000; // 60 seconds internal timeout - then return resume_id
364
+ const MAX_POLL_TIME = 60000; // 60 seconds internal timeout - then return resume_endpoint
366
365
  let pollCount = 0;
367
366
  const POLL_INTERVAL = 3000;
368
367
 
@@ -433,12 +432,12 @@ export const falGenerate = {
433
432
  await wait(POLL_INTERVAL);
434
433
  }
435
434
 
436
- // Timeout - return composite resume_id
435
+ // Timeout - return composite resume_endpoint
437
436
  // We need to know modelId here. If we started new, we have 'preset'
438
437
  // If we resumed, we parsed 'mId' or used raw.
439
438
  const currentModelId =
440
- args.resume_id && args.resume_id.includes("::")
441
- ? args.resume_id.split("::")[0]
439
+ args.resume_endpoint && args.resume_endpoint.includes("::")
440
+ ? args.resume_endpoint.split("::")[0]
442
441
  : args.preset_name
443
442
  ? config.presets.find((p) => p.presetName === args.preset_name)
444
443
  ?.modelId
@@ -447,11 +446,11 @@ export const falGenerate = {
447
446
  return JSON.stringify({
448
447
  status: "IN_PROGRESS",
449
448
  request_id: requestId,
450
- resume_id: statusUrl, // Use the FULL URL for reliable resume
449
+ resume_endpoint: statusUrl, // Use the FULL URL for reliable resume
451
450
  status_url: statusUrl,
452
451
  response_url: responseUrl,
453
452
  message:
454
- "The generation is still in progress. Call this tool again with resume_id (the URL) to continue polling.",
453
+ "The generation is still in progress. Call this tool again with resume_endpoint (the URL) to continue polling.",
455
454
  });
456
455
  }, "fal_generate");
457
456
  },
@@ -72,7 +72,9 @@ export const falListPresets = {
72
72
 
73
73
  // Fetch schemas for presets that don't have them (or if refresh requested)
74
74
  for (const preset of config.presets) {
75
- const shouldFetch = !preset.input_schema || args.refresh_schemas;
75
+ const hasSchema =
76
+ preset.input_schema && Object.keys(preset.input_schema).length > 0;
77
+ const shouldFetch = !hasSchema || args.refresh_schemas;
76
78
  console.log(
77
79
  `[fal_list_presets] ${
78
80
  preset.presetName
@@ -6,13 +6,27 @@
6
6
  import { z } from "zod";
7
7
  import { safeToolExecute } from "../utils/tool-wrapper";
8
8
  import { getGoogleAccessToken } from "../utils/google-auth";
9
+ import { sanitizeResponse } from "../utils/sanitize";
9
10
 
10
11
  const FAL_KEY = process.env.FAL_KEY;
11
12
 
12
13
  /**
13
14
  * Check FAL generation status using the status URL
14
15
  */
15
- async function checkFalStatus(statusUrl: string): Promise<any> {
16
+ interface VertexOperation {
17
+ done?: boolean;
18
+ response?: {
19
+ videos?: Array<{
20
+ bytesBase64Encoded?: string;
21
+ }>;
22
+ saved_videos?: any[];
23
+ [key: string]: any;
24
+ };
25
+ error?: any;
26
+ [key: string]: any;
27
+ }
28
+
29
+ export async function checkFalStatus(statusUrl: string): Promise<any> {
16
30
  if (!FAL_KEY) {
17
31
  throw new Error("FAL_KEY environment variable not set");
18
32
  }
@@ -30,45 +44,52 @@ async function checkFalStatus(statusUrl: string): Promise<any> {
30
44
  throw new Error(`FAL API error [${response.status}]: ${errorText}`);
31
45
  }
32
46
 
33
- const statusResult = (await response.json()) as { status?: string };
34
-
35
- if (statusResult.status === "COMPLETED") {
36
- // Fetch the actual result
37
- const responseUrl = statusUrl.replace(/\/status$/, "");
38
- const resultResponse = await fetch(responseUrl, {
39
- method: "GET",
40
- headers: {
41
- Authorization: `Key ${FAL_KEY}`,
42
- "Content-Type": "application/json",
43
- },
44
- });
45
-
46
- if (resultResponse.ok) {
47
- return await resultResponse.json();
48
- }
47
+ return await response.json();
48
+ }
49
+
50
+ export async function fetchFalResult(responseUrl: string): Promise<any> {
51
+ if (!FAL_KEY) {
52
+ throw new Error("FAL_KEY environment variable not set");
53
+ }
54
+
55
+ const response = await fetch(responseUrl, {
56
+ method: "GET",
57
+ headers: {
58
+ Authorization: `Key ${FAL_KEY}`,
59
+ "Content-Type": "application/json",
60
+ },
61
+ });
62
+
63
+ if (!response.ok) {
64
+ const errorText = await response.text();
65
+ throw new Error(`FAL API error [${response.status}]: ${errorText}`);
49
66
  }
50
67
 
51
- return statusResult;
68
+ return await response.json();
52
69
  }
53
70
 
54
- /**
55
- * Check Vertex AI operation status
56
- */
57
- async function checkVertexStatus(
58
- operationName: string,
59
- projectId: string,
60
- locationId: string
61
- ): Promise<any> {
71
+ export async function checkVertexStatus(resumeEndpoint: string): Promise<any> {
62
72
  const accessToken = await getGoogleAccessToken();
63
73
 
64
- const operationsUrl = `https://${locationId}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${locationId}/publishers/google/models/veo-3.1-generate-preview/operations/${operationName}`;
74
+ // resumeEndpoint is composite format: fetchUrl||operationName||outputPath
75
+ const parts = resumeEndpoint.split("||");
76
+ const fetchUrl = parts[0] || "";
77
+ const operationName = parts[1] || "";
78
+ const outputPath = parts[2] || ""; // Optional custom output path
65
79
 
66
- const response = await fetch(operationsUrl, {
67
- method: "GET",
80
+ if (!fetchUrl || !operationName) {
81
+ throw new Error(
82
+ "Invalid Vertex resume_endpoint format. Expected 'fetchUrl||operationName[||outputPath]'.",
83
+ );
84
+ }
85
+
86
+ const response = await fetch(fetchUrl, {
87
+ method: "POST",
68
88
  headers: {
69
89
  Authorization: `Bearer ${accessToken}`,
70
90
  "Content-Type": "application/json",
71
91
  },
92
+ body: JSON.stringify({ operationName }),
72
93
  });
73
94
 
74
95
  if (!response.ok) {
@@ -76,64 +97,91 @@ async function checkVertexStatus(
76
97
  throw new Error(`Vertex AI API error [${response.status}]: ${errorText}`);
77
98
  }
78
99
 
79
- return await response.json();
100
+ const result = (await response.json()) as VertexOperation;
101
+
102
+ // If completed, save videos if present
103
+ const done = !!result.done || !!result.response;
104
+ if (done) {
105
+ const resp = result.response || result;
106
+ if (Array.isArray(resp?.videos) && resp.videos.length > 0) {
107
+ const { getStorage } = await import("../storage");
108
+ const { generateTimestampedFilename } = await import("../utils/filename");
109
+ const storage = getStorage();
110
+ const savedVideos: any[] = [];
111
+
112
+ for (let i = 0; i < resp.videos.length; i++) {
113
+ const v = resp.videos[i];
114
+ if (v?.bytesBase64Encoded) {
115
+ let filePath: string;
116
+ if (outputPath) {
117
+ // Use custom path, add index for subsequent videos
118
+ filePath =
119
+ i === 0 ? outputPath : outputPath.replace(/\.mp4$/i, `_${i}.mp4`);
120
+ } else {
121
+ // Default timestamped filename
122
+ filePath = generateTimestampedFilename(`video_output_${i}.mp4`);
123
+ }
124
+ const buf = Buffer.from(v.bytesBase64Encoded, "base64");
125
+ const url = await storage.writeFile(filePath, buf);
126
+ savedVideos.push({
127
+ url,
128
+ filename: filePath,
129
+ mimeType: "video/mp4",
130
+ });
131
+ // CRITICAL: Remove base64 data to prevent context window poisoning
132
+ delete v.bytesBase64Encoded;
133
+ }
134
+ }
135
+
136
+ if (savedVideos.length > 0) {
137
+ resp.saved_videos = savedVideos;
138
+ }
139
+ }
140
+ }
141
+
142
+ return result;
80
143
  }
81
144
 
82
145
  export const getGenerationStatus = {
83
146
  name: "get_generation_status",
84
147
  description:
85
148
  "Check the status or retrieve the result of a generation operation that was started by 'fal_generate' or 'generateVideoi2v'. " +
86
- "Use this when the original generation tool returned an 'IN_PROGRESS' status with a 'resume_id'. " +
87
- "Pass the resume_id exactly as it was returned. " +
88
- "For FAL operations, the resume_id is a full URL. " +
89
- "For Vertex AI operations, the resume_id is an operation name.",
149
+ "Use this when the original generation tool returned an 'IN_PROGRESS' status with a 'resume_endpoint'. " +
150
+ "Pass the resume_endpoint exactly as it was returned. " +
151
+ "For FAL operations, the resume_endpoint is a full URL. " +
152
+ "For Vertex AI operations, the resume_endpoint is an operation name or full path.",
90
153
  parameters: z.object({
91
- resume_id: z
154
+ resume_endpoint: z
92
155
  .string()
93
156
  .describe(
94
- "The resume_id returned by the original generation tool. " +
157
+ "The resume_endpoint returned by the original generation tool. " +
95
158
  "For FAL: This is a full URL (starts with 'https://queue.fal.run/...'). " +
96
- "For Vertex AI: This is an operation name."
159
+ "For Vertex AI: This is an operation name or full path.",
97
160
  ),
98
161
  source: z
99
162
  .enum(["fal", "vertex", "auto"])
100
163
  .optional()
101
164
  .default("auto")
102
165
  .describe(
103
- "Source of the operation: 'fal' for FAL AI, 'vertex' for Google Vertex AI, or 'auto' to auto-detect based on resume_id format."
166
+ "Source of the operation: 'fal' for FAL AI, 'vertex' for Google Vertex AI, or 'auto' to auto-detect based on resume_endpoint format.",
104
167
  ),
105
- project_id: z
106
- .string()
107
- .optional()
108
- .default("mixio-pro")
109
- .describe("GCP Project ID (only needed for Vertex AI operations)."),
110
- location_id: z
111
- .string()
112
- .optional()
113
- .default("us-central1")
114
- .describe("GCP region (only needed for Vertex AI operations)."),
115
168
  }),
116
169
  timeoutMs: 30000, // 30 seconds for status check
117
170
  execute: async (args: {
118
- resume_id: string;
171
+ resume_endpoint: string;
119
172
  source?: "fal" | "vertex" | "auto";
120
- project_id?: string;
121
- location_id?: string;
122
173
  }) => {
123
174
  return safeToolExecute(async () => {
124
- const {
125
- resume_id,
126
- source = "auto",
127
- project_id = "mixio-pro",
128
- location_id = "us-central1",
129
- } = args;
130
-
131
- // Auto-detect source based on resume_id format
175
+ const { resume_endpoint, source = "auto" } = args;
176
+ const project_id = "mixio-pro";
177
+ const location_id = "us-central1";
178
+
179
+ // Auto-detect source based on resume_endpoint format
132
180
  let detectedSource = source;
133
181
  if (source === "auto") {
134
182
  if (
135
- resume_id.startsWith("https://queue.fal.run") ||
136
- resume_id.startsWith("https://fal.run")
183
+ resume_endpoint.startsWith("https://queue.fal.run") ||
184
+ resume_endpoint.startsWith("https://fal.run")
137
185
  ) {
138
186
  detectedSource = "fal";
139
187
  } else {
@@ -144,30 +192,33 @@ export const getGenerationStatus = {
144
192
  let result: any;
145
193
 
146
194
  if (detectedSource === "fal") {
147
- result = await checkFalStatus(resume_id);
195
+ result = await checkFalStatus(resume_endpoint);
148
196
  } else {
149
- result = await checkVertexStatus(resume_id, project_id, location_id);
197
+ result = await checkVertexStatus(resume_endpoint);
150
198
  }
151
199
 
152
200
  // Normalize the response
153
201
  const status =
154
- result.status || (result.done ? "COMPLETED" : "IN_PROGRESS");
202
+ (result as any).status ||
203
+ ((result as any).done ? "COMPLETED" : "IN_PROGRESS");
204
+
205
+ const safeResult = sanitizeResponse(result);
155
206
 
156
207
  return JSON.stringify(
157
208
  {
158
209
  source: detectedSource,
159
210
  status,
160
- resume_id,
161
- result,
211
+ resume_endpoint,
212
+ result: safeResult,
162
213
  message:
163
214
  status === "COMPLETED"
164
- ? "Generation completed! The result is included in the 'result' field."
215
+ ? "Generation completed! Check 'result.response.saved_videos' for the video URLs."
165
216
  : status === "FAILED"
166
- ? "Generation failed. Check the 'result' field for error details."
167
- : "Generation is still in progress. Call this tool again with the same resume_id to check later.",
217
+ ? "Generation failed. Check the 'result' field for error details."
218
+ : "Generation is still in progress. Call this tool again with the same resume_endpoint to check later.",
168
219
  },
169
220
  null,
170
- 2
221
+ 2,
171
222
  );
172
223
  }, "get_generation_status");
173
224
  },
@@ -8,6 +8,7 @@ import {
8
8
  } from "../utils/prompt-enhancer-presets";
9
9
 
10
10
  import { getGoogleAccessToken } from "../utils/google-auth";
11
+ import { checkVertexStatus } from "./get-status";
11
12
 
12
13
  async function wait(ms: number): Promise<void> {
13
14
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -16,7 +17,7 @@ async function wait(ms: number): Promise<void> {
16
17
  import { ensureLocalFile } from "../utils/url-file";
17
18
 
18
19
  async function fileToBase64(
19
- filePath: string
20
+ filePath: string,
20
21
  ): Promise<{ data: string; mimeType: string }> {
21
22
  const fileResult = await ensureLocalFile(filePath);
22
23
 
@@ -47,7 +48,7 @@ export const imageToVideo = {
47
48
  description:
48
49
  "Generate professional-quality cinematic videos from a starting image and text prompt using Google's Vertex AI Veo models. " +
49
50
  "This tool follows a 'Synchronous Facade' pattern: it handles polling internally but can be paused/resumed. " +
50
- "If the generation takes too long, it returns a 'resume_id' that you MUST use to call this tool again to pick up progress. " +
51
+ "If the generation takes too long, it returns a 'resume_endpoint' that you MUST use to call this tool again to pick up progress. " +
51
52
  "It produces state-of-the-art cinematic results. " +
52
53
  "ONLY USE WHEN WORKING WITH GOOGLE VERTEX AI MODELS.",
53
54
  parameters: z.object({
@@ -55,7 +56,7 @@ export const imageToVideo = {
55
56
  .string()
56
57
  .optional()
57
58
  .describe(
58
- "Required for new requests. Descriptive text for the video action and style (e.g., 'A robot walking through a neon city at night')."
59
+ "Required for new requests. Descriptive text for the video action and style (e.g., 'A robot walking through a neon city at night').",
59
60
  ),
60
61
  image_path: z
61
62
  .string()
@@ -65,21 +66,21 @@ export const imageToVideo = {
65
66
  .string()
66
67
  .optional()
67
68
  .describe(
68
- "Optional: Absolute local path or URL to the ENDING image frame to guide the video's conclusion."
69
+ "Optional: Absolute local path or URL to the ENDING image frame to guide the video's conclusion.",
69
70
  ),
70
71
  aspect_ratio: z
71
72
  .string()
72
73
  .optional()
73
74
  .default("16:9")
74
75
  .describe(
75
- "Target aspect ratio: '16:9' (landscape) or '9:16' (vertical)."
76
+ "Target aspect ratio: '16:9' (landscape) or '9:16' (vertical).",
76
77
  ),
77
78
  duration_seconds: z
78
79
  .string()
79
80
  .optional()
80
81
  .default("6")
81
82
  .describe(
82
- "Target duration. Vertex AI ONLY supports exactly '4', '6', or '8' seconds. Other values will be rounded to the nearest supported step."
83
+ "Target duration. Vertex AI ONLY supports exactly '4', '6', or '8' seconds. Other values will be rounded to the nearest supported step.",
83
84
  ),
84
85
  resolution: z
85
86
  .string()
@@ -89,71 +90,59 @@ export const imageToVideo = {
89
90
  .string()
90
91
  .optional()
91
92
  .describe(
92
- "Visual elements or styles to EXCLUDE from the generated video."
93
+ "Visual elements or styles to EXCLUDE from the generated video.",
93
94
  ),
94
95
  person_generation: z
95
96
  .string()
96
97
  .optional()
97
98
  .describe(
98
- "Policy for generating people: 'allow_adult' (standard) or 'allow_all'. Note: Gemini 1.5+ safety filters apply."
99
+ "Policy for generating people: 'allow_adult' (standard) or 'allow_all'. Note: Gemini 1.5+ safety filters apply.",
99
100
  ),
100
101
  reference_images: z
101
102
  .array(z.string())
102
103
  .optional()
103
104
  .describe(
104
- "Optional: Additional images (up to 3) to guide style or character consistency."
105
+ "Optional: Additional images (up to 3) to guide style or character consistency.",
105
106
  ),
106
107
  output_path: z
107
108
  .string()
108
109
  .optional()
109
110
  .describe(
110
- "Optional: Local path to save the resulting .mp4 file. Defaults to timestamped filename."
111
- ),
112
- project_id: z
113
- .string()
114
- .optional()
115
- .default("mixio-pro")
116
- .describe("GCP Project ID for Vertex billing. Default is mixio-pro."),
117
- location_id: z
118
- .string()
119
- .optional()
120
- .default("us-central1")
121
- .describe(
122
- "GCP region for Vertex AI processing (Default is 'us-central1')."
111
+ "Optional: Local path to save the resulting .mp4 file. Defaults to timestamped filename.",
123
112
  ),
124
113
  model_id: z
125
114
  .string()
126
115
  .optional()
127
116
  .default("veo-3.1-fast-generate-001")
128
117
  .describe(
129
- "Specific Vertex Veo model ID to use. Default Value is veo-3.1-fast-generate-001"
118
+ "Specific Vertex Veo model ID to use. Default Value is veo-3.1-fast-generate-001",
130
119
  ),
131
120
  generate_audio: z
132
121
  .boolean()
133
122
  .optional()
134
123
  .describe(
135
- "If true, Vertex will attempt to synthesize synchronized audio for the video."
124
+ "If true, Vertex will attempt to synthesize synchronized audio for the video.",
136
125
  )
137
126
  .default(false),
138
- resume_id: z
127
+ resume_endpoint: z
139
128
  .string()
140
129
  .optional()
141
130
  .describe(
142
131
  "If provided, the tool will check the status of an existing Vertex operation instead of starting a new one. " +
143
- "Use the 'request_id' returned in an 'IN_PROGRESS' response."
132
+ "Use the 'resume_endpoint' returned in an 'IN_PROGRESS' response.",
144
133
  ),
145
134
  auto_enhance: z
146
135
  .boolean()
147
136
  .optional()
148
137
  .describe(
149
- "Whether to automatically enhance the prompt using Veo/LTX guidelines (default: true if enabled via preset or config). Set to false to disable enhancement."
138
+ "Whether to automatically enhance the prompt using Veo/LTX guidelines (default: true if enabled via preset or config). Set to false to disable enhancement.",
150
139
  ),
151
140
  enhancer_preset: z
152
141
  .string()
153
142
  .optional()
154
143
  .describe(
155
144
  "Optional: Name of a video prompt enhancer preset (e.g., 'veo', 'ltx2', 'cinematic_video'). " +
156
- "When using Veo, setting this to 'veo' (or setting auto_enhance=true) will trigger the LLM-based enhancer."
145
+ "When using Veo, setting this to 'veo' (or setting auto_enhance=true) will trigger the LLM-based enhancer.",
157
146
  ),
158
147
  }),
159
148
  timeoutMs: 90000, // 90 seconds MCP timeout (internal timeout is 60s)
@@ -169,11 +158,9 @@ export const imageToVideo = {
169
158
  person_generation?: string;
170
159
  reference_images?: string[] | string;
171
160
  output_path?: string;
172
- project_id?: string;
173
- location_id?: string;
174
161
  model_id?: string;
175
162
  generate_audio?: boolean;
176
- resume_id?: string;
163
+ resume_endpoint?: string;
177
164
  enhancer_preset?: string;
178
165
  auto_enhance?: boolean;
179
166
  },
@@ -190,11 +177,11 @@ export const imageToVideo = {
190
177
  info: (msg: string, data?: any) => void;
191
178
  debug: (msg: string, data?: any) => void;
192
179
  };
193
- }
180
+ },
194
181
  ) {
195
182
  return safeToolExecute(async () => {
196
- const projectId = args.project_id || "mixio-pro";
197
- const location = args.location_id || "us-central1";
183
+ const projectId = "mixio-pro";
184
+ const location = "us-central1";
198
185
  const modelId = args.model_id || "veo-3.1-fast-generate-preview";
199
186
 
200
187
  // Validate and parse duration_seconds - snap to nearest 4, 6, or 8
@@ -260,13 +247,13 @@ export const imageToVideo = {
260
247
 
261
248
  // If resuming, reconstruct the full operation path from the UUID
262
249
  let operationName: string | undefined;
263
- if (args.resume_id) {
250
+ if (args.resume_endpoint) {
264
251
  // Support both UUID-only and full path formats
265
- if (args.resume_id.includes("/")) {
266
- operationName = args.resume_id; // Already a full path
252
+ if (args.resume_endpoint.includes("/")) {
253
+ operationName = args.resume_endpoint; // Already a full path
267
254
  } else {
268
255
  // Reconstruct full path from UUID
269
- operationName = `projects/${projectId}/locations/${location}/publishers/google/models/${modelId}/operations/${args.resume_id}`;
256
+ operationName = `projects/${projectId}/locations/${location}/publishers/google/models/${modelId}/operations/${args.resume_endpoint}`;
270
257
  }
271
258
  }
272
259
  let current: any;
@@ -327,7 +314,7 @@ export const imageToVideo = {
327
314
  refImages = args.reference_images;
328
315
  } else {
329
316
  throw new Error(
330
- "Invalid reference_images: must be array or string"
317
+ "Invalid reference_images: must be array or string",
331
318
  );
332
319
  }
333
320
 
@@ -342,7 +329,7 @@ export const imageToVideo = {
342
329
  },
343
330
  referenceType: "asset",
344
331
  };
345
- })
332
+ }),
346
333
  );
347
334
  }
348
335
  }
@@ -385,7 +372,7 @@ export const imageToVideo = {
385
372
  try {
386
373
  enhancedPrompt = await enhancePromptWithLLM(args.prompt, "veo");
387
374
  context?.log?.info(
388
- `LLM-enhanced prompt for Veo: "${args.prompt}" → "${enhancedPrompt}"`
375
+ `LLM-enhanced prompt for Veo: "${args.prompt}" → "${enhancedPrompt}"`,
389
376
  );
390
377
 
391
378
  if (context?.streamContent) {
@@ -396,12 +383,12 @@ export const imageToVideo = {
396
383
  }
397
384
  } catch (err: any) {
398
385
  context?.log?.info(
399
- `LLM enhancement failed, using original: ${err.message}`
386
+ `LLM enhancement failed, using original: ${err.message}`,
400
387
  );
401
388
  }
402
389
  } else {
403
390
  context?.log?.info(
404
- "GEMINI_API_KEY not set, skipping Veo LLM enhancement"
391
+ "GEMINI_API_KEY not set, skipping Veo LLM enhancement",
405
392
  );
406
393
  }
407
394
  } else {
@@ -457,57 +444,44 @@ export const imageToVideo = {
457
444
 
458
445
  if (!operationName) {
459
446
  throw new Error(
460
- "Vertex did not return an operation name for long-running request"
447
+ "Vertex did not return an operation name for long-running request",
461
448
  );
462
449
  }
463
450
 
464
- // Extract just the operation UUID from the full path for a cleaner resume_id
465
- // Full path: projects/.../operations/<uuid>
466
- const operationUuid = operationName.split("/").pop() || operationName;
451
+ // Construct the composite resume_endpoint: fetchUrl||operationName||outputPath
452
+ // This allows get_generation_status to use the URL directly and preserve output_path
453
+ const outputPathPart = args.output_path || "";
454
+ const compositeResumeEndpoint = `${fetchUrl}||${operationName}||${outputPathPart}`;
467
455
 
468
- // Stream the resume_id to the LLM immediately (before polling starts)
456
+ // Stream the resume_endpoint to the LLM immediately (before polling starts)
469
457
  // This way the LLM has it even if MCP client times out during polling
470
458
  if (context?.streamContent) {
471
- const isResume = !!args.resume_id;
459
+ const isResume = !!args.resume_endpoint;
472
460
  await context.streamContent({
473
461
  type: "text" as const,
474
462
  text: isResume
475
- ? `[Vertex] Resuming status check for job: ${operationUuid}`
476
- : `[Vertex] Video generation started. resume_id: ${operationUuid} (use this to check status if needed)`,
463
+ ? `[Vertex] Resuming status check for job`
464
+ : `[Vertex] Video generation started. resume_endpoint: ${compositeResumeEndpoint} (use this to check status if needed)`,
477
465
  });
478
466
  }
479
467
 
480
468
  // Poll for status - keep polling until done
481
- // Resume_id was already streamed, so if MCP client times out the LLM still has it
469
+ // Resume_endpoint was already streamed, so if MCP client times out the LLM still has it
482
470
  let done = current ? !!current.done || !!current.response : false;
483
471
  const startTime = Date.now();
484
- const MAX_POLL_TIME = 60000; // 60 seconds internal timeout - then return resume_id
472
+ const MAX_POLL_TIME = 60000; // 60 seconds internal timeout - then return resume_endpoint
485
473
 
486
474
  while (!done && Date.now() - startTime < MAX_POLL_TIME) {
487
475
  await wait(10000); // 10 second intervals
488
476
 
489
- const poll = await fetch(fetchUrl, {
490
- method: "POST",
491
- headers: {
492
- Authorization: `Bearer ${token}`,
493
- "Content-Type": "application/json",
494
- },
495
- body: JSON.stringify({ operationName }),
496
- });
497
- if (!poll.ok) {
498
- const text = await poll.text();
499
- throw new Error(
500
- `Vertex operation poll failed: ${poll.status} ${text}`
501
- );
502
- }
503
- current = (await poll.json()) as any;
477
+ current = await checkVertexStatus(compositeResumeEndpoint);
504
478
  done = !!current.done || !!current.response;
505
479
 
506
480
  if (context?.reportProgress) {
507
481
  const elapsed = Date.now() - startTime;
508
482
  const progressPercent = Math.min(
509
483
  Math.round((elapsed / MAX_POLL_TIME) * 100),
510
- 99
484
+ 99,
511
485
  );
512
486
  await context.reportProgress({
513
487
  progress: progressPercent,
@@ -519,7 +493,7 @@ export const imageToVideo = {
519
493
  await context.streamContent({
520
494
  type: "text" as const,
521
495
  text: `[Vertex] Still processing... (${Math.round(
522
- (Date.now() - startTime) / 1000
496
+ (Date.now() - startTime) / 1000,
523
497
  )}s elapsed)`,
524
498
  });
525
499
  }
@@ -529,68 +503,33 @@ export const imageToVideo = {
529
503
  return JSON.stringify({
530
504
  status: "IN_PROGRESS",
531
505
  request_id: operationName,
532
- resume_id: operationName,
506
+ resume_endpoint: compositeResumeEndpoint,
533
507
  message:
534
- "Still in progress. Call this tool again with resume_id to continue checking.",
508
+ "Still in progress. Call this tool again with resume_endpoint to continue checking.",
535
509
  });
536
510
  }
537
511
 
538
512
  const resp = current.response || current;
539
- // Decode from response.videos[].bytesBase64Encoded only
540
- const videos: Array<{ url: string; filename: string; mimeType: string }> =
541
- [];
542
- const saveVideo = async (base64: string, index: number) => {
543
- if (!base64) return;
544
-
545
- // Use provided output path or generate default with timestamp
546
- let filePath: string;
547
- if (args.output_path) {
548
- // User provided path - use as-is for first video, add index for subsequent
549
- filePath =
550
- index === 0
551
- ? args.output_path
552
- : args.output_path.replace(/\.mp4$/i, `_${index}.mp4`);
553
- } else {
554
- // No path provided - generate timestamped default
555
- const defaultName = `video_output${index > 0 ? `_${index}` : ""}.mp4`;
556
- filePath = generateTimestampedFilename(defaultName);
557
- }
558
-
559
- const buf = Buffer.from(base64, "base64");
560
- const storage = getStorage();
561
- const url = await storage.writeFile(filePath, buf);
562
- videos.push({
563
- url,
564
- filename: filePath,
565
- mimeType: "video/mp4",
566
- });
567
- };
568
-
569
- if (Array.isArray(resp?.videos) && resp.videos.length > 0) {
570
- for (let i = 0; i < resp.videos.length; i++) {
571
- const v = resp.videos[i] || {};
572
- if (typeof v.bytesBase64Encoded === "string") {
573
- await saveVideo(v.bytesBase64Encoded, i);
574
- }
575
- }
576
- }
577
- if (videos.length > 0) {
513
+
514
+ // checkVertexStatus already handles saving videos and sanitizing base64
515
+ if (Array.isArray(resp.saved_videos) && resp.saved_videos.length > 0) {
578
516
  return JSON.stringify({
579
- videos,
517
+ videos: resp.saved_videos,
580
518
  message: "Video(s) generated successfully",
581
519
  });
582
520
  }
583
521
 
584
- // If nothing saved, return a concise summary plus head/tail snippets of JSON
585
- let jsonStr = "";
586
- try {
587
- jsonStr = JSON.stringify(resp);
588
- } catch {}
589
- const head150 = jsonStr ? jsonStr.slice(0, 150) : "";
590
- const tail50 = jsonStr
591
- ? jsonStr.slice(Math.max(0, jsonStr.length - 50))
592
- : "";
593
- return `Vertex operation done but no videos array present. operationName=${operationName}. json_head150=${head150} json_tail50=${tail50}`;
522
+ // If nothing saved, return a clean error without any raw JSON that could contain base64
523
+ // CRITICAL: Never return raw response data to prevent context window poisoning
524
+ const respKeys = resp ? Object.keys(resp) : [];
525
+ return JSON.stringify({
526
+ status: "ERROR",
527
+ message:
528
+ "Vertex operation completed but no videos were found in the response.",
529
+ operationName,
530
+ responseKeys: respKeys,
531
+ hint: "The response structure may have changed. Check the Vertex AI documentation or search for the expected response format.",
532
+ });
594
533
  }, "imageToVideo");
595
534
  },
596
535
  };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Utility to sanitize API responses by removing or masking large data fields
3
+ * like base64-encoded bytes to prevent context window poisoning.
4
+ */
5
+
6
+ /**
7
+ * Recursively sanitizes an object by replacing large data fields.
8
+ * @param obj The object or array to sanitize
9
+ * @returns A new object with sensitive/large fields masked
10
+ */
11
+ export function sanitizeResponse(obj: any): any {
12
+ if (obj === null || obj === undefined) return obj;
13
+ if (typeof obj !== "object") return obj;
14
+
15
+ if (Array.isArray(obj)) {
16
+ return obj.map(sanitizeResponse);
17
+ }
18
+
19
+ const sanitized: any = {};
20
+ for (const key of Object.keys(obj)) {
21
+ // List of keys known to contain large binary data or base64
22
+ const isLargeDataKey =
23
+ key === "bytesBase64Encoded" ||
24
+ key === "base64" ||
25
+ key === "data" ||
26
+ key === "content" && typeof obj[key] === "string" && obj[key].length > 10000;
27
+
28
+ if (isLargeDataKey) {
29
+ sanitized[key] = "[LARGE_DATA_HIDDEN]";
30
+ } else {
31
+ sanitized[key] = sanitizeResponse(obj[key]);
32
+ }
33
+ }
34
+ return sanitized;
35
+ }