@mixio-pro/kalaasetu-mcp 1.2.2 → 2.0.2-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.
@@ -1,19 +1,25 @@
1
1
  /**
2
2
  * Generate module for fal.ai MCP server.
3
- * Provides tools for generating content and managing queue operations with fal.ai models.
3
+ * Provides tools for generating content using fal.ai models with MCP progress streaming.
4
4
  */
5
5
 
6
6
  import { z } from "zod";
7
7
  import { safeToolExecute } from "../../utils/tool-wrapper";
8
+ import { resolveEnhancer } from "../../utils/prompt-enhancer-presets";
8
9
  import {
9
10
  FAL_QUEUE_URL,
10
- FAL_DIRECT_URL,
11
11
  AUTHENTICATED_TIMEOUT,
12
12
  getApiKey,
13
13
  loadFalConfig,
14
- type FalPresetConfig,
15
14
  } from "./config";
16
15
 
16
+ /**
17
+ * Helper to wait for a specified duration.
18
+ */
19
+ async function wait(ms: number): Promise<void> {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
17
23
  /**
18
24
  * Make an authenticated request to fal.ai API.
19
25
  */
@@ -41,6 +47,12 @@ async function authenticatedRequest(
41
47
 
42
48
  if (!response.ok) {
43
49
  const errorText = await response.text();
50
+ if (response.status === 404) {
51
+ return {
52
+ status: "IN_PROGRESS_WAITING",
53
+ detail: "Request not yet available in queue.",
54
+ };
55
+ }
44
56
  throw new Error(`[${response.status}] API error: ${errorText}`);
45
57
  }
46
58
 
@@ -58,20 +70,38 @@ function sanitizeParameters(
58
70
  );
59
71
  }
60
72
 
73
+ /**
74
+ * Progress reporter interface for MCP context compatibility.
75
+ */
76
+ interface ProgressContext {
77
+ reportProgress?: (progress: {
78
+ progress: number;
79
+ total: number;
80
+ }) => Promise<void>;
81
+ streamContent?: (
82
+ content: { type: "text"; text: string } | { type: "text"; text: string }[]
83
+ ) => Promise<void>;
84
+ log?: {
85
+ info: (message: string, data?: any) => void;
86
+ debug: (message: string, data?: any) => void;
87
+ };
88
+ }
89
+
61
90
  export const falGenerate = {
62
91
  name: "fal_generate",
63
92
  description:
64
93
  "The primary tool for generating AI content (images, videos, etc.) using fal.ai. " +
65
- "This tool follows a 'Preset' pattern: you choose a high-level intent (preset_name) and provide optional parameters. " +
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. " +
66
96
  "Use 'fal_list_presets' to discover available intents and names. " +
67
97
  "PREREQUISITE: If using local files as parameters, you MUST upload them first using 'fal_upload_file' and use the resulting CDN URL. " +
68
- "If a task is expected to take longer than 10-20 seconds, set 'queue: true' to receive status and result URLs instead of a direct result. " +
69
98
  "ONLY USE WHEN WORKING WITH FAL MODELS/PRESETS.",
70
99
  parameters: z.object({
71
100
  preset_name: z
72
101
  .string()
102
+ .optional()
73
103
  .describe(
74
- "The unique name of the generation preset (e.g., 'ltx_image_to_video', 'cinematic_image'). Obtain this from 'fal_list_presets'."
104
+ "Required for new requests. The unique name of the generation preset (e.g., 'ltx_image_to_video'). Obtain this from 'fal_list_presets'."
75
105
  ),
76
106
  parameters: z
77
107
  .record(z.string(), z.any())
@@ -81,120 +111,348 @@ export const falGenerate = {
81
111
  "These override the default values defined in the preset. " +
82
112
  "NOTE: For image-to-video or video-to-video tasks, use 'fal_upload_file' first and pass the resulting CDN URL here."
83
113
  ),
84
- queue: z
85
- .boolean()
114
+ resume_id: z
115
+ .string()
86
116
  .optional()
87
- .default(false)
88
117
  .describe(
89
- "Set to true for asynchronous execution. Use this for high-resolution video or complex tasks. " +
90
- "When true, returns 'status_url' and 'cancel_url' instead of the final result."
118
+ "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."
91
120
  ),
92
121
  }),
93
- timeoutMs: 300000,
94
- execute: async (args: {
95
- preset_name: string;
96
- parameters?: Record<string, any>;
97
- queue?: boolean;
98
- }) => {
122
+ timeoutMs: 90000, // 90 seconds MCP timeout (internal timeout is 60s)
123
+ execute: async (
124
+ args: {
125
+ preset_name?: string;
126
+ parameters?: Record<string, any>;
127
+ resume_id?: string;
128
+ auto_enhance?: boolean;
129
+ },
130
+ context?: ProgressContext
131
+ ) => {
99
132
  return safeToolExecute(async () => {
133
+ let statusUrl: string;
134
+ let responseUrl: string;
135
+ let requestId: string;
100
136
  const config = loadFalConfig();
101
- const preset = config.presets.find(
102
- (p) => p.presetName === args.preset_name
103
- );
104
137
 
105
- if (!preset) {
106
- throw new Error(
107
- `Preset '${args.preset_name}' not found. Use fal_list_presets to see available options.`
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;
143
+ // Derive responseUrl by removing /status suffix if present
144
+ responseUrl = args.resume_id.replace(/\/status$/, "");
145
+ // Extract requestId from URL for logging
146
+ const urlParts = args.resume_id.split("/");
147
+ const lastPart = urlParts[urlParts.length - 1] || "";
148
+ requestId =
149
+ lastPart.replace("/status", "") ||
150
+ urlParts[urlParts.length - 2] ||
151
+ "unknown";
152
+ context?.log?.info(`Resuming with FAL URL: ${args.resume_id}`);
153
+ } else {
154
+ // LEGACY: Try to resolve model from preset_name or parse modelId::requestId
155
+ let modelIdFromPreset: string | undefined;
156
+ if (args.preset_name) {
157
+ const p = config.presets.find(
158
+ (x) => x.presetName === args.preset_name
159
+ );
160
+ if (p) {
161
+ modelIdFromPreset = p.modelId;
162
+ context?.log?.info(
163
+ `Using model from preset '${args.preset_name}': ${modelIdFromPreset}`
164
+ );
165
+ }
166
+ }
167
+
168
+ if (args.resume_id.includes("::")) {
169
+ const parts = args.resume_id.split("::");
170
+ const mId = parts[0];
171
+ const rId = parts[1] || "";
172
+
173
+ requestId = rId;
174
+ // Prefer ID from preset if available (explicit user intent), otherwise use ID from string
175
+ const effectiveModelId = modelIdFromPreset || mId;
176
+
177
+ statusUrl = `${FAL_QUEUE_URL}/${effectiveModelId}/requests/${rId}/status`;
178
+ responseUrl = `${FAL_QUEUE_URL}/${effectiveModelId}/requests/${rId}`;
179
+ context?.log?.info(
180
+ `Resuming polling for ${effectiveModelId} request: ${rId}`
181
+ );
182
+ } else {
183
+ // Legacy/Fallback for raw UUIDs
184
+ requestId = args.resume_id;
185
+
186
+ if (modelIdFromPreset) {
187
+ // Best case: User provided the preset name!
188
+ statusUrl = `${FAL_QUEUE_URL}/${modelIdFromPreset}/requests/${requestId}/status`;
189
+ responseUrl = `${FAL_QUEUE_URL}/${modelIdFromPreset}/requests/${requestId}`;
190
+ context?.log?.info(
191
+ `Resuming polling with explicit model: ${modelIdFromPreset}`
192
+ );
193
+ } else {
194
+ // 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}`;
197
+
198
+ // Verify/Recovery: Check if generic URL works, if not try to guess model
199
+ // ... (Smart recovery logic below)
200
+ }
201
+
202
+ // Verify/Recovery: Check if generic URL works, if not try to guess model
203
+ try {
204
+ await authenticatedRequest(statusUrl, "GET");
205
+ } catch (e: any) {
206
+ if (`${e}`.includes("405") || `${e}`.includes("404")) {
207
+ context?.log?.info(
208
+ "Generic status URL failed, attempting to recover model-specific URL..."
209
+ );
210
+ let found = false;
211
+ for (const p of config.presets) {
212
+ const testUrl = `${FAL_QUEUE_URL}/${p.modelId}/requests/${requestId}/status`;
213
+ try {
214
+ await authenticatedRequest(testUrl, "GET");
215
+ statusUrl = testUrl;
216
+ responseUrl = `${FAL_QUEUE_URL}/${p.modelId}/requests/${requestId}`;
217
+ context?.log?.info(
218
+ `Recovered session using model: ${p.modelId}`
219
+ );
220
+ found = true;
221
+ break;
222
+ } catch (ignore) {}
223
+ }
224
+
225
+ if (!found) {
226
+ context?.log?.info(
227
+ "Could not recover session URL. Assuming generic URL (might fail)."
228
+ );
229
+ }
230
+ }
231
+ }
232
+ context?.log?.info(
233
+ `Resuming polling for request: ${args.resume_id}`
234
+ );
235
+ }
236
+ } // Close the LEGACY else block (line 149)
237
+ } else {
238
+ if (!args.preset_name) {
239
+ throw new Error(
240
+ "preset_name is required when starting a new request."
241
+ );
242
+ }
243
+ // ... (config loading and preset finding code remains same, omitted for brevity but preserved in tool)
244
+ const preset = config.presets.find(
245
+ (p) => p.presetName === args.preset_name
246
+ );
247
+
248
+ if (!preset) {
249
+ throw new Error(
250
+ `Preset '${args.preset_name}' not found. Use fal_list_presets to see available options.`
251
+ );
252
+ }
253
+
254
+ // ... (API key checking and Prompt Enhancement logic remains same)
255
+ try {
256
+ const apiKey = getApiKey();
257
+ if (context?.streamContent) {
258
+ await context.streamContent({
259
+ type: "text" as const,
260
+ text: `[FAL] ✓ API key found (${apiKey.slice(
261
+ 0,
262
+ 8
263
+ )}...). Using preset: ${args.preset_name} → model: ${
264
+ preset.modelId
265
+ }`,
266
+ });
267
+ }
268
+ } catch (keyError: any) {
269
+ // ...
270
+ throw keyError;
271
+ }
272
+
273
+ const mergedParams = {
274
+ ...(preset.defaultParams || {}),
275
+ ...(args.parameters || {}),
276
+ };
277
+
278
+ // ... (Prompt enhancement logic preserved)
279
+ // Copying simplified logic block for replacement context matches
280
+ const shouldEnhance = args.auto_enhance !== false;
281
+ if (shouldEnhance && preset.promptEnhancer && mergedParams.prompt) {
282
+ const enhancerName =
283
+ typeof preset.promptEnhancer === "string"
284
+ ? preset.promptEnhancer
285
+ : null;
286
+ if (enhancerName === "ltx2") {
287
+ // ... (LLM logic)
288
+ const { enhancePromptWithLLM, isLLMEnhancerAvailable } =
289
+ await import("../../utils/llm-prompt-enhancer");
290
+ if (isLLMEnhancerAvailable()) {
291
+ // ...
292
+ try {
293
+ mergedParams.prompt = await enhancePromptWithLLM(
294
+ mergedParams.prompt,
295
+ "ltx2"
296
+ );
297
+ // ...
298
+ } catch (err) {}
299
+ }
300
+ } else {
301
+ // ...
302
+ const enhancer = resolveEnhancer(preset.promptEnhancer);
303
+ if (enhancer.hasTransformations()) {
304
+ mergedParams.prompt = enhancer.enhance(mergedParams.prompt);
305
+ const negatives = enhancer.getNegativeElements();
306
+ if (negatives && !mergedParams.negative_prompt)
307
+ mergedParams.negative_prompt = negatives;
308
+ }
309
+ }
310
+ }
311
+
312
+ const sanitizedParams = sanitizeParameters(mergedParams);
313
+ const url = `${FAL_QUEUE_URL}/${preset.modelId}`;
314
+
315
+ if (context?.streamContent) {
316
+ await context.streamContent({
317
+ type: "text" as const,
318
+ text: `[FAL] Submitting generation request to ${preset.modelId}...`,
319
+ });
320
+ }
321
+
322
+ const queueRes = await authenticatedRequest(
323
+ url,
324
+ "POST",
325
+ sanitizedParams
108
326
  );
327
+
328
+ if (!queueRes.request_id && !queueRes.status_url) {
329
+ return JSON.stringify(queueRes);
330
+ }
331
+
332
+ requestId =
333
+ queueRes.request_id || queueRes.status_url?.split("/").pop() || "";
334
+
335
+ if (!requestId) {
336
+ throw new Error("Could not extract request ID from response");
337
+ }
338
+
339
+ // Prefer explicit status/response URLs from API if available
340
+ statusUrl =
341
+ queueRes.status_url ||
342
+ `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}/status`;
343
+ responseUrl =
344
+ queueRes.response_url ||
345
+ `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}`;
346
+
347
+ // Stream the FULL status URL for reliable resuming
348
+ if (context?.streamContent) {
349
+ await context.streamContent({
350
+ type: "text" as const,
351
+ text: `[FAL] Generation started. resume_id: ${statusUrl} (use this URL to check status)`,
352
+ });
353
+ }
109
354
  }
110
355
 
111
- // Merge defaults from config with runtime overrides
112
- const mergedParams = {
113
- ...(preset.defaultParams || {}),
114
- ...(args.parameters || {}),
115
- };
356
+ // Stream message for resume calls
357
+ if (args.resume_id && context?.streamContent) {
358
+ await context.streamContent({
359
+ type: "text" as const,
360
+ text: `[FAL] Resuming status check for job: ${requestId}`,
361
+ });
362
+ }
116
363
 
117
- const sanitizedParams = sanitizeParameters(mergedParams);
364
+ const startTime = Date.now();
365
+ const MAX_POLL_TIME = 60000; // 60 seconds internal timeout - then return resume_id
366
+ let pollCount = 0;
367
+ const POLL_INTERVAL = 3000;
118
368
 
119
- const baseUrl = args.queue ? FAL_QUEUE_URL : FAL_DIRECT_URL;
120
- const url = `${baseUrl}/${preset.modelId}`;
369
+ while (Date.now() - startTime < MAX_POLL_TIME) {
370
+ pollCount++;
371
+ let res;
372
+ try {
373
+ res = await authenticatedRequest(statusUrl, "GET");
374
+ } catch (e: any) {
375
+ if (`${e}`.includes("405")) {
376
+ context?.log?.info(
377
+ `Status check 405 on ${statusUrl}, trying fallback to responseUrl...`
378
+ );
379
+ // Try checking the request root URL instead of /status
380
+ res = await authenticatedRequest(responseUrl, "GET");
381
+ // If successful, update statusUrl to match for future polls
382
+ statusUrl = responseUrl;
383
+ } else {
384
+ throw e;
385
+ }
386
+ }
121
387
 
122
- console.error(
123
- `[fal_generate] Using preset '${args.preset_name}' on model ${preset.modelId}...`
124
- );
388
+ // Update URLs if the polling response provides better ones
389
+ // This fixes 405 errors if our constructed URL was slightly off
390
+ if (res.status_url) statusUrl = res.status_url;
391
+ if (res.response_url) responseUrl = res.response_url;
125
392
 
126
- const result = await authenticatedRequest(url, "POST", sanitizedParams);
393
+ // Stream progress to MCP client
394
+ if (context?.reportProgress) {
395
+ // ... (progress logic)
396
+ const queuePosition = res.queue_position ?? 0;
397
+ const elapsed = Date.now() - startTime;
398
+ const progressPercent = Math.min(
399
+ Math.round((elapsed / MAX_POLL_TIME) * 100),
400
+ 99
401
+ );
402
+ await context.reportProgress({
403
+ progress: progressPercent,
404
+ total: 100,
405
+ });
406
+ }
127
407
 
128
- return JSON.stringify(result);
129
- }, "fal_generate");
130
- },
131
- };
408
+ if (context?.streamContent && pollCount % 5 === 0) {
409
+ // ...
410
+ await context.streamContent({
411
+ type: "text" as const,
412
+ text: `[FAL] Still processing... (${Math.round(
413
+ (Date.now() - startTime) / 1000
414
+ )}s elapsed, status: ${res.status})`,
415
+ });
416
+ }
132
417
 
133
- /**
134
- * Get the result of a queued request.
135
- */
136
- export const falGetResult = {
137
- name: "fal_get_result",
138
- description:
139
- "Retrieve the final output of a queued/asynchronous fal.ai request. " +
140
- "PREREQUISITE: This tool can ONLY be used with request 'response_url's obtained from 'fal_generate' or 'fal_get_status'. " +
141
- "Only call this after 'fal_get_status' indicates that the request status is 'COMPLETED'. " +
142
- "ONLY USE WHEN WORKING WITH FAL MODELS/PRESETS.",
143
- parameters: z.object({
144
- url: z
145
- .string()
146
- .describe(
147
- "The 'response_url' provided by a queued 'fal_generate' or 'fal_get_status' call."
148
- ),
149
- }),
150
- timeoutMs: 300000,
151
- execute: async (args: { url: string }) => {
152
- const result = await authenticatedRequest(args.url, "GET");
153
- return JSON.stringify(result);
154
- },
155
- };
418
+ if (res.status === "COMPLETED") {
419
+ if (context?.reportProgress) {
420
+ await context.reportProgress({ progress: 100, total: 100 });
421
+ }
422
+ // responseUrl is now guaranteed to be correct/fresh from polling
423
+ const finalResult = await authenticatedRequest(responseUrl, "GET");
424
+ return JSON.stringify(finalResult);
425
+ }
156
426
 
157
- /**
158
- * Check the status of a queued request.
159
- */
160
- export const falGetStatus = {
161
- name: "fal_get_status",
162
- description:
163
- "Check the current progress or status of an asynchronous fal.ai request. " +
164
- "PREREQUISITE: This tool can ONLY be used with request 'status_url's obtained from a queued 'fal_generate' call. " +
165
- "Use this for polling until the status becomes 'COMPLETED', then use 'fal_get_result' for the final output. " +
166
- "ONLY USE WHEN WORKING WITH FAL MODELS/PRESETS.",
167
- parameters: z.object({
168
- url: z
169
- .string()
170
- .describe("The 'status_url' provided by a queued 'fal_generate' call."),
171
- }),
172
- timeoutMs: 300000,
173
- execute: async (args: { url: string }) => {
174
- const result = await authenticatedRequest(args.url, "GET");
175
- return JSON.stringify(result);
176
- },
177
- };
427
+ if (res.status === "FAILED") {
428
+ throw new Error(
429
+ `Generation failed: ${JSON.stringify(res.error || res)}`
430
+ );
431
+ }
178
432
 
179
- /**
180
- * Cancel a queued request.
181
- */
182
- export const falCancelRequest = {
183
- name: "fal_cancel_request",
184
- description:
185
- "Terminate and cancel an ongoing asynchronous fal.ai request. " +
186
- "PREREQUISITE: This tool can ONLY be used with request 'cancel_url's obtained from a queued 'fal_generate' call. " +
187
- "ONLY USE WHEN WORKING WITH FAL MODELS/PRESETS.",
188
- parameters: z.object({
189
- url: z
190
- .string()
191
- .describe("The 'cancel_url' provided by a queued 'fal_generate' call."),
192
- }),
193
- timeoutMs: 300000,
194
- execute: async (args: { url: string }) => {
195
- return safeToolExecute(async () => {
196
- const result = await authenticatedRequest(args.url, "PUT");
197
- return JSON.stringify(result);
198
- }, "fal_cancel_request");
433
+ await wait(POLL_INTERVAL);
434
+ }
435
+
436
+ // Timeout - return composite resume_id
437
+ // We need to know modelId here. If we started new, we have 'preset'
438
+ // If we resumed, we parsed 'mId' or used raw.
439
+ const currentModelId =
440
+ args.resume_id && args.resume_id.includes("::")
441
+ ? args.resume_id.split("::")[0]
442
+ : args.preset_name
443
+ ? config.presets.find((p) => p.presetName === args.preset_name)
444
+ ?.modelId
445
+ : "unknown";
446
+
447
+ return JSON.stringify({
448
+ status: "IN_PROGRESS",
449
+ request_id: requestId,
450
+ resume_id: statusUrl, // Use the FULL URL for reliable resume
451
+ status_url: statusUrl,
452
+ response_url: responseUrl,
453
+ message:
454
+ "The generation is still in progress. Call this tool again with resume_id (the URL) to continue polling.",
455
+ });
456
+ }, "fal_generate");
199
457
  },
200
458
  };
@@ -3,12 +3,7 @@
3
3
  * Export all fal.ai tools for registration with the MCP server.
4
4
  */
5
5
 
6
- export { falListPresets, falGetPresetDetails, falGetConfig } from "./models";
7
- export {
8
- falGenerate,
9
- falGetResult,
10
- falGetStatus,
11
- falCancelRequest,
12
- } from "./generate";
6
+ export { falListPresets, falGetPresetDetails } from "./models";
7
+ export { falGenerate } from "./generate";
13
8
  export { falUploadFile } from "./storage";
14
9
  export { SERVER_NAME, SERVER_DESCRIPTION, SERVER_VERSION } from "./config";