@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.
- package/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/constants.d.ts +123 -0
- package/dist/constants.js +169 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +130 -0
- package/dist/instructions.d.ts +6 -0
- package/dist/instructions.js +90 -0
- package/dist/schemas/balance.d.ts +11 -0
- package/dist/schemas/balance.js +8 -0
- package/dist/schemas/common.d.ts +38 -0
- package/dist/schemas/common.js +52 -0
- package/dist/schemas/generation.d.ts +219 -0
- package/dist/schemas/generation.js +217 -0
- package/dist/schemas/image.d.ts +55 -0
- package/dist/schemas/image.js +46 -0
- package/dist/schemas/output.d.ts +75 -0
- package/dist/schemas/output.js +41 -0
- package/dist/schemas/postprocessing.d.ts +135 -0
- package/dist/schemas/postprocessing.js +123 -0
- package/dist/schemas/printing.d.ts +63 -0
- package/dist/schemas/printing.js +54 -0
- package/dist/schemas/tasks.d.ts +123 -0
- package/dist/schemas/tasks.js +85 -0
- package/dist/services/error-handler.d.ts +32 -0
- package/dist/services/error-handler.js +141 -0
- package/dist/services/file-utils.d.ts +15 -0
- package/dist/services/file-utils.js +55 -0
- package/dist/services/meshy-client.d.ts +54 -0
- package/dist/services/meshy-client.js +172 -0
- package/dist/services/output-manager.d.ts +52 -0
- package/dist/services/output-manager.js +284 -0
- package/dist/tools/balance.d.ts +9 -0
- package/dist/tools/balance.js +61 -0
- package/dist/tools/generation.d.ts +9 -0
- package/dist/tools/generation.js +419 -0
- package/dist/tools/image.d.ts +9 -0
- package/dist/tools/image.js +154 -0
- package/dist/tools/postprocessing.d.ts +9 -0
- package/dist/tools/postprocessing.js +405 -0
- package/dist/tools/printing.d.ts +9 -0
- package/dist/tools/printing.js +338 -0
- package/dist/tools/tasks.d.ts +9 -0
- package/dist/tools/tasks.js +1074 -0
- package/dist/tools/workspace.d.ts +9 -0
- package/dist/tools/workspace.js +161 -0
- package/dist/types.d.ts +261 -0
- package/dist/types.js +4 -0
- package/dist/utils/endpoints.d.ts +16 -0
- package/dist/utils/endpoints.js +38 -0
- package/dist/utils/request-builder.d.ts +15 -0
- package/dist/utils/request-builder.js +24 -0
- package/dist/utils/response-formatter.d.ts +27 -0
- package/dist/utils/response-formatter.js +37 -0
- package/dist/utils/slicer-detector.d.ts +29 -0
- package/dist/utils/slicer-detector.js +237 -0
- 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
|
+
}
|