@meshy-ai/meshy-mcp-server 0.2.0

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.
Files changed (58) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +108 -0
  4. package/dist/constants.d.ts +123 -0
  5. package/dist/constants.js +169 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.js +130 -0
  8. package/dist/instructions.d.ts +6 -0
  9. package/dist/instructions.js +90 -0
  10. package/dist/schemas/balance.d.ts +11 -0
  11. package/dist/schemas/balance.js +8 -0
  12. package/dist/schemas/common.d.ts +38 -0
  13. package/dist/schemas/common.js +52 -0
  14. package/dist/schemas/generation.d.ts +219 -0
  15. package/dist/schemas/generation.js +217 -0
  16. package/dist/schemas/image.d.ts +55 -0
  17. package/dist/schemas/image.js +46 -0
  18. package/dist/schemas/output.d.ts +75 -0
  19. package/dist/schemas/output.js +41 -0
  20. package/dist/schemas/postprocessing.d.ts +135 -0
  21. package/dist/schemas/postprocessing.js +123 -0
  22. package/dist/schemas/printing.d.ts +63 -0
  23. package/dist/schemas/printing.js +54 -0
  24. package/dist/schemas/tasks.d.ts +123 -0
  25. package/dist/schemas/tasks.js +85 -0
  26. package/dist/services/error-handler.d.ts +32 -0
  27. package/dist/services/error-handler.js +141 -0
  28. package/dist/services/file-utils.d.ts +15 -0
  29. package/dist/services/file-utils.js +55 -0
  30. package/dist/services/meshy-client.d.ts +54 -0
  31. package/dist/services/meshy-client.js +172 -0
  32. package/dist/services/output-manager.d.ts +52 -0
  33. package/dist/services/output-manager.js +284 -0
  34. package/dist/tools/balance.d.ts +9 -0
  35. package/dist/tools/balance.js +61 -0
  36. package/dist/tools/generation.d.ts +9 -0
  37. package/dist/tools/generation.js +419 -0
  38. package/dist/tools/image.d.ts +9 -0
  39. package/dist/tools/image.js +154 -0
  40. package/dist/tools/postprocessing.d.ts +9 -0
  41. package/dist/tools/postprocessing.js +405 -0
  42. package/dist/tools/printing.d.ts +9 -0
  43. package/dist/tools/printing.js +338 -0
  44. package/dist/tools/tasks.d.ts +9 -0
  45. package/dist/tools/tasks.js +1074 -0
  46. package/dist/tools/workspace.d.ts +9 -0
  47. package/dist/tools/workspace.js +161 -0
  48. package/dist/types.d.ts +261 -0
  49. package/dist/types.js +4 -0
  50. package/dist/utils/endpoints.d.ts +16 -0
  51. package/dist/utils/endpoints.js +38 -0
  52. package/dist/utils/request-builder.d.ts +15 -0
  53. package/dist/utils/request-builder.js +24 -0
  54. package/dist/utils/response-formatter.d.ts +27 -0
  55. package/dist/utils/response-formatter.js +37 -0
  56. package/dist/utils/slicer-detector.d.ts +29 -0
  57. package/dist/utils/slicer-detector.js +237 -0
  58. package/package.json +64 -0
@@ -0,0 +1,1074 @@
1
+ /**
2
+ * Task management tools (get status with wait mode, list, cancel, download)
3
+ */
4
+ import axios from "axios";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import { getTaskWithAutoInference } from "../services/meshy-client.js";
8
+ import { handleMeshyError } from "../services/error-handler.js";
9
+ import { GetTaskStatusInputSchema, ListTasksInputSchema, CancelTaskInputSchema, DownloadModelInputSchema } from "../schemas/tasks.js";
10
+ import { TaskStatusOutputSchema } from "../schemas/output.js";
11
+ import { ResponseFormat, TaskStatus, TaskType, CHARACTER_LIMIT, POLL_INITIAL_DELAY, POLL_MAX_DELAY, POLL_BACKOFF_FACTOR, POLL_FINALIZATION_DELAY } from "../constants.js";
12
+ import { getTaskEndpoint, LIST_CAPABLE_TASK_TYPES } from "../utils/endpoints.js";
13
+ import { resolveProjectDir, getFilePath, getTextureFilePath, inferStage, recordTask, saveThumbnail } from "../services/output-manager.js";
14
+ /**
15
+ * Download a file from URL to local path using streaming.
16
+ * Returns file size in bytes.
17
+ */
18
+ /**
19
+ * Fix OBJ file for 3D printing: Y-up → Z-up rotation, scale, center, bottom at Z=0.
20
+ */
21
+ function fixObjForPrinting(filePath, targetHeightMm = 75) {
22
+ const content = fs.readFileSync(filePath, "utf-8");
23
+ const lines = content.split("\n");
24
+ const entries = [];
25
+ let minX = Infinity, maxX = -Infinity;
26
+ let minY = Infinity, maxY = -Infinity;
27
+ let minZ = Infinity, maxZ = -Infinity;
28
+ for (const line of lines) {
29
+ if (line.startsWith("v ")) {
30
+ const parts = line.split(/\s+/);
31
+ const x = parseFloat(parts[1]);
32
+ const y = parseFloat(parts[2]);
33
+ const z = parseFloat(parts[3]);
34
+ // Y-up to Z-up: (x, y, z) → (x, -z, y)
35
+ const rx = x, ry = -z, rz = y;
36
+ minX = Math.min(minX, rx);
37
+ maxX = Math.max(maxX, rx);
38
+ minY = Math.min(minY, ry);
39
+ maxY = Math.max(maxY, ry);
40
+ minZ = Math.min(minZ, rz);
41
+ maxZ = Math.max(maxZ, rz);
42
+ entries.push({ tag: "v", x: rx, y: ry, z: rz, extra: parts.slice(4).join(" ") });
43
+ }
44
+ else if (line.startsWith("vn ")) {
45
+ const parts = line.split(/\s+/);
46
+ const nx = parseFloat(parts[1]);
47
+ const ny = parseFloat(parts[2]);
48
+ const nz = parseFloat(parts[3]);
49
+ entries.push({ tag: "vn", x: nx, y: -nz, z: ny });
50
+ }
51
+ else {
52
+ entries.push({ tag: "line", text: line });
53
+ }
54
+ }
55
+ const height = maxZ - minZ;
56
+ const scale = height > 1e-6 ? targetHeightMm / height : 1.0;
57
+ const xOff = -(minX + maxX) / 2 * scale;
58
+ const yOff = -(minY + maxY) / 2 * scale;
59
+ const zOff = -(minZ * scale);
60
+ const output = [];
61
+ for (const entry of entries) {
62
+ if (entry.tag === "v") {
63
+ const tx = entry.x * scale + xOff;
64
+ const ty = entry.y * scale + yOff;
65
+ const tz = entry.z * scale + zOff;
66
+ const extra = entry.extra ? ` ${entry.extra}` : "";
67
+ output.push(`v ${tx.toFixed(6)} ${ty.toFixed(6)} ${tz.toFixed(6)}${extra}`);
68
+ }
69
+ else if (entry.tag === "vn") {
70
+ output.push(`vn ${entry.x.toFixed(6)} ${entry.y.toFixed(6)} ${entry.z.toFixed(6)}`);
71
+ }
72
+ else {
73
+ output.push(entry.text);
74
+ }
75
+ }
76
+ fs.writeFileSync(filePath, output.join("\n"), "utf-8");
77
+ console.error(`OBJ fixed for printing: Y-up→Z-up, ${targetHeightMm}mm, centered, bottom at Z=0`);
78
+ }
79
+ async function downloadFileToLocal(url, saveTo) {
80
+ // Ensure directory exists
81
+ const dir = path.dirname(saveTo);
82
+ if (!fs.existsSync(dir)) {
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ }
85
+ const response = await axios.get(url, {
86
+ responseType: "stream",
87
+ timeout: 120000 // 120s timeout
88
+ });
89
+ const writer = fs.createWriteStream(saveTo);
90
+ response.data.pipe(writer);
91
+ return new Promise((resolve, reject) => {
92
+ writer.on("finish", () => {
93
+ const stats = fs.statSync(saveTo);
94
+ resolve(stats.size);
95
+ });
96
+ writer.on("error", reject);
97
+ });
98
+ }
99
+ function sleep(ms) {
100
+ return new Promise(resolve => setTimeout(resolve, ms));
101
+ }
102
+ /**
103
+ * Render a progress bar
104
+ */
105
+ function renderProgressBar(progress, width = 20) {
106
+ const clamped = Math.max(0, Math.min(100, progress));
107
+ const filled = Math.round((clamped / 100) * width);
108
+ return `[${'█'.repeat(filled)}${'░'.repeat(width - filled)}] ${clamped}%`;
109
+ }
110
+ /**
111
+ * Format task for display (single-poll mode)
112
+ */
113
+ function formatTask(task, format) {
114
+ if (format === ResponseFormat.MARKDOWN) {
115
+ const lines = [`# Task: ${task.id}`, ""];
116
+ const progress = task.progress || 0;
117
+ lines.push(`${renderProgressBar(progress)} — ${task.status}`);
118
+ lines.push("");
119
+ lines.push(`**Phase**: ${task.phase}`);
120
+ lines.push(`**Created**: ${new Date(task.created_at).toLocaleString()}`);
121
+ if (task.updated_at)
122
+ lines.push(`**Updated**: ${new Date(task.updated_at).toLocaleString()}`);
123
+ lines.push("");
124
+ if (task.status === TaskStatus.SUCCEEDED && task.model_urls) {
125
+ lines.push("## Result");
126
+ const formats = Object.keys(task.model_urls).filter(k => task.model_urls[k]);
127
+ lines.push(`- **Available Formats**: ${formats.join(', ').toUpperCase()}`);
128
+ if (task.thumbnail_url)
129
+ lines.push(`- **Thumbnail**: ${task.thumbnail_url}`);
130
+ if (task.vertex_count && task.face_count) {
131
+ lines.push(`- **Vertices**: ${task.vertex_count.toLocaleString()}`);
132
+ lines.push(`- **Faces**: ${task.face_count.toLocaleString()}`);
133
+ }
134
+ lines.push("");
135
+ // Handle texture_urls — can be array of objects or single object
136
+ const textures = task.texture_urls;
137
+ if (textures && !Array.isArray(textures)) {
138
+ lines.push("## Textures");
139
+ if (textures.base_color)
140
+ lines.push(`- Base Color: Available`);
141
+ if (textures.metallic)
142
+ lines.push(`- Metallic: Available`);
143
+ if (textures.roughness)
144
+ lines.push(`- Roughness: Available`);
145
+ if (textures.normal)
146
+ lines.push(`- Normal Map: Available`);
147
+ lines.push("");
148
+ }
149
+ else if (Array.isArray(textures) && textures.length > 0) {
150
+ lines.push("## Textures");
151
+ const first = textures[0];
152
+ if (first.base_color)
153
+ lines.push(`- Base Color: Available`);
154
+ if (first.metallic)
155
+ lines.push(`- Metallic: Available`);
156
+ if (first.roughness)
157
+ lines.push(`- Roughness: Available`);
158
+ if (first.normal)
159
+ lines.push(`- Normal Map: Available`);
160
+ lines.push("");
161
+ }
162
+ lines.push("**Next Steps**: Use `meshy_download_model` to get download URLs.");
163
+ }
164
+ else if (task.status === TaskStatus.IN_PROGRESS) {
165
+ if (progress >= 95) {
166
+ lines.push("The task is in finalization (this is normal and can take 30-120s). Do NOT cancel.");
167
+ lines.push("");
168
+ lines.push("**TIP**: Use `meshy_get_task_status` with wait=true (default) to auto-wait until completion.");
169
+ }
170
+ else {
171
+ lines.push("The task is still processing.");
172
+ lines.push("");
173
+ lines.push("**TIP**: Use `meshy_get_task_status` with wait=true (default) to auto-wait until completion.");
174
+ }
175
+ }
176
+ else if (task.status === TaskStatus.PENDING) {
177
+ lines.push("The task is queued and will start processing soon.");
178
+ lines.push("");
179
+ lines.push("**TIP**: Use `meshy_get_task_status` with wait=true (default) to auto-wait until completion.");
180
+ }
181
+ else if (task.status === TaskStatus.FAILED && task.task_error) {
182
+ lines.push(`## Error`);
183
+ if (task.task_error.code)
184
+ lines.push(`**Code**: ${task.task_error.code}`);
185
+ lines.push(`**Message**: ${task.task_error.message}`);
186
+ }
187
+ return lines.join("\n");
188
+ }
189
+ else {
190
+ return JSON.stringify(task, null, 2);
191
+ }
192
+ }
193
+ /**
194
+ * Build a rich success response with progress bar for wait mode
195
+ */
196
+ function buildWaitSuccessResponse(task, taskId, taskType, waitTimeSec, pollCount) {
197
+ const lines = [
198
+ `# Task Completed`,
199
+ "",
200
+ `${renderProgressBar(100)} — SUCCEEDED (${waitTimeSec}s, ${pollCount} polls)`,
201
+ "",
202
+ `**Task ID**: ${taskId}`,
203
+ ""
204
+ ];
205
+ // Model info
206
+ if (task.vertex_count && task.face_count) {
207
+ lines.push(`## Model Info`);
208
+ lines.push(`- **Vertices**: ${task.vertex_count.toLocaleString()}`);
209
+ lines.push(`- **Faces**: ${task.face_count.toLocaleString()}`);
210
+ lines.push("");
211
+ }
212
+ // Available formats
213
+ if (task.model_urls) {
214
+ const formats = Object.keys(task.model_urls).filter(k => task.model_urls[k]);
215
+ if (formats.length > 0) {
216
+ lines.push(`## Available Formats`);
217
+ lines.push(`${formats.join(", ").toUpperCase()}`);
218
+ lines.push("");
219
+ }
220
+ }
221
+ // Rigging results
222
+ if (taskType === TaskType.RIGGING && task.result) {
223
+ lines.push("## Rigging Result");
224
+ if (task.result.rigged_character_glb_url)
225
+ lines.push("- Rigged character: GLB available");
226
+ if (task.result.rigged_character_fbx_url)
227
+ lines.push("- Rigged character: FBX available");
228
+ if (task.result.basic_animations) {
229
+ lines.push("- **Walking animation**: included FREE");
230
+ lines.push("- **Running animation**: included FREE");
231
+ }
232
+ lines.push("");
233
+ lines.push("**NOTE**: Walking and running animations are included FREE with rigging. Do NOT call `meshy_animate` for these — only use it for CUSTOM animations (3 credits each).");
234
+ lines.push("");
235
+ }
236
+ // Animation results
237
+ if (taskType === TaskType.ANIMATION && task.result) {
238
+ lines.push("## Animation Result");
239
+ if (task.result.animation_glb_url)
240
+ lines.push("- Animation GLB available");
241
+ if (task.result.animation_fbx_url)
242
+ lines.push("- Animation FBX available");
243
+ lines.push("");
244
+ }
245
+ // Next steps
246
+ lines.push("**Next Steps**: Use `meshy_download_model` with task_id \"" + taskId + "\"" +
247
+ (taskType !== TaskType.TEXT_TO_3D ? ` and task_type "${taskType}"` : "") +
248
+ " to get download URLs.");
249
+ const modelUrls = {};
250
+ if (task.model_urls) {
251
+ for (const [k, v] of Object.entries(task.model_urls)) {
252
+ if (v)
253
+ modelUrls[k] = v;
254
+ }
255
+ }
256
+ return {
257
+ content: [{ type: "text", text: lines.join("\n") }],
258
+ structuredContent: {
259
+ outcome: "SUCCEEDED",
260
+ task_id: taskId,
261
+ status: "SUCCEEDED",
262
+ progress: 100,
263
+ wait_time_seconds: waitTimeSec,
264
+ poll_count: pollCount,
265
+ model_urls: Object.keys(modelUrls).length > 0 ? modelUrls : undefined,
266
+ vertex_count: task.vertex_count,
267
+ face_count: task.face_count
268
+ }
269
+ };
270
+ }
271
+ /**
272
+ * Register task management tools with the MCP server
273
+ */
274
+ export function registerTaskTools(server, client) {
275
+ // Get task status tool (with optional wait mode)
276
+ server.registerTool("meshy_get_task_status", {
277
+ title: "Get Task Status",
278
+ description: `Check task status or wait for completion. Supports two modes:
279
+
280
+ **wait=true (default)**: Auto-polls with exponential backoff (5s→30s, 15s at 95%+) until SUCCEEDED/FAILED/TIMEOUT. Returns full result with progress bar. Recommended — call once and get the final result.
281
+
282
+ **wait=false**: Returns current status immediately (single query) with progress bar.
283
+
284
+ Args:
285
+ - task_id (string): Task ID returned from generation tools (required)
286
+ - task_type (enum, optional): Task type for endpoint routing (default: "text-to-3d"). Auto-infers if wrong.
287
+ - wait (boolean): Auto-wait until completion (default: true)
288
+ - timeout_seconds (number): Max wait time when wait=true (default: 300, max: 300)
289
+ - response_format (enum): "markdown" or "json" (default: "markdown")
290
+
291
+ Returns (wait=true, SUCCEEDED):
292
+ Progress bar + full task data + model info + download instructions
293
+
294
+ Returns (wait=false):
295
+ Progress bar + current status snapshot
296
+
297
+ Examples:
298
+ - Auto-wait: { task_id: "abc-123" }
299
+ - Quick check: { task_id: "abc-123", wait: false }
300
+ - Image-to-3d: { task_id: "abc-123", task_type: "image-to-3d" }
301
+ - Short timeout: { task_id: "abc-123", timeout_seconds: 60 }`,
302
+ inputSchema: GetTaskStatusInputSchema,
303
+ outputSchema: TaskStatusOutputSchema,
304
+ annotations: {
305
+ readOnlyHint: true,
306
+ destructiveHint: false,
307
+ idempotentHint: true,
308
+ openWorldHint: true
309
+ }
310
+ }, async (params, extra) => {
311
+ const preferredEndpoint = getTaskEndpoint(params.task_type);
312
+ // --- wait=false: single query mode ---
313
+ if (!params.wait) {
314
+ try {
315
+ const { task } = await getTaskWithAutoInference(client, params.task_id, preferredEndpoint);
316
+ const textContent = formatTask(task, params.response_format);
317
+ const modelUrls = {};
318
+ if (task.model_urls) {
319
+ for (const [k, v] of Object.entries(task.model_urls)) {
320
+ if (v)
321
+ modelUrls[k] = v;
322
+ }
323
+ }
324
+ return {
325
+ content: [{ type: "text", text: textContent }],
326
+ structuredContent: {
327
+ outcome: task.status,
328
+ task_id: params.task_id,
329
+ status: task.status,
330
+ progress: task.progress || 0,
331
+ model_urls: Object.keys(modelUrls).length > 0 ? modelUrls : undefined,
332
+ vertex_count: task.vertex_count,
333
+ face_count: task.face_count,
334
+ error_code: task.task_error?.code,
335
+ error_message: task.task_error?.message
336
+ }
337
+ };
338
+ }
339
+ catch (error) {
340
+ return {
341
+ isError: true,
342
+ content: [{ type: "text", text: handleMeshyError(error) }]
343
+ };
344
+ }
345
+ }
346
+ // --- wait=true: polling mode ---
347
+ const timeoutMs = params.timeout_seconds * 1000;
348
+ const startTime = Date.now();
349
+ let pollCount = 0;
350
+ let currentDelay = POLL_INITIAL_DELAY;
351
+ let resolvedEndpoint = preferredEndpoint;
352
+ try {
353
+ while (true) {
354
+ pollCount++;
355
+ let task;
356
+ // First poll: use auto-inference to resolve the correct endpoint
357
+ if (pollCount === 1) {
358
+ const result = await getTaskWithAutoInference(client, params.task_id, preferredEndpoint);
359
+ task = result.task;
360
+ resolvedEndpoint = result.endpoint;
361
+ }
362
+ else {
363
+ task = await client.get(`${resolvedEndpoint}/${params.task_id}`);
364
+ }
365
+ const progress = task.progress || 0;
366
+ // Send progress notification via logging (primary channel)
367
+ try {
368
+ server.sendLoggingMessage({
369
+ level: "info",
370
+ data: `${renderProgressBar(progress)} — ${task.status} (poll #${pollCount})`
371
+ });
372
+ }
373
+ catch {
374
+ // Logging not critical
375
+ }
376
+ // Send structured progress notification (enhanced channel for clients that support it)
377
+ if (extra._meta?.progressToken !== undefined) {
378
+ try {
379
+ await server.server.notification({
380
+ method: "notifications/progress",
381
+ params: {
382
+ progressToken: extra._meta.progressToken,
383
+ progress: progress,
384
+ total: 100,
385
+ message: `${task.status} - ${renderProgressBar(progress)}`
386
+ }
387
+ });
388
+ }
389
+ catch {
390
+ // Client may not support progress notifications, silently ignore
391
+ }
392
+ }
393
+ // Terminal: SUCCEEDED
394
+ if (task.status === TaskStatus.SUCCEEDED) {
395
+ const waitTimeSec = Math.round((Date.now() - startTime) / 1000);
396
+ return buildWaitSuccessResponse(task, params.task_id, params.task_type, waitTimeSec, pollCount);
397
+ }
398
+ // Terminal: FAILED
399
+ if (task.status === TaskStatus.FAILED) {
400
+ const waitTimeSec = Math.round((Date.now() - startTime) / 1000);
401
+ const errorMsg = task.task_error?.message || "Unknown error";
402
+ const errorCode = task.task_error?.code || "";
403
+ return {
404
+ isError: true,
405
+ content: [{
406
+ type: "text",
407
+ text: `# Task Failed
408
+
409
+ ${renderProgressBar(progress)} — FAILED (${waitTimeSec}s, ${pollCount} polls)
410
+
411
+ **Task ID**: ${params.task_id}
412
+ **Error**: ${errorCode ? `[${errorCode}] ` : ""}${errorMsg}
413
+
414
+ The task failed during processing. You may want to retry with different parameters.`
415
+ }],
416
+ structuredContent: {
417
+ outcome: "FAILED",
418
+ task_id: params.task_id,
419
+ status: "FAILED",
420
+ progress,
421
+ error_code: errorCode || undefined,
422
+ error_message: errorMsg,
423
+ wait_time_seconds: waitTimeSec,
424
+ poll_count: pollCount
425
+ }
426
+ };
427
+ }
428
+ // Terminal: CANCELED
429
+ if (task.status === TaskStatus.CANCELED) {
430
+ const waitTimeSec = Math.round((Date.now() - startTime) / 1000);
431
+ return {
432
+ content: [{
433
+ type: "text",
434
+ text: `# Task Canceled
435
+
436
+ ${renderProgressBar(progress)} — CANCELED (${waitTimeSec}s, ${pollCount} polls)
437
+
438
+ **Task ID**: ${params.task_id}`
439
+ }],
440
+ structuredContent: {
441
+ outcome: "CANCELED",
442
+ task_id: params.task_id,
443
+ status: "CANCELED",
444
+ progress,
445
+ wait_time_seconds: waitTimeSec,
446
+ poll_count: pollCount
447
+ }
448
+ };
449
+ }
450
+ // Check timeout before sleeping
451
+ const elapsed = Date.now() - startTime;
452
+ if (elapsed + currentDelay > timeoutMs) {
453
+ const waitTimeSec = Math.round(elapsed / 1000);
454
+ return {
455
+ isError: true,
456
+ content: [{
457
+ type: "text",
458
+ text: `# Task Timeout
459
+
460
+ ${renderProgressBar(progress)} — ${task.status} (${waitTimeSec}s, ${pollCount} polls)
461
+
462
+ **Task ID**: ${params.task_id}
463
+ **Timeout**: ${params.timeout_seconds}s exceeded
464
+
465
+ The task is still processing. You can:
466
+ 1. Call \`meshy_get_task_status\` again with a longer timeout_seconds
467
+ 2. Call with wait=false to check status manually`
468
+ }],
469
+ structuredContent: {
470
+ outcome: "TIMEOUT",
471
+ task_id: params.task_id,
472
+ status: task.status,
473
+ progress,
474
+ wait_time_seconds: waitTimeSec,
475
+ poll_count: pollCount
476
+ }
477
+ };
478
+ }
479
+ // Determine delay: fixed 15s during finalization, otherwise backoff
480
+ const delayToUse = progress >= 95 ? POLL_FINALIZATION_DELAY : currentDelay;
481
+ await sleep(delayToUse);
482
+ // Increase delay for next iteration (backoff)
483
+ if (progress < 95) {
484
+ currentDelay = Math.min(currentDelay * POLL_BACKOFF_FACTOR, POLL_MAX_DELAY);
485
+ }
486
+ }
487
+ }
488
+ catch (error) {
489
+ const waitTimeSec = Math.round((Date.now() - startTime) / 1000);
490
+ return {
491
+ isError: true,
492
+ content: [{
493
+ type: "text",
494
+ text: `${handleMeshyError(error)}\n\n**Wait Time**: ${waitTimeSec}s (${pollCount} polls)\n\nYou can retry with \`meshy_get_task_status\` or use wait=false to check manually.`
495
+ }]
496
+ };
497
+ }
498
+ });
499
+ // List tasks tool
500
+ server.registerTool("meshy_list_tasks", {
501
+ title: "List Generation Tasks",
502
+ description: `List tasks across all task types with filtering and pagination.
503
+
504
+ Queries one or all task types. When task_type is omitted, queries ALL 7 list-capable endpoints in parallel and merges results sorted by creation time.
505
+
506
+ Args:
507
+ - task_type (enum, optional): Filter by task type. If omitted, queries ALL types and merges results.
508
+ Supported: "text-to-3d", "image-to-3d", "multi-image-to-3d", "remesh", "retexture", "text-to-image", "image-to-image"
509
+ Note: "rigging" and "animation" do NOT have list endpoints.
510
+ - sort_by (enum): Sort by creation time - "-created_at" (newest first, default) or "+created_at" (oldest first)
511
+ - status (enum, optional): Filter by status - "PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "CANCELED"
512
+ - phase (enum, optional): Filter by phase - "draft", "generate", "texture", "stylize", "animate"
513
+ - limit (number): Results per page per endpoint, 1-50 (default: 20). When querying all types, total results may be up to 7 × limit.
514
+ - offset (number): Skip N results for pagination (default: 0)
515
+ - response_format (enum): Output format - "markdown" or "json" (default: "markdown")
516
+
517
+ Returns:
518
+ {
519
+ "page_count": 20, // Tasks in this response
520
+ "offset": 0,
521
+ "tasks": [ { task_type: "text-to-3d", ... }, ... ],
522
+ "has_more": true,
523
+ "next_offset": 20
524
+ }
525
+
526
+ Pagination:
527
+ To get next page, use the returned next_offset value.
528
+
529
+ Examples:
530
+ - List all recent tasks: { limit: 10 }
531
+ - List only image-to-3d: { task_type: "image-to-3d", limit: 20 }
532
+ - List successful tasks: { status: "SUCCEEDED", limit: 20 }
533
+ - Get second page: { limit: 20, offset: 20 }
534
+
535
+ Error Handling:
536
+ - Automatically truncates if response exceeds size limit
537
+ - Partial endpoint failures are tolerated (uses Promise.allSettled)`,
538
+ inputSchema: ListTasksInputSchema,
539
+ annotations: {
540
+ readOnlyHint: true,
541
+ destructiveHint: false,
542
+ idempotentHint: true,
543
+ openWorldHint: true
544
+ }
545
+ }, async (params) => {
546
+ try {
547
+ const pageSize = Math.min(params.limit, 50); // API max is 50
548
+ const pageNum = Math.floor(params.offset / pageSize) + 1;
549
+ const baseQueryParams = {
550
+ page_size: pageSize,
551
+ page_num: pageNum,
552
+ sort_by: params.sort_by
553
+ };
554
+ if (params.status)
555
+ baseQueryParams.status = params.status;
556
+ if (params.phase)
557
+ baseQueryParams.phase = params.phase;
558
+ // Determine which endpoints to query
559
+ const taskTypesToQuery = params.task_type
560
+ ? [params.task_type]
561
+ : LIST_CAPABLE_TASK_TYPES;
562
+ let allTasks = [];
563
+ if (taskTypesToQuery.length === 1) {
564
+ // Single endpoint query
565
+ const endpoint = getTaskEndpoint(taskTypesToQuery[0]);
566
+ const tasks = await client.get(endpoint, baseQueryParams);
567
+ const taskList = Array.isArray(tasks) ? tasks : [];
568
+ allTasks = taskList.map(task => ({
569
+ id: task.id,
570
+ name: task.name || task.prompt || "Untitled",
571
+ task_type: taskTypesToQuery[0],
572
+ status: task.status,
573
+ progress: task.progress,
574
+ phase: task.phase,
575
+ created_at: task.created_at,
576
+ vertex_count: task.vertex_count,
577
+ face_count: task.face_count
578
+ }));
579
+ }
580
+ else {
581
+ // Parallel query across all list-capable endpoints
582
+ const results = await Promise.allSettled(taskTypesToQuery.map(async (tt) => {
583
+ const endpoint = getTaskEndpoint(tt);
584
+ const tasks = await client.get(endpoint, baseQueryParams);
585
+ const taskList = Array.isArray(tasks) ? tasks : [];
586
+ return taskList.map(task => ({
587
+ id: task.id,
588
+ name: task.name || task.prompt || "Untitled",
589
+ task_type: tt,
590
+ status: task.status,
591
+ progress: task.progress,
592
+ phase: task.phase,
593
+ created_at: task.created_at,
594
+ vertex_count: task.vertex_count,
595
+ face_count: task.face_count
596
+ }));
597
+ }));
598
+ for (const result of results) {
599
+ if (result.status === "fulfilled") {
600
+ allTasks.push(...result.value);
601
+ }
602
+ // Rejected results are silently skipped (partial failure tolerance)
603
+ }
604
+ // Sort merged results by created_at
605
+ allTasks.sort((a, b) => {
606
+ const timeA = new Date(a.created_at).getTime();
607
+ const timeB = new Date(b.created_at).getTime();
608
+ return params.sort_by === "+created_at" ? timeA - timeB : timeB - timeA;
609
+ });
610
+ }
611
+ const output = {
612
+ page_count: allTasks.length,
613
+ offset: params.offset,
614
+ queried_types: taskTypesToQuery,
615
+ tasks: allTasks,
616
+ has_more: taskTypesToQuery.length === 1 ? allTasks.length >= pageSize : false,
617
+ next_offset: taskTypesToQuery.length === 1 && allTasks.length >= pageSize
618
+ ? params.offset + allTasks.length
619
+ : undefined
620
+ };
621
+ // Helper to render tasks as markdown
622
+ const renderTasksMarkdown = (tasks, totalCount, truncated = false) => {
623
+ const lines = [`# Tasks`, ""];
624
+ const typeLabel = params.task_type ? `[${params.task_type}]` : "[all types]";
625
+ lines.push(`**Showing**: ${totalCount} tasks ${typeLabel} (offset: ${output.offset})`);
626
+ if (truncated)
627
+ lines.push(`*(truncated from ${allTasks.length} results)*`);
628
+ lines.push("");
629
+ for (const task of tasks) {
630
+ const typeTag = taskTypesToQuery.length > 1 ? ` [${task.task_type}]` : "";
631
+ lines.push(`## ${task.name}${typeTag} (${task.id})`);
632
+ lines.push(`- **Status**: ${task.status} ${task.status === TaskStatus.IN_PROGRESS ? `(${task.progress}%)` : ''}`);
633
+ lines.push(`- **Phase**: ${task.phase}`);
634
+ lines.push(`- **Created**: ${new Date(task.created_at).toLocaleString()}`);
635
+ if (task.vertex_count) {
636
+ lines.push(`- **Geometry**: ${task.vertex_count.toLocaleString()} vertices, ${task.face_count?.toLocaleString()} faces`);
637
+ }
638
+ lines.push("");
639
+ }
640
+ if (output.has_more) {
641
+ lines.push(`**More results available**. Use offset=${output.next_offset} to see next page.`);
642
+ }
643
+ return lines.join("\n");
644
+ };
645
+ let textContent;
646
+ if (params.response_format === ResponseFormat.MARKDOWN) {
647
+ textContent = renderTasksMarkdown(output.tasks, output.page_count);
648
+ }
649
+ else {
650
+ textContent = JSON.stringify(output, null, 2);
651
+ }
652
+ // Check character limit — re-render in same format with fewer tasks
653
+ if (textContent.length > CHARACTER_LIMIT) {
654
+ const truncatedTasks = output.tasks.slice(0, Math.max(1, Math.floor(output.tasks.length / 2)));
655
+ output.tasks = truncatedTasks;
656
+ output.page_count = truncatedTasks.length;
657
+ if (params.response_format === ResponseFormat.MARKDOWN) {
658
+ textContent = renderTasksMarkdown(truncatedTasks, truncatedTasks.length, true) +
659
+ `\n\n[Response truncated. Use smaller limit or add task_type filter to see more results.]`;
660
+ }
661
+ else {
662
+ textContent = JSON.stringify(output, null, 2) +
663
+ `\n\n[Response truncated. Use smaller limit or add task_type filter to see more results.]`;
664
+ }
665
+ }
666
+ return {
667
+ content: [{ type: "text", text: textContent }],
668
+ structuredContent: output
669
+ };
670
+ }
671
+ catch (error) {
672
+ return {
673
+ isError: true,
674
+ content: [{
675
+ type: "text",
676
+ text: handleMeshyError(error)
677
+ }]
678
+ };
679
+ }
680
+ });
681
+ // Cancel task tool
682
+ server.registerTool("meshy_cancel_task", {
683
+ title: "Cancel Generation Task",
684
+ description: `Cancel a pending or in-progress 3D generation task.
685
+
686
+ This tool cancels a task that is currently PENDING or IN_PROGRESS. Completed or failed tasks cannot be canceled.
687
+
688
+ Args:
689
+ - task_id (string): Task ID to cancel (required)
690
+ - task_type (enum, optional): Task type to route to correct endpoint (default: "text-to-3d"). Auto-infers if wrong.
691
+
692
+ Returns:
693
+ {
694
+ "success": true,
695
+ "message": "Task canceled successfully",
696
+ "task_id": "abc-123",
697
+ "previous_status": "IN_PROGRESS"
698
+ }
699
+
700
+ Use Cases:
701
+ - Cancel tasks that are taking too long
702
+ - Free up task slots when at limit
703
+ - Stop incorrect generations
704
+
705
+ Examples:
706
+ - Cancel task: { task_id: "abc-123" }
707
+
708
+ Error Handling:
709
+ - Returns error if task is already completed
710
+ - Returns "NotFound" if task doesn't exist`,
711
+ inputSchema: CancelTaskInputSchema,
712
+ annotations: {
713
+ readOnlyHint: false,
714
+ destructiveHint: true,
715
+ idempotentHint: true,
716
+ openWorldHint: true
717
+ }
718
+ }, async (params) => {
719
+ try {
720
+ const preferredEndpoint = getTaskEndpoint(params.task_type);
721
+ const { task, endpoint } = await getTaskWithAutoInference(client, params.task_id, preferredEndpoint);
722
+ const previousStatus = task.status;
723
+ // Cancel the task
724
+ await client.delete(`${endpoint}/${params.task_id}`);
725
+ const output = {
726
+ success: true,
727
+ message: "Task canceled successfully",
728
+ task_id: params.task_id,
729
+ previous_status: previousStatus
730
+ };
731
+ const textContent = `# Task Canceled
732
+
733
+ **Task ID**: ${params.task_id}
734
+ **Previous Status**: ${previousStatus}
735
+
736
+ The task has been canceled successfully.`;
737
+ return {
738
+ content: [{ type: "text", text: textContent }],
739
+ structuredContent: output
740
+ };
741
+ }
742
+ catch (error) {
743
+ return {
744
+ isError: true,
745
+ content: [{
746
+ type: "text",
747
+ text: handleMeshyError(error)
748
+ }]
749
+ };
750
+ }
751
+ });
752
+ // Download model tool
753
+ server.registerTool("meshy_download_model", {
754
+ title: "Get Model Download URLs",
755
+ description: `Download a completed 3D model to local disk with automatic file organization.
756
+
757
+ IMPORTANT: Ask the user which format they need BEFORE downloading. Do NOT download all formats.
758
+ Format recommendations: GLB (viewing), OBJ (white model printing), 3MF (multicolor printing), FBX (game engines), USDZ (AR).
759
+
760
+ By default, files are auto-saved to meshy_output/ under the current working directory with smart naming and history tracking. Use save_to to override with a custom path.
761
+
762
+ Args:
763
+ - task_id (string): Task ID of completed model (required)
764
+ - task_type (enum, optional): Task type to route to correct endpoint (default: "text-to-3d"). Auto-infers if wrong.
765
+ - format (enum): Model format - "glb", "fbx", "usdz", "stl", "obj", or "3mf" (default: "glb"). IMPORTANT: Ask user which format they need before downloading.
766
+ - include_textures (boolean): Include texture files (default: true)
767
+ - save_to (string, optional): Override auto path with a custom ABSOLUTE path. If omitted, auto-saves to meshy_output/{timestamp}_{prompt}_{id}/.
768
+ - parent_task_id (string, optional): Parent task ID for chaining (e.g., preview_task_id for refine). Places output in the same project folder.
769
+ - print_ready (boolean, optional): If true and format="obj", auto-fix for 3D printing: Y-up→Z-up, scale to height, center, bottom at Z=0.
770
+ - print_height_mm (number, optional): Target height in mm when print_ready=true. Default 75. Adjust per user request.
771
+
772
+ Returns:
773
+ { "local_path": "/path/to/file.obj", "file_size_bytes": 12345678, "project_dir": "...", "print_fixed": true }
774
+
775
+ Examples:
776
+ - Auto-save: { task_id: "abc-123", format: "glb" }
777
+ - 3D printing: { task_id: "abc-123", format: "obj", print_ready: true, print_height_mm: 100 }
778
+ - Chained refine: { task_id: "def-456", parent_task_id: "abc-123", format: "glb" }
779
+
780
+ Error Handling:
781
+ - Returns error if task is not SUCCEEDED
782
+ - If download fails, falls back to returning URLs`,
783
+ inputSchema: DownloadModelInputSchema,
784
+ annotations: {
785
+ readOnlyHint: false,
786
+ destructiveHint: false,
787
+ idempotentHint: true,
788
+ openWorldHint: true
789
+ }
790
+ }, async (params) => {
791
+ try {
792
+ const preferredEndpoint = getTaskEndpoint(params.task_type);
793
+ const { task } = await getTaskWithAutoInference(client, params.task_id, preferredEndpoint);
794
+ if (task.status !== TaskStatus.SUCCEEDED) {
795
+ return {
796
+ isError: true,
797
+ content: [{
798
+ type: "text",
799
+ text: `Error: Task is not completed yet. Current status: ${task.status}. Use meshy_get_task_status to check progress.`
800
+ }]
801
+ };
802
+ }
803
+ let fmt = params.format;
804
+ // 3MF format: not yet supported by API
805
+ if (fmt === "3mf" && !task.model_urls?.["3mf"]) {
806
+ const hasObj = !!task.model_urls?.obj;
807
+ return {
808
+ isError: true,
809
+ content: [{
810
+ type: "text",
811
+ text: `# 3MF Format Not Yet Supported
812
+
813
+ 3MF download is not yet available. This feature is coming soon.
814
+
815
+ ${hasObj
816
+ ? `This model has **OBJ** format available. Would you like to download the OBJ file instead?
817
+
818
+ OBJ files can be imported directly into most slicer software:
819
+ - **Bambu Studio**: File → Import → select .obj file
820
+ - **OrcaSlicer**: File → Import → select .obj file
821
+ - **Cura**: File → Open File(s) → select .obj file
822
+ - **Creality Print**: File → Open → select .obj file
823
+ - **PrusaSlicer**: File → Import → select .obj file
824
+
825
+ **IMPORTANT**: Please confirm before I proceed with the OBJ download. Do NOT use \`meshy_send_to_slicer\` — manually import the downloaded file in your slicer instead.`
826
+ : `Unfortunately, OBJ format is also not available for this model. Available formats: ${task.model_urls ? Object.keys(task.model_urls).join(', ') : 'none'}`}`
827
+ }]
828
+ };
829
+ }
830
+ // Rigging tasks: result.rigged_character_{format}_url
831
+ if (params.task_type === TaskType.RIGGING && task.result) {
832
+ const lines = [`# Rigging Download URLs`, "", `**Task ID**: ${params.task_id}`, ""];
833
+ lines.push("## Rigged Character");
834
+ if (task.result.rigged_character_glb_url)
835
+ lines.push(`- **GLB**: ${task.result.rigged_character_glb_url}`);
836
+ if (task.result.rigged_character_fbx_url)
837
+ lines.push(`- **FBX**: ${task.result.rigged_character_fbx_url}`);
838
+ lines.push("");
839
+ if (task.result.basic_animations) {
840
+ const anim = task.result.basic_animations;
841
+ lines.push("## Basic Animations (FREE with rigging)");
842
+ lines.push("### Walking");
843
+ if (anim.walking_glb_url)
844
+ lines.push(`- **GLB**: ${anim.walking_glb_url}`);
845
+ if (anim.walking_fbx_url)
846
+ lines.push(`- **FBX**: ${anim.walking_fbx_url}`);
847
+ if (anim.walking_armature_glb_url)
848
+ lines.push(`- **Armature GLB**: ${anim.walking_armature_glb_url}`);
849
+ lines.push("### Running");
850
+ if (anim.running_glb_url)
851
+ lines.push(`- **GLB**: ${anim.running_glb_url}`);
852
+ if (anim.running_fbx_url)
853
+ lines.push(`- **FBX**: ${anim.running_fbx_url}`);
854
+ if (anim.running_armature_glb_url)
855
+ lines.push(`- **Armature GLB**: ${anim.running_armature_glb_url}`);
856
+ lines.push("");
857
+ lines.push("**NOTE**: These animations were included FREE with rigging. Do NOT call `meshy_animate` for walking/running.");
858
+ lines.push("");
859
+ }
860
+ lines.push("**Note**: Download URLs expire after 24 hours.");
861
+ const output = {
862
+ download_url: fmt === "fbx"
863
+ ? task.result.rigged_character_fbx_url
864
+ : task.result.rigged_character_glb_url,
865
+ format: fmt,
866
+ rigged_character_glb_url: task.result.rigged_character_glb_url,
867
+ rigged_character_fbx_url: task.result.rigged_character_fbx_url,
868
+ basic_animations: task.result.basic_animations,
869
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
870
+ };
871
+ return {
872
+ content: [{ type: "text", text: lines.join("\n") }],
873
+ structuredContent: output
874
+ };
875
+ }
876
+ // Animation tasks: result.animation_{format}_url
877
+ if (params.task_type === TaskType.ANIMATION && task.result) {
878
+ const lines = [`# Animation Download URLs`, "", `**Task ID**: ${params.task_id}`, ""];
879
+ lines.push("## Animation Files");
880
+ if (task.result.animation_glb_url)
881
+ lines.push(`- **GLB**: ${task.result.animation_glb_url}`);
882
+ if (task.result.animation_fbx_url)
883
+ lines.push(`- **FBX**: ${task.result.animation_fbx_url}`);
884
+ if (task.result.processed_usdz_url)
885
+ lines.push(`- **USDZ**: ${task.result.processed_usdz_url}`);
886
+ if (task.result.processed_armature_fbx_url)
887
+ lines.push(`- **Armature FBX**: ${task.result.processed_armature_fbx_url}`);
888
+ if (task.result.processed_animation_fps_fbx_url)
889
+ lines.push(`- **FPS-converted FBX**: ${task.result.processed_animation_fps_fbx_url}`);
890
+ lines.push("");
891
+ lines.push("**Note**: Download URLs expire after 24 hours.");
892
+ const output = {
893
+ download_url: fmt === "fbx"
894
+ ? task.result.animation_fbx_url
895
+ : fmt === "usdz"
896
+ ? task.result.processed_usdz_url
897
+ : task.result.animation_glb_url,
898
+ format: fmt,
899
+ animation_glb_url: task.result.animation_glb_url,
900
+ animation_fbx_url: task.result.animation_fbx_url,
901
+ processed_usdz_url: task.result.processed_usdz_url,
902
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
903
+ };
904
+ return {
905
+ content: [{ type: "text", text: lines.join("\n") }],
906
+ structuredContent: output
907
+ };
908
+ }
909
+ // Standard tasks (text-to-3d, image-to-3d, remesh, retexture): top-level model_urls
910
+ if (!task.model_urls) {
911
+ return {
912
+ isError: true,
913
+ content: [{
914
+ type: "text",
915
+ text: "Error: Task completed but no model URLs available. " +
916
+ (task.result
917
+ ? "This task may use a different response format. Try meshy_get_task_status with response_format='json' to see the raw response."
918
+ : "")
919
+ }]
920
+ };
921
+ }
922
+ const downloadUrl = task.model_urls[fmt];
923
+ if (!downloadUrl) {
924
+ return {
925
+ isError: true,
926
+ content: [{
927
+ type: "text",
928
+ text: `Error: Format ${fmt} is not available for this model. Available formats: ${Object.keys(task.model_urls).join(', ')}`
929
+ }]
930
+ };
931
+ }
932
+ let textureUrls;
933
+ if (params.include_textures && task.texture_urls) {
934
+ if (Array.isArray(task.texture_urls) && task.texture_urls.length > 0) {
935
+ textureUrls = task.texture_urls[0];
936
+ }
937
+ else if (!Array.isArray(task.texture_urls)) {
938
+ textureUrls = task.texture_urls;
939
+ }
940
+ }
941
+ // Determine save path: explicit save_to OR auto-managed output directory
942
+ const stage = inferStage(params.task_type, task.type);
943
+ let savePath;
944
+ let projectDir;
945
+ if (params.save_to) {
946
+ savePath = params.save_to;
947
+ }
948
+ else {
949
+ // Auto-save to meshy_output/{timestamp}_{prompt}_{id}/
950
+ projectDir = resolveProjectDir(params.task_id, params.task_type, task.prompt, params.parent_task_id, task.created_at);
951
+ savePath = getFilePath(projectDir, stage, fmt);
952
+ }
953
+ try {
954
+ const fileSize = await downloadFileToLocal(downloadUrl, savePath);
955
+ // Auto-fix OBJ for 3D printing if requested
956
+ if (params.print_ready && fmt === "obj") {
957
+ fixObjForPrinting(savePath, params.print_height_mm || 75);
958
+ }
959
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
960
+ const savedFiles = [path.basename(savePath)];
961
+ // Download textures alongside the model
962
+ const savedTextures = [];
963
+ if (params.include_textures && textureUrls) {
964
+ for (const [texType, texUrl] of Object.entries(textureUrls)) {
965
+ if (texUrl && typeof texUrl === "string") {
966
+ try {
967
+ let texPath;
968
+ if (projectDir) {
969
+ texPath = getTextureFilePath(projectDir, stage, texType, texUrl);
970
+ }
971
+ else {
972
+ const saveDir = path.dirname(savePath);
973
+ const baseName = path.basename(savePath, path.extname(savePath));
974
+ const texExt = texUrl.includes(".png") ? ".png" : ".jpg";
975
+ texPath = path.join(saveDir, `${baseName}_${texType}${texExt}`);
976
+ }
977
+ await downloadFileToLocal(texUrl, texPath);
978
+ savedTextures.push(texPath);
979
+ savedFiles.push(path.basename(texPath));
980
+ }
981
+ catch {
982
+ // Texture download failed, continue
983
+ }
984
+ }
985
+ }
986
+ }
987
+ // Auto-download thumbnail for managed projects
988
+ if (projectDir && task.thumbnail_url) {
989
+ saveThumbnail(projectDir, task.thumbnail_url).catch(() => { });
990
+ }
991
+ // Record task in project metadata
992
+ if (projectDir) {
993
+ const record = {
994
+ task_id: params.task_id,
995
+ task_type: params.task_type,
996
+ stage,
997
+ prompt: task.prompt,
998
+ status: task.status,
999
+ files: savedFiles,
1000
+ created_at: new Date().toISOString()
1001
+ };
1002
+ recordTask(projectDir, record);
1003
+ }
1004
+ const output = {
1005
+ download_url: downloadUrl,
1006
+ local_path: savePath,
1007
+ project_dir: projectDir,
1008
+ file_size_bytes: fileSize,
1009
+ format: fmt,
1010
+ texture_paths: savedTextures.length > 0 ? savedTextures : undefined,
1011
+ vertex_count: task.vertex_count,
1012
+ face_count: task.face_count,
1013
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
1014
+ };
1015
+ let textContent = `# Model Downloaded
1016
+
1017
+ **Task ID**: ${params.task_id}
1018
+ **Format**: ${fmt.toUpperCase()}
1019
+ **Local File**: ${savePath}
1020
+ **File Size**: ${fileSizeMB} MB`;
1021
+ if (projectDir) {
1022
+ textContent += `\n**Project Folder**: ${projectDir}`;
1023
+ }
1024
+ if (task.vertex_count && task.face_count) {
1025
+ textContent += `\n\n## Model Info\n- **Vertices**: ${task.vertex_count.toLocaleString()}\n- **Faces**: ${task.face_count.toLocaleString()}`;
1026
+ }
1027
+ if (savedTextures.length > 0) {
1028
+ textContent += `\n\n## Saved Textures\n${savedTextures.map(t => `- ${t}`).join("\n")}`;
1029
+ }
1030
+ textContent += `\n\n**Note**: Source URL expires after 24 hours. The local file is permanent.`;
1031
+ return {
1032
+ content: [{ type: "text", text: textContent }],
1033
+ structuredContent: output
1034
+ };
1035
+ }
1036
+ catch (downloadError) {
1037
+ // Download failed — fall back to returning URLs
1038
+ const errorMsg = downloadError instanceof Error ? downloadError.message : String(downloadError);
1039
+ const output = {
1040
+ download_url: downloadUrl,
1041
+ format: fmt,
1042
+ download_error: errorMsg,
1043
+ texture_urls: textureUrls,
1044
+ vertex_count: task.vertex_count,
1045
+ face_count: task.face_count,
1046
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
1047
+ };
1048
+ return {
1049
+ content: [{
1050
+ type: "text",
1051
+ text: `# Download Failed — URLs Provided Instead
1052
+
1053
+ **Error**: ${errorMsg}
1054
+
1055
+ **Download URL** (use manually):
1056
+ ${downloadUrl}
1057
+
1058
+ **Note**: Download URLs expire after 24 hours.`
1059
+ }],
1060
+ structuredContent: output
1061
+ };
1062
+ }
1063
+ }
1064
+ catch (error) {
1065
+ return {
1066
+ isError: true,
1067
+ content: [{
1068
+ type: "text",
1069
+ text: handleMeshyError(error)
1070
+ }]
1071
+ };
1072
+ }
1073
+ });
1074
+ }