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