@mixio-pro/kalaasetu-mcp 2.0.6-beta → 2.0.8-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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mixio-pro/kalaasetu-mcp",
3
- "version": "2.0.6-beta",
3
+ "version": "2.0.8-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",
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
 
@@ -160,9 +160,7 @@ export function loadFalConfig(): FalConfig {
160
160
  }
161
161
 
162
162
  try {
163
- const absolutePath = path.isAbsolute(configPath)
164
- ? configPath
165
- : path.join(process.cwd(), configPath);
163
+ const absolutePath = path.resolve(configPath);
166
164
 
167
165
  if (!fs.existsSync(absolutePath)) {
168
166
  console.error(
@@ -212,9 +210,7 @@ export function saveFalConfig(config: FalConfig): boolean {
212
210
  }
213
211
 
214
212
  try {
215
- const absolutePath = path.isAbsolute(configPath)
216
- ? configPath
217
- : path.join(process.cwd(), configPath);
213
+ const absolutePath = path.resolve(configPath);
218
214
 
219
215
  fs.writeFileSync(absolutePath, JSON.stringify(config, null, 2), "utf-8");
220
216
  return true;
@@ -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
+ }
@@ -12,7 +12,6 @@ import {
12
12
  getApiKey,
13
13
  loadFalConfig,
14
14
  } from "./config";
15
- import { checkFalStatus, fetchFalResult } from "../get-status";
16
15
 
17
16
  /**
18
17
  * Helper to wait for a specified duration.
@@ -106,7 +105,6 @@ export const falGenerate = {
106
105
  ),
107
106
  parameters: z
108
107
  .record(z.string(), z.any())
109
- .optional()
110
108
  .describe(
111
109
  "A dictionary of model-specific parameters (e.g., { 'prompt': '...', 'image_url': '...' }). " +
112
110
  "These override the default values defined in the preset. " +
@@ -371,14 +369,14 @@ export const falGenerate = {
371
369
  pollCount++;
372
370
  let res;
373
371
  try {
374
- res = await checkFalStatus(statusUrl);
372
+ res = await authenticatedRequest(statusUrl, "GET");
375
373
  } catch (e: any) {
376
374
  if (`${e}`.includes("405")) {
377
375
  context?.log?.info(
378
376
  `Status check 405 on ${statusUrl}, trying fallback to responseUrl...`
379
377
  );
380
378
  // Try checking the request root URL instead of /status
381
- res = await fetchFalResult(responseUrl);
379
+ res = await authenticatedRequest(responseUrl, "GET");
382
380
  // If successful, update statusUrl to match for future polls
383
381
  statusUrl = responseUrl;
384
382
  } else {
@@ -421,7 +419,7 @@ export const falGenerate = {
421
419
  await context.reportProgress({ progress: 100, total: 100 });
422
420
  }
423
421
  // responseUrl is now guaranteed to be correct/fresh from polling
424
- const finalResult = await fetchFalResult(responseUrl);
422
+ const finalResult = await authenticatedRequest(responseUrl, "GET");
425
423
  return JSON.stringify(finalResult);
426
424
  }
427
425
 
@@ -72,19 +72,15 @@ 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;
76
- console.log(
77
- `[fal_list_presets] ${
78
- preset.presetName
79
- }: shouldFetch=${shouldFetch}, hasSchema=${!!preset.input_schema}, refresh=${
80
- args.refresh_schemas
81
- }`
82
- );
75
+ const hasSchema =
76
+ preset.input_schema && Object.keys(preset.input_schema).length > 0;
77
+ const shouldFetch = !hasSchema || args.refresh_schemas;
78
+
83
79
 
84
80
  if (shouldFetch) {
85
81
  try {
86
82
  const url = `https://fal.ai/api/openapi/queue/openapi.json?endpoint_id=${preset.modelId}`;
87
- console.log(`[fal_list_presets] Fetching schema from: ${url}`);
83
+
88
84
  const response = await fetch(url, {
89
85
  method: "GET",
90
86
  signal: AbortSignal.timeout(10000),
@@ -93,22 +89,17 @@ export const falListPresets = {
93
89
  if (response.ok) {
94
90
  const openApiSchema = await response.json();
95
91
  const simplified = extractInputSchema(openApiSchema);
96
- console.log(
97
- `[fal_list_presets] Extracted schema for ${preset.presetName}:`,
98
- simplified ? Object.keys(simplified) : null
99
- );
92
+
100
93
 
101
94
  if (simplified) {
102
95
  preset.input_schema = simplified;
103
96
  updated = true;
104
97
  }
105
98
  } else {
106
- console.log(
107
- `[fal_list_presets] Fetch failed: ${response.status}`
108
- );
99
+
109
100
  }
110
101
  } catch (e: any) {
111
- console.log(
102
+ console.error(
112
103
  `[fal_list_presets] Error fetching schema for ${preset.presetName}:`,
113
104
  e.message
114
105
  );
@@ -118,9 +109,7 @@ export const falListPresets = {
118
109
 
119
110
  // Save updated config if schemas were fetched
120
111
  if (updated) {
121
- console.log(`[fal_list_presets] Saving updated config...`);
122
- const saved = saveFalConfig(config);
123
- console.log(`[fal_list_presets] Config saved: ${saved}`);
112
+ saveFalConfig(config);
124
113
  }
125
114
 
126
115
  // Return enriched preset list
@@ -6,6 +6,7 @@
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
 
@@ -67,9 +68,7 @@ export async function fetchFalResult(responseUrl: string): Promise<any> {
67
68
  return await response.json();
68
69
  }
69
70
 
70
- export async function checkVertexStatus(
71
- resumeEndpoint: string
72
- ): Promise<any> {
71
+ export async function checkVertexStatus(resumeEndpoint: string): Promise<any> {
73
72
  const accessToken = await getGoogleAccessToken();
74
73
 
75
74
  // resumeEndpoint is composite format: fetchUrl||operationName||outputPath
@@ -80,7 +79,7 @@ export async function checkVertexStatus(
80
79
 
81
80
  if (!fetchUrl || !operationName) {
82
81
  throw new Error(
83
- "Invalid Vertex resume_endpoint format. Expected 'fetchUrl||operationName[||outputPath]'."
82
+ "Invalid Vertex resume_endpoint format. Expected 'fetchUrl||operationName[||outputPath]'.",
84
83
  );
85
84
  }
86
85
 
@@ -117,9 +116,7 @@ export async function checkVertexStatus(
117
116
  if (outputPath) {
118
117
  // Use custom path, add index for subsequent videos
119
118
  filePath =
120
- i === 0
121
- ? outputPath
122
- : outputPath.replace(/\.mp4$/i, `_${i}.mp4`);
119
+ i === 0 ? outputPath : outputPath.replace(/\.mp4$/i, `_${i}.mp4`);
123
120
  } else {
124
121
  // Default timestamped filename
125
122
  filePath = generateTimestampedFilename(`video_output_${i}.mp4`);
@@ -131,6 +128,8 @@ export async function checkVertexStatus(
131
128
  filename: filePath,
132
129
  mimeType: "video/mp4",
133
130
  });
131
+ // CRITICAL: Remove base64 data to prevent context window poisoning
132
+ delete v.bytesBase64Encoded;
134
133
  }
135
134
  }
136
135
 
@@ -157,14 +156,14 @@ export const getGenerationStatus = {
157
156
  .describe(
158
157
  "The resume_endpoint returned by the original generation tool. " +
159
158
  "For FAL: This is a full URL (starts with 'https://queue.fal.run/...'). " +
160
- "For Vertex AI: This is an operation name or full path."
159
+ "For Vertex AI: This is an operation name or full path.",
161
160
  ),
162
161
  source: z
163
162
  .enum(["fal", "vertex", "auto"])
164
163
  .optional()
165
164
  .default("auto")
166
165
  .describe(
167
- "Source of the operation: 'fal' for FAL AI, 'vertex' for Google Vertex AI, or 'auto' to auto-detect based on resume_endpoint 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.",
168
167
  ),
169
168
  }),
170
169
  timeoutMs: 30000, // 30 seconds for status check
@@ -173,10 +172,7 @@ export const getGenerationStatus = {
173
172
  source?: "fal" | "vertex" | "auto";
174
173
  }) => {
175
174
  return safeToolExecute(async () => {
176
- const {
177
- resume_endpoint,
178
- source = "auto",
179
- } = args;
175
+ const { resume_endpoint, source = "auto" } = args;
180
176
  const project_id = "mixio-pro";
181
177
  const location_id = "us-central1";
182
178
 
@@ -203,25 +199,27 @@ export const getGenerationStatus = {
203
199
 
204
200
  // Normalize the response
205
201
  const status =
206
- (result as any).status || ((result as any).done ? "COMPLETED" : "IN_PROGRESS");
202
+ (result as any).status ||
203
+ ((result as any).done ? "COMPLETED" : "IN_PROGRESS");
204
+
205
+ const safeResult = sanitizeResponse(result);
207
206
 
208
207
  return JSON.stringify(
209
208
  {
210
209
  source: detectedSource,
211
210
  status,
212
211
  resume_endpoint,
213
- result,
212
+ result: safeResult,
214
213
  message:
215
214
  status === "COMPLETED"
216
- ? "Generation completed! The result is included in the 'result' field."
215
+ ? "Generation completed! Check 'result.response.saved_videos' for the video URLs."
217
216
  : status === "FAILED"
218
- ? "Generation failed. Check the 'result' field for error details."
219
- : "Generation is still in progress. Call this tool again with the same resume_endpoint 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.",
220
219
  },
221
220
  null,
222
- 2
221
+ 2,
223
222
  );
224
223
  }, "get_generation_status");
225
224
  },
226
225
  };
227
-
@@ -17,7 +17,7 @@ async function wait(ms: number): Promise<void> {
17
17
  import { ensureLocalFile } from "../utils/url-file";
18
18
 
19
19
  async function fileToBase64(
20
- filePath: string
20
+ filePath: string,
21
21
  ): Promise<{ data: string; mimeType: string }> {
22
22
  const fileResult = await ensureLocalFile(filePath);
23
23
 
@@ -56,7 +56,7 @@ export const imageToVideo = {
56
56
  .string()
57
57
  .optional()
58
58
  .describe(
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
+ "Required for new requests. Descriptive text for the video action and style (e.g., 'A robot walking through a neon city at night').",
60
60
  ),
61
61
  image_path: z
62
62
  .string()
@@ -66,21 +66,21 @@ export const imageToVideo = {
66
66
  .string()
67
67
  .optional()
68
68
  .describe(
69
- "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.",
70
70
  ),
71
71
  aspect_ratio: z
72
72
  .string()
73
73
  .optional()
74
74
  .default("16:9")
75
75
  .describe(
76
- "Target aspect ratio: '16:9' (landscape) or '9:16' (vertical)."
76
+ "Target aspect ratio: '16:9' (landscape) or '9:16' (vertical).",
77
77
  ),
78
78
  duration_seconds: z
79
79
  .string()
80
80
  .optional()
81
81
  .default("6")
82
82
  .describe(
83
- "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.",
84
84
  ),
85
85
  resolution: z
86
86
  .string()
@@ -90,38 +90,38 @@ export const imageToVideo = {
90
90
  .string()
91
91
  .optional()
92
92
  .describe(
93
- "Visual elements or styles to EXCLUDE from the generated video."
93
+ "Visual elements or styles to EXCLUDE from the generated video.",
94
94
  ),
95
95
  person_generation: z
96
96
  .string()
97
97
  .optional()
98
98
  .describe(
99
- "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.",
100
100
  ),
101
101
  reference_images: z
102
102
  .array(z.string())
103
103
  .optional()
104
104
  .describe(
105
- "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.",
106
106
  ),
107
107
  output_path: z
108
108
  .string()
109
109
  .optional()
110
110
  .describe(
111
- "Optional: Local path to save the resulting .mp4 file. Defaults to timestamped filename."
111
+ "Optional: Local path to save the resulting .mp4 file. Defaults to timestamped filename.",
112
112
  ),
113
113
  model_id: z
114
114
  .string()
115
115
  .optional()
116
116
  .default("veo-3.1-fast-generate-001")
117
117
  .describe(
118
- "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",
119
119
  ),
120
120
  generate_audio: z
121
121
  .boolean()
122
122
  .optional()
123
123
  .describe(
124
- "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.",
125
125
  )
126
126
  .default(false),
127
127
  resume_endpoint: z
@@ -129,20 +129,20 @@ export const imageToVideo = {
129
129
  .optional()
130
130
  .describe(
131
131
  "If provided, the tool will check the status of an existing Vertex operation instead of starting a new one. " +
132
- "Use the 'resume_endpoint' returned in an 'IN_PROGRESS' response."
132
+ "Use the 'resume_endpoint' returned in an 'IN_PROGRESS' response.",
133
133
  ),
134
134
  auto_enhance: z
135
135
  .boolean()
136
136
  .optional()
137
137
  .describe(
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."
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.",
139
139
  ),
140
140
  enhancer_preset: z
141
141
  .string()
142
142
  .optional()
143
143
  .describe(
144
144
  "Optional: Name of a video prompt enhancer preset (e.g., 'veo', 'ltx2', 'cinematic_video'). " +
145
- "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.",
146
146
  ),
147
147
  }),
148
148
  timeoutMs: 90000, // 90 seconds MCP timeout (internal timeout is 60s)
@@ -177,7 +177,7 @@ export const imageToVideo = {
177
177
  info: (msg: string, data?: any) => void;
178
178
  debug: (msg: string, data?: any) => void;
179
179
  };
180
- }
180
+ },
181
181
  ) {
182
182
  return safeToolExecute(async () => {
183
183
  const projectId = "mixio-pro";
@@ -314,7 +314,7 @@ export const imageToVideo = {
314
314
  refImages = args.reference_images;
315
315
  } else {
316
316
  throw new Error(
317
- "Invalid reference_images: must be array or string"
317
+ "Invalid reference_images: must be array or string",
318
318
  );
319
319
  }
320
320
 
@@ -329,7 +329,7 @@ export const imageToVideo = {
329
329
  },
330
330
  referenceType: "asset",
331
331
  };
332
- })
332
+ }),
333
333
  );
334
334
  }
335
335
  }
@@ -372,7 +372,7 @@ export const imageToVideo = {
372
372
  try {
373
373
  enhancedPrompt = await enhancePromptWithLLM(args.prompt, "veo");
374
374
  context?.log?.info(
375
- `LLM-enhanced prompt for Veo: "${args.prompt}" → "${enhancedPrompt}"`
375
+ `LLM-enhanced prompt for Veo: "${args.prompt}" → "${enhancedPrompt}"`,
376
376
  );
377
377
 
378
378
  if (context?.streamContent) {
@@ -383,12 +383,12 @@ export const imageToVideo = {
383
383
  }
384
384
  } catch (err: any) {
385
385
  context?.log?.info(
386
- `LLM enhancement failed, using original: ${err.message}`
386
+ `LLM enhancement failed, using original: ${err.message}`,
387
387
  );
388
388
  }
389
389
  } else {
390
390
  context?.log?.info(
391
- "GEMINI_API_KEY not set, skipping Veo LLM enhancement"
391
+ "GEMINI_API_KEY not set, skipping Veo LLM enhancement",
392
392
  );
393
393
  }
394
394
  } else {
@@ -444,7 +444,7 @@ export const imageToVideo = {
444
444
 
445
445
  if (!operationName) {
446
446
  throw new Error(
447
- "Vertex did not return an operation name for long-running request"
447
+ "Vertex did not return an operation name for long-running request",
448
448
  );
449
449
  }
450
450
 
@@ -481,7 +481,7 @@ export const imageToVideo = {
481
481
  const elapsed = Date.now() - startTime;
482
482
  const progressPercent = Math.min(
483
483
  Math.round((elapsed / MAX_POLL_TIME) * 100),
484
- 99
484
+ 99,
485
485
  );
486
486
  await context.reportProgress({
487
487
  progress: progressPercent,
@@ -493,7 +493,7 @@ export const imageToVideo = {
493
493
  await context.streamContent({
494
494
  type: "text" as const,
495
495
  text: `[Vertex] Still processing... (${Math.round(
496
- (Date.now() - startTime) / 1000
496
+ (Date.now() - startTime) / 1000,
497
497
  )}s elapsed)`,
498
498
  });
499
499
  }
@@ -510,61 +510,26 @@ export const imageToVideo = {
510
510
  }
511
511
 
512
512
  const resp = current.response || current;
513
- // Decode from response.videos[].bytesBase64Encoded only
514
- const videos: Array<{ url: string; filename: string; mimeType: string }> =
515
- [];
516
- const saveVideo = async (base64: string, index: number) => {
517
- if (!base64) return;
518
-
519
- // Use provided output path or generate default with timestamp
520
- let filePath: string;
521
- if (args.output_path) {
522
- // User provided path - use as-is for first video, add index for subsequent
523
- filePath =
524
- index === 0
525
- ? args.output_path
526
- : args.output_path.replace(/\.mp4$/i, `_${index}.mp4`);
527
- } else {
528
- // No path provided - generate timestamped default
529
- const defaultName = `video_output${index > 0 ? `_${index}` : ""}.mp4`;
530
- filePath = generateTimestampedFilename(defaultName);
531
- }
532
-
533
- const buf = Buffer.from(base64, "base64");
534
- const storage = getStorage();
535
- const url = await storage.writeFile(filePath, buf);
536
- videos.push({
537
- url,
538
- filename: filePath,
539
- mimeType: "video/mp4",
540
- });
541
- };
542
-
543
- if (Array.isArray(resp?.videos) && resp.videos.length > 0) {
544
- for (let i = 0; i < resp.videos.length; i++) {
545
- const v = resp.videos[i] || {};
546
- if (typeof v.bytesBase64Encoded === "string") {
547
- await saveVideo(v.bytesBase64Encoded, i);
548
- }
549
- }
550
- }
551
- 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) {
552
516
  return JSON.stringify({
553
- videos,
517
+ videos: resp.saved_videos,
554
518
  message: "Video(s) generated successfully",
555
519
  });
556
520
  }
557
521
 
558
- // If nothing saved, return a concise summary plus head/tail snippets of JSON
559
- let jsonStr = "";
560
- try {
561
- jsonStr = JSON.stringify(resp);
562
- } catch {}
563
- const head150 = jsonStr ? jsonStr.slice(0, 150) : "";
564
- const tail50 = jsonStr
565
- ? jsonStr.slice(Math.max(0, jsonStr.length - 50))
566
- : "";
567
- 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
+ });
568
533
  }, "imageToVideo");
569
534
  },
570
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
+ }