@mixio-pro/kalaasetu-mcp 2.3.27 → 2.3.28

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.3.27",
3
+ "version": "2.3.28",
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",
@@ -126,13 +126,14 @@ function buildZodSchema(
126
126
  // Convert to Zod using Zod 4's fromJSONSchema (experimental)
127
127
  let zodSchema = (z as any).fromJSONSchema(jsonSchema) as z.ZodObject<any>;
128
128
 
129
- // Always add resume_endpoint for resumable operations
129
+ // Always add internal parameters (output_path, resume_endpoint)
130
130
  zodSchema = zodSchema.extend({
131
131
  output_path: z
132
132
  .string()
133
133
  .optional()
134
134
  .describe(
135
- "Optional local path/filename to save the final generated asset when status is checked via get_generation_status.",
135
+ "Optional local path/filename to save the generated file (e.g., 'path/to/image.png'). " +
136
+ "When status is checked via get_generation_status, the file will be saved to this location.",
136
137
  ),
137
138
  resume_endpoint: z
138
139
  .string()
@@ -177,41 +178,31 @@ export function createToolFromPreset(preset: FalPresetConfig) {
177
178
 
178
179
  // Handle resume flow
179
180
  if (args.resume_endpoint) {
180
- const resumeParts = args.resume_endpoint.split("||");
181
- const maybeTracking = resumeParts[resumeParts.length - 1];
182
- const decodedTracking =
183
- maybeTracking &&
184
- !maybeTracking.startsWith("http") &&
185
- !maybeTracking.includes("/")
186
- ? (() => {
187
- try {
188
- return decodeTrackingContext(maybeTracking);
189
- } catch {
190
- return null;
191
- }
192
- })()
193
- : null;
194
-
195
- const resumeEndpointWithoutTracking =
196
- decodedTracking?.toolName && resumeParts.length > 1
197
- ? resumeParts.slice(0, -1).join("||")
198
- : args.resume_endpoint;
199
-
200
- if (resumeEndpointWithoutTracking.startsWith("http")) {
201
- statusUrl = resumeEndpointWithoutTracking;
202
- responseUrl = resumeEndpointWithoutTracking.replace(/\/status$/, "");
203
- const urlParts = resumeEndpointWithoutTracking.split("/");
181
+ const parts = args.resume_endpoint.split("||");
182
+ statusUrl = parts[0];
183
+
184
+ // Extract tracking context and output_path if present in composite endpoint
185
+ for (let i = 1; i < parts.length; i++) {
186
+ const part = parts[i];
187
+ if (part && (part.includes("/") || part.includes("\\")) && !args.output_path) {
188
+ args.output_path = part;
189
+ }
190
+ }
191
+
192
+ if (statusUrl.startsWith("http")) {
193
+ responseUrl = statusUrl.replace(/\/status$/, "");
194
+ const urlParts = statusUrl.split("/");
204
195
  const lastPart = urlParts[urlParts.length - 1] || "";
205
196
  requestId =
206
197
  lastPart.replace("/status", "") ||
207
198
  urlParts[urlParts.length - 2] ||
208
199
  "unknown";
209
200
  context?.log?.info(
210
- `Resuming with FAL URL: ${resumeEndpointWithoutTracking}`,
201
+ `Resuming with FAL URL: ${statusUrl} (Job ID: ${requestId})`,
211
202
  );
212
203
  } else {
213
204
  // Legacy UUID format - reconstruct URL
214
- requestId = resumeEndpointWithoutTracking;
205
+ requestId = statusUrl;
215
206
  statusUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}/status`;
216
207
  responseUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}`;
217
208
  context?.log?.info(
@@ -404,7 +395,22 @@ export function createToolFromPreset(preset: FalPresetConfig) {
404
395
  responseUrl,
405
396
  "GET",
406
397
  );
407
- return JSON.stringify(sanitizeResponse(finalResult));
398
+
399
+ // Handle saving to output_path if provided
400
+ const sanitizedResult = sanitizeResponse(finalResult);
401
+ if (args.output_path) {
402
+ try {
403
+ const { saveFalResult } = await import("../../utils/fal-save");
404
+ const savedFiles = await saveFalResult(finalResult, args.output_path);
405
+ if (savedFiles.length > 0) {
406
+ (sanitizedResult as any).saved_files = savedFiles;
407
+ }
408
+ } catch (err: any) {
409
+ context?.log?.info(`Failed to save files to ${args.output_path}: ${err.message}`);
410
+ }
411
+ }
412
+
413
+ return JSON.stringify(sanitizedResult);
408
414
  }
409
415
 
410
416
  if (res.status === "FAILED") {
@@ -416,15 +422,22 @@ export function createToolFromPreset(preset: FalPresetConfig) {
416
422
  await wait(POLL_INTERVAL);
417
423
  }
418
424
 
419
- // Timeout - return resume_endpoint
425
+ // Timeout - return composite resume_endpoint
426
+ const trackingPiece = encodeTrackingContext({
427
+ toolName: toolName,
428
+ toolArgs: extractPrimitiveArgs(args),
429
+ requestId: (context as any)?.requestId,
430
+ });
431
+
420
432
  return JSON.stringify({
421
433
  status: "IN_PROGRESS",
422
434
  request_id: requestId,
435
+ // Encode tracking context and output_path so get-status can issue async refunds/saves
423
436
  resume_endpoint: `${statusUrl}||${encodeTrackingContext({
424
437
  toolName,
425
438
  toolArgs: extractPrimitiveArgs(args),
426
439
  requestId: (context as any)?.requestId,
427
- })}`,
440
+ })}||${args.output_path || ""}`,
428
441
  status_url: statusUrl,
429
442
  response_url: responseUrl,
430
443
  message:
@@ -113,6 +113,12 @@ export const falGenerate = {
113
113
  "These override the default values defined in the preset. " +
114
114
  "NOTE: For image-to-video or video-to-video tasks, use 'fal_upload_file' first and pass the resulting CDN URL here.",
115
115
  ),
116
+ output_path: z
117
+ .string()
118
+ .optional()
119
+ .describe(
120
+ "Local path to save the generated file (e.g., 'path/to/image.png')",
121
+ ),
116
122
  resume_endpoint: z
117
123
  .string()
118
124
  .optional()
@@ -127,32 +133,42 @@ export const falGenerate = {
127
133
  preset_name?: string;
128
134
  parameters?: Record<string, any>;
129
135
  resume_endpoint?: string;
136
+ output_path?: string;
130
137
  },
131
138
  context?: ProgressContext,
132
139
  ) => {
133
140
  return safeToolExecute(
134
141
  async () => {
135
- let statusUrl: string;
136
- let responseUrl: string;
137
- let requestId: string;
142
+ let statusUrl: string = "";
143
+ let responseUrl: string = "";
144
+ let requestId: string = "";
138
145
  const config = loadFalConfig();
139
146
 
140
147
  if (args.resume_endpoint) {
141
148
  // Check if resume_endpoint is a full URL (new format) or legacy ID
142
149
  if (args.resume_endpoint.startsWith("http")) {
143
- // NEW: resume_endpoint IS the status/response URL
144
- statusUrl = args.resume_endpoint;
150
+ // NEW: resume_endpoint IS the status/response URL (possibly composite with extra metadata)
151
+ if (args.resume_endpoint.includes("||")) {
152
+ const parts = args.resume_endpoint.split("||");
153
+ statusUrl = parts[0] ?? "";
154
+ // Extract output_path if present in composite endpoint
155
+ if (parts.length >= 3 && !args.output_path) {
156
+ args.output_path = parts[2];
157
+ }
158
+ } else {
159
+ statusUrl = args.resume_endpoint;
160
+ }
145
161
  // Derive responseUrl by removing /status suffix if present
146
- responseUrl = args.resume_endpoint.replace(/\/status$/, "");
162
+ responseUrl = statusUrl.replace(/\/status$/, "");
147
163
  // Extract requestId from URL for logging
148
- const urlParts = args.resume_endpoint.split("/");
164
+ const urlParts = statusUrl.split("/");
149
165
  const lastPart = urlParts[urlParts.length - 1] || "";
150
166
  requestId =
151
167
  lastPart.replace("/status", "") ||
152
168
  urlParts[urlParts.length - 2] ||
153
169
  "unknown";
154
170
  context?.log?.info(
155
- `Resuming with FAL URL: ${args.resume_endpoint}`,
171
+ `Resuming with FAL URL: ${statusUrl} (Job ID: ${requestId})`,
156
172
  );
157
173
  } else {
158
174
  // LEGACY: Try to resolve model from preset_name or parse modelId::requestId
@@ -233,9 +249,7 @@ export const falGenerate = {
233
249
  }
234
250
  }
235
251
  }
236
- context?.log?.info(
237
- `Resuming polling for request: ${args.resume_endpoint}`,
238
- );
252
+ context?.log?.info(`Resuming polling for request: ${statusUrl}`);
239
253
  }
240
254
  } // Close the LEGACY else block (line 149)
241
255
  } else {
@@ -391,6 +405,25 @@ export const falGenerate = {
391
405
  }
392
406
  // responseUrl is now guaranteed to be correct/fresh from polling
393
407
  const finalResult = await authenticatedRequest(responseUrl, "GET");
408
+
409
+ // Handle saving to output_path if provided
410
+ if (args.output_path) {
411
+ try {
412
+ const { saveFalResult } = await import("../../utils/fal-save");
413
+ const savedFiles = await saveFalResult(
414
+ finalResult,
415
+ args.output_path,
416
+ );
417
+ if (savedFiles.length > 0) {
418
+ (finalResult as any).saved_files = savedFiles;
419
+ }
420
+ } catch (err: any) {
421
+ context?.log?.info(
422
+ `Failed to save files to ${args.output_path}: ${err.message}`,
423
+ );
424
+ }
425
+ }
426
+
394
427
  return JSON.stringify(finalResult);
395
428
  }
396
429
 
@@ -430,12 +463,12 @@ export const falGenerate = {
430
463
  return JSON.stringify({
431
464
  status: "IN_PROGRESS",
432
465
  request_id: requestId,
433
- // Encode tracking context so get-status can issue async refunds
466
+ // Encode tracking context and output_path so get-status can issue async refunds/saves
434
467
  resume_endpoint: `${statusUrl}||${encodeTrackingContext({
435
468
  toolName: "fal_generate",
436
469
  toolArgs: extractPrimitiveArgs(args),
437
470
  requestId: (context as any)?.requestId,
438
- })}`,
471
+ })}||${args.output_path || ""}`,
439
472
  status_url: statusUrl,
440
473
  response_url: responseUrl,
441
474
  message:
@@ -314,9 +314,13 @@ export const getGenerationStatus = {
314
314
  .string()
315
315
  .describe(
316
316
  "The resume_endpoint returned by the original generation tool. " +
317
- "For FAL: This is a full URL (starts with 'https://queue.fal.run/...'). " +
317
+ "For FAL: This is a full URL (starts with 'https://queue.fal.run/...') but may include extra metadata via '||' delimiters. " +
318
318
  "For Vertex AI: This is typically the composite string 'fetchUrl||operationName||outputPath' with optional tracking context appended.",
319
319
  ),
320
+ output_path: z
321
+ .string()
322
+ .optional()
323
+ .describe("Optional override for the local path to save the generated file."),
320
324
  source: z
321
325
  .enum(["fal", "vertex", "auto"])
322
326
  .optional()
@@ -330,6 +334,7 @@ export const getGenerationStatus = {
330
334
  args: {
331
335
  resume_endpoint: string;
332
336
  source?: "fal" | "vertex" | "auto";
337
+ output_path?: string;
333
338
  },
334
339
  context?: any,
335
340
  ) => {
@@ -339,34 +344,59 @@ export const getGenerationStatus = {
339
344
  const project_id = "mixio-pro";
340
345
  const location_id = "us-central1";
341
346
 
342
- // 1. Decode generic tracking context if present
347
+ // 1. Decode generic tracking context and extra metadata if present
343
348
  let trackingContext: any = null;
344
349
  let originalEndpoint = resume_endpoint;
350
+ let outputPath = args.output_path;
351
+
345
352
  if (resume_endpoint.includes("||")) {
346
353
  const parts = resume_endpoint.split("||");
347
- // If the last part might be base64 JSON
348
- const lastPart = parts[parts.length - 1];
349
- if (
350
- lastPart &&
351
- !lastPart.startsWith("http") &&
352
- !lastPart.includes("/") &&
353
- !lastPart.includes("mixio-pro") // Ensure it doesn't look like an operation path or URL
354
- ) {
355
- try {
356
- const { decodeTrackingContext } =
357
- await import("../utils/endpoint-encoder");
358
- trackingContext = decodeTrackingContext(lastPart);
359
- if (trackingContext?.toolName) {
360
- // Remove the tracking piece from the endpoint we pass to handlers
361
- originalEndpoint = parts.slice(0, -1).join("||");
362
- // For FAL, the endpoint is just the URL (first part).
363
- // For Vertex, it's the `fetchUrl||operationName||outputPath`.
364
- // For safety, let's keep the remaining parts together.
365
- } else {
366
- trackingContext = null;
367
- originalEndpoint = resume_endpoint;
354
+
355
+ // For FAL jobs starting with https://
356
+ if (parts?.[0]?.startsWith("https://")) {
357
+ originalEndpoint = parts[0];
358
+ // Part 1 is tracking context (base64)
359
+ if (parts.length >= 2 && parts[1]) {
360
+ try {
361
+ const { decodeTrackingContext } = await import("../utils/endpoint-encoder");
362
+ trackingContext = decodeTrackingContext(parts[1]);
363
+ // Try to extract output_path from tracking context if not explicitly provided
364
+ if (!outputPath) {
365
+ outputPath = extractOutputPathFromTrackingContext(trackingContext);
366
+ }
367
+ } catch (err) {}
368
+ }
369
+ // Part 2 is output_path in our new URL||TRACKING||PATH format
370
+ if (parts.length >= 3 && parts[2] && !outputPath) {
371
+ outputPath = parts[2];
372
+ }
373
+ } else {
374
+ // Vertex logic (legacy/other)
375
+ const lastPart = parts[parts.length - 1];
376
+ if (
377
+ lastPart &&
378
+ !lastPart.startsWith("http") &&
379
+ !lastPart.includes("/") &&
380
+ !lastPart.includes("mixio-pro") // Ensure it doesn't look like an operation path or URL
381
+ ) {
382
+ try {
383
+ const { decodeTrackingContext } =
384
+ await import("../utils/endpoint-encoder");
385
+ trackingContext = decodeTrackingContext(lastPart);
386
+ if (trackingContext?.toolName) {
387
+ // Remove the tracking piece from the endpoint we pass to handlers
388
+ originalEndpoint = parts.slice(0, -1).join("||");
389
+ }
390
+ } catch (err) {}
391
+ }
392
+
393
+ // For Vertex, if outputPath is not explicitly provided, it might be in the 3rd part of originalEndpoint
394
+ if (!outputPath) {
395
+ const vertexParts = originalEndpoint.split("||");
396
+ if (vertexParts.length >= 3) {
397
+ outputPath = vertexParts[2];
368
398
  }
369
- } catch (err) {}
399
+ }
370
400
  }
371
401
  }
372
402
 
@@ -386,31 +416,39 @@ export const getGenerationStatus = {
386
416
  let result: any;
387
417
 
388
418
  if (detectedSource === "fal") {
389
- const falStatusResult = await checkFalStatus(originalEndpoint);
390
- const falStatus = falStatusResult?.status;
391
-
392
- if (falStatus === "COMPLETED") {
393
- const falResultUrl = getFalResultUrl(originalEndpoint);
394
- const falFinalResult = await fetchFalResult(falResultUrl);
395
- const falOutputPath = extractOutputPathFromTrackingContext(trackingContext);
396
- const { savedVideos, savedImages } = await saveFalUrls(
397
- falFinalResult,
398
- falOutputPath,
399
- );
400
-
401
- if (savedVideos.length > 0) {
402
- falFinalResult.saved_videos = savedVideos;
403
- }
404
- if (savedImages.length > 0) {
405
- falFinalResult.saved_images = savedImages;
419
+ result = await checkFalStatus(originalEndpoint);
420
+
421
+ if (result.status === "COMPLETED") {
422
+ const falResultUrl = result.response_url || originalEndpoint.replace(/\/status$/, "");
423
+ const finalResult = await fetchFalResult(falResultUrl);
424
+
425
+ // Prefer direct local saving if outputPath is available
426
+ if (outputPath) {
427
+ try {
428
+ const { saveFalResult } = await import("../utils/fal-save");
429
+ const saved = await saveFalResult(finalResult, outputPath);
430
+ if (saved && saved.length > 0) {
431
+ result.saved_files = saved;
432
+ }
433
+ } catch (err: any) {
434
+ logger.error(`[FAL Status] Failed to save result locally to ${outputPath}: ${err.message}`);
435
+ }
436
+ } else {
437
+ // Fallback to standard storage logic (upstream)
438
+ try {
439
+ const { savedVideos, savedImages } = await saveFalUrls(finalResult);
440
+ if (savedVideos.length > 0) finalResult.saved_videos = savedVideos;
441
+ if (savedImages.length > 0) finalResult.saved_images = savedImages;
442
+ } catch (err: any) {
443
+ logger.error(`[FAL Status] Failed to save result to standard storage: ${err.message}`);
444
+ }
406
445
  }
407
446
 
408
447
  result = {
409
- ...falFinalResult,
448
+ ...finalResult,
449
+ saved_files: result.saved_files,
410
450
  status: "COMPLETED",
411
451
  };
412
- } else {
413
- result = falStatusResult;
414
452
  }
415
453
  } else {
416
454
  result = await checkVertexStatus(originalEndpoint);
@@ -444,9 +482,11 @@ export const getGenerationStatus = {
444
482
  result: safeResult,
445
483
  message:
446
484
  status === "COMPLETED"
447
- ? detectedSource === "fal"
448
- ? "Generation completed! Check 'result.saved_videos' or 'result.saved_images' for downloaded asset URLs."
449
- : "Generation completed! Check 'result.response.saved_videos' for the video URLs."
485
+ ? (result.saved_files
486
+ ? `Generation completed! Files saved to ${result.saved_files[0].localPath}`
487
+ : (result.saved_videos || result.saved_images
488
+ ? "Generation completed! Files saved to storage."
489
+ : "Generation completed! Check 'result' for the output URLs."))
450
490
  : status === "FAILED"
451
491
  ? "Generation failed. Check the 'result' field for error details."
452
492
  : "Generation is still in progress. Call this tool again with the same resume_endpoint to check later.",
@@ -0,0 +1,122 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { logger } from "./logger";
4
+
5
+ /**
6
+ * Result of a file save operation.
7
+ */
8
+ export interface SavedFile {
9
+ url: string;
10
+ localPath: string;
11
+ filename: string;
12
+ contentType?: string;
13
+ }
14
+
15
+ /**
16
+ * Clean and normalize a path, handling Windows-style backslashes on POSIX systems.
17
+ */
18
+ export function normalizeLocalPath(filePath: string): string {
19
+ // Replace Windows backslashes with forward slashes for Mac/Linux
20
+ let normalized = filePath.replace(/\\/g, "/");
21
+
22
+ // If it starts with a drive letter (e.g., B:/), we might want to handle it
23
+ // For now, we'll keep it as is, which might resolve relative to CWD if not absolute on Mac
24
+ // But usually, these are absolute paths in the user's environment.
25
+
26
+ return path.normalize(normalized);
27
+ }
28
+
29
+ /**
30
+ * Download a file from a URL and save it to a local path.
31
+ */
32
+ async function downloadFile(url: string, destPath: string): Promise<void> {
33
+ const response = await fetch(url);
34
+ if (!response.ok) {
35
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
36
+ }
37
+
38
+ const arrayBuffer = await response.arrayBuffer();
39
+ const buffer = Buffer.from(arrayBuffer);
40
+
41
+ // Ensure directory exists
42
+ const dir = path.dirname(destPath);
43
+ if (!fs.existsSync(dir)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+
47
+ fs.writeFileSync(destPath, buffer);
48
+ }
49
+
50
+ /**
51
+ * Parse a FAL result and save any images/videos to the specified output path.
52
+ *
53
+ * @param result The JSON result from FAL API
54
+ * @param outputPath The base local path to save to (e.g., "path/to/output.png")
55
+ * @returns Array of saved file information
56
+ */
57
+ export async function saveFalResult(result: any, outputPath: string): Promise<SavedFile[]> {
58
+ const savedFiles: SavedFile[] = [];
59
+ const normalizedPath = normalizeLocalPath(outputPath);
60
+ const ext = path.extname(normalizedPath);
61
+ const dir = path.dirname(normalizedPath);
62
+ const base = path.basename(normalizedPath, ext);
63
+
64
+ // 1. Identify files to download
65
+ const filesToDownload: { url: string; contentType?: string }[] = [];
66
+
67
+ if (Array.isArray(result.images)) {
68
+ for (const img of result.images) {
69
+ if (img.url) filesToDownload.push({ url: img.url, contentType: img.content_type });
70
+ }
71
+ } else if (result.image && result.image.url) {
72
+ filesToDownload.push({ url: result.image.url, contentType: result.image.content_type });
73
+ }
74
+
75
+ if (result.video && result.video.url) {
76
+ filesToDownload.push({ url: result.video.url, contentType: result.video.content_type });
77
+ } else if (Array.isArray(result.videos)) {
78
+ for (const vid of result.videos) {
79
+ if (vid.url) filesToDownload.push({ url: vid.url, contentType: vid.content_type });
80
+ }
81
+ }
82
+
83
+ // Fallback: search for any "url" fields that look like media
84
+ if (filesToDownload.length === 0) {
85
+ const searchUrls = (obj: any) => {
86
+ if (!obj || typeof obj !== "object") return;
87
+ if (obj.url && typeof obj.url === "string" && (obj.url.startsWith("http"))) {
88
+ filesToDownload.push({ url: obj.url, contentType: obj.content_type });
89
+ } else {
90
+ for (const key in obj) searchUrls(obj[key]);
91
+ }
92
+ };
93
+ searchUrls(result);
94
+ }
95
+
96
+ // 2. Download and save
97
+ for (const [i, file] of filesToDownload.entries()) {
98
+ let destPath: string;
99
+
100
+ if (i === 0) {
101
+ destPath = normalizedPath;
102
+ } else {
103
+ // For subsequent files, add an index before the extension
104
+ destPath = path.join(dir, `${base}_${i}${ext}`);
105
+ }
106
+
107
+ logger.info(`[FAL Save] Saving ${file.url} to ${destPath}...`);
108
+ try {
109
+ await downloadFile(file.url, destPath);
110
+ savedFiles.push({
111
+ url: file.url,
112
+ localPath: destPath,
113
+ filename: path.basename(destPath),
114
+ contentType: file.contentType
115
+ });
116
+ } catch (err: any) {
117
+ logger.error(`[FAL Save] Failed to save ${file.url}: ${err.message}`);
118
+ }
119
+ }
120
+
121
+ return savedFiles;
122
+ }