@mixio-pro/kalaasetu-mcp 2.0.6-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 +1 -1
- package/src/index.ts +12 -7
- package/src/tools/fal/dynamic-tools.ts +406 -0
- package/src/tools/fal/generate.ts +3 -5
- package/src/tools/fal/models.ts +3 -1
- package/src/tools/get-status.ts +18 -20
- package/src/tools/image-to-video.ts +38 -73
- package/src/utils/sanitize.ts +35 -0
package/package.json
CHANGED
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
|
-
|
|
40
|
-
|
|
41
|
-
// Fal AI Tools
|
|
40
|
+
// Discovery and Utility Tools
|
|
42
41
|
server.addTool(falListPresets);
|
|
43
|
-
server.addTool(
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
|
422
|
+
const finalResult = await authenticatedRequest(responseUrl, "GET");
|
|
425
423
|
return JSON.stringify(finalResult);
|
|
426
424
|
}
|
|
427
425
|
|
package/src/tools/fal/models.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/tools/get-status.ts
CHANGED
|
@@ -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 ||
|
|
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!
|
|
215
|
+
? "Generation completed! Check 'result.response.saved_videos' for the video URLs."
|
|
217
216
|
: status === "FAILED"
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
:
|
|
567
|
-
|
|
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
|
+
}
|