hydrilla-mcp 2.0.1 → 2.0.2

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/README.md CHANGED
@@ -91,7 +91,7 @@ The path is relative to the folder that contains `.mcp.json`. The server reads `
91
91
  | `hydrilla_me` | Plan and credit balance. Free. |
92
92
  | `hydrilla_text_to_3d` | Generate a GLB from a prompt. `send_to` places it in an open app. |
93
93
  | `hydrilla_image_to_3d` | Generate a GLB from an image URL. `send_to` places it in an open app. |
94
- | `hydrilla_get_task` | Status and mesh URL of one task. |
94
+ | `hydrilla_get_task` | Waits up to ~45 s on one task, then returns status, progress and mesh URL. `send_to` places it in an app once ready. |
95
95
  | `hydrilla_list_tasks` | Recent models on the account. Free. |
96
96
  | `hydrilla_cancel_task` | Stop a running task; unused credits are refunded. |
97
97
  | `hydrilla_list_apps` | Which apps have their Bridge running right now. |
@@ -105,5 +105,8 @@ Bridge running, `send_to: "blender"` on a generate tool, or `hydrilla_send_to_ap
105
105
  the plugin download the GLB and import it into the open scene. If the Bridge is off, the
106
106
  tool says which button to click and the model can be sent again without regenerating.
107
107
 
108
- A generate call waits up to 8 minutes and reports progress to clients that ask for it. If
109
- the model is not ready by then, it returns the task id to check with `hydrilla_get_task`.
108
+ A model takes 10 to 20 minutes to generate. No tool call stays open longer than about a
109
+ minute: a generate call returns the task id with the progress so far, and the agent calls
110
+ `hydrilla_get_task` (which waits up to ~45 seconds per call) until the model is ready, telling
111
+ the user the progress in between. Calls also send progress notifications to clients that ask
112
+ for them. Hydrilla fails and refunds a task that runs longer than 20 minutes.
package/dist/api.js CHANGED
@@ -2,7 +2,9 @@ import { API_KEYS_URL, SERVER_VERSION, resolveApiBase, resolveApiKey } from "./c
2
2
  const REQUEST_TIMEOUT_MS = 60_000;
3
3
  /** Text-to-3D creates its concept image before the request returns. */
4
4
  export const CREATE_TIMEOUT_MS = 240_000;
5
- const POLL_INTERVAL_MS = 2_000;
5
+ const POLL_INTERVAL_MS = 3_000;
6
+ /** Shortest timeout given to one status request, so a nearly spent wait still gets an answer. */
7
+ const MIN_POLL_TIMEOUT_MS = 10_000;
6
8
  const MAX_POLL_FAILURES = 5;
7
9
  const NO_KEY_MESSAGE = `No Hydrilla API key found. Ask the user to add their key in the Hydrilla plugin (for example the Hydrilla tab in Blender), ` +
8
10
  `or to set HYDRILLA_API_KEY in this MCP server's config. Keys are created at ${API_KEYS_URL}. ` +
@@ -85,10 +87,10 @@ export async function api(path, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
85
87
  }
86
88
  return data;
87
89
  }
88
- export const getTask = (taskId) => api(`/v1/3d/tasks/${encodeURIComponent(taskId)}`);
89
- const pollOnce = async (taskId) => {
90
+ export const getTask = (taskId, timeoutMs = REQUEST_TIMEOUT_MS) => api(`/v1/3d/tasks/${encodeURIComponent(taskId)}`, {}, timeoutMs);
91
+ const pollOnce = async (taskId, timeoutMs) => {
90
92
  try {
91
- return await getTask(taskId);
93
+ return await getTask(taskId, timeoutMs);
92
94
  }
93
95
  catch (error) {
94
96
  // A brief network or server hiccup should not lose a paid task.
@@ -100,17 +102,22 @@ const pollOnce = async (taskId) => {
100
102
  }
101
103
  };
102
104
  /**
103
- * Poll until the task finishes or the wait budget runs out. On timeout the
104
- * task keeps running on Hydrilla and can be checked again with its id.
105
+ * Poll until the task finishes or the wait budget runs out. Always checks at
106
+ * least once, and no request outlives the deadline by more than
107
+ * MIN_POLL_TIMEOUT_MS. On timeout the task keeps running on Hydrilla and can
108
+ * be checked again with its id.
105
109
  */
106
110
  export async function waitForTask(taskId, maxWaitMs, onProgress) {
107
111
  const deadline = Date.now() + maxWaitMs;
108
112
  const state = {
113
+ polls: 0,
109
114
  failures: 0,
110
115
  last: { id: taskId, status: "PENDING" },
111
116
  };
112
- while (Date.now() < deadline) {
113
- const task = await pollOnce(taskId);
117
+ while (state.polls === 0 || Date.now() < deadline) {
118
+ state.polls += 1;
119
+ const timeoutMs = Math.max(MIN_POLL_TIMEOUT_MS, Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now()));
120
+ const task = await pollOnce(taskId, timeoutMs);
114
121
  if (task) {
115
122
  state.failures = 0;
116
123
  state.last = task;
@@ -125,7 +132,11 @@ export async function waitForTask(taskId, maxWaitMs, onProgress) {
125
132
  throw new HydrillaError("Lost contact with Hydrilla while waiting. Check the task again with hydrilla_get_task.");
126
133
  }
127
134
  }
128
- await sleep(POLL_INTERVAL_MS);
135
+ const remainingMs = deadline - Date.now();
136
+ if (remainingMs <= 0) {
137
+ break;
138
+ }
139
+ await sleep(Math.min(POLL_INTERVAL_MS, remainingMs));
129
140
  }
130
141
  return { task: state.last, timedOut: true };
131
142
  }
package/dist/bridge.js CHANGED
@@ -6,8 +6,8 @@ export const APPS = ["blender", "maya", "3dsmax", "unreal", "unity", "godot"];
6
6
  const BRIDGE_HOST = "127.0.0.1";
7
7
  const BRIDGE_SERVICE = "hydrilla-bridge";
8
8
  const HEALTH_TIMEOUT_MS = 1_500;
9
- /** The plugin downloads the mesh before it replies. */
10
- const IMPORT_TIMEOUT_MS = 5 * 60_000;
9
+ /** The plugin downloads the mesh before it replies; kept short so the tool call still answers. */
10
+ const IMPORT_TIMEOUT_MS = 2 * 60_000;
11
11
  const BRIDGE_PORTS = {
12
12
  blender: 7345,
13
13
  maya: 7346,
@@ -73,7 +73,7 @@ const postImport = async (app, body) => {
73
73
  }
74
74
  catch (error) {
75
75
  const reason = error instanceof Error ? error.message : String(error);
76
- throw new BridgeError(`${APP_NAMES[app]} stopped responding while receiving the model (${reason}).`);
76
+ throw new BridgeError(`${APP_NAMES[app]} did not confirm the import (${reason}). It may still be importing; ask the user to check the app before sending the model again.`);
77
77
  }
78
78
  };
79
79
  const readBridgeError = async (response) => {
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- export const SERVER_VERSION = "2.0.1";
5
+ export const SERVER_VERSION = "2.0.2";
6
6
  export const DEFAULT_API_BASE = "https://hydrilla-ai-g1bm.vercel.app";
7
7
  export const API_KEYS_URL = "https://hydrilla.ai/app/api-keys";
8
8
  /** Hosts that do not serve the developer API (old backend, GPU gateway). */
package/dist/index.js CHANGED
@@ -7,8 +7,14 @@ import { CREATE_TIMEOUT_MS, api, getTask, waitForTask } from "./api.js";
7
7
  import { APPS, appName, listListeningApps, sendMeshToApp } from "./bridge.js";
8
8
  import { SERVER_VERSION, getPostHogHost, getPostHogProjectToken, loadLocalEnv } from "./config.js";
9
9
  loadLocalEnv();
10
- /** How long a generate call waits before handing back the task id to check later. */
11
- const MAX_WAIT_MS = 8 * 60_000;
10
+ /**
11
+ * Longest a tool call waits on a running task before it answers. A generation
12
+ * takes 10 to 20 minutes, far longer than many clients let one tool call run
13
+ * (Codex stops at 60 seconds), and the user sees nothing while a call is open.
14
+ * So each call answers with the current progress, and the agent calls
15
+ * hydrilla_get_task again to keep waiting.
16
+ */
17
+ const CALL_BUDGET_MS = 45_000;
12
18
  const jsonResult = (value) => ({
13
19
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
14
20
  });
@@ -60,21 +66,22 @@ const deliver = async (task, app, name) => {
60
66
  return { send_error: error instanceof Error ? error.message : String(error) };
61
67
  }
62
68
  };
63
- /** Wait for a created task and, if asked, place the model in the user's open app. */
64
- const finishGeneration = async (taskId, options, extra) => {
65
- if (!options.wait) {
66
- return jsonResult({
67
- id: taskId,
68
- status: "PENDING",
69
- note: "Generation started. Check it with hydrilla_get_task; send it to an app with hydrilla_send_to_app once it has succeeded.",
70
- });
71
- }
72
- const { task, timedOut } = await waitForTask(taskId, MAX_WAIT_MS, progressReporter(extra));
69
+ const STILL_GENERATING_NOTE = "Still generating; a model usually takes 10 to 20 minutes in total. Tell the user the progress so far, " +
70
+ "then call hydrilla_get_task with this task_id (and the same send_to and name, if any) to keep waiting. " +
71
+ "Do not start the generation again.";
72
+ const elapsedSecondsOf = (task) => typeof task.created_at === "number" ? Math.max(0, Math.round(Date.now() / 1000 - task.created_at)) : undefined;
73
+ /**
74
+ * Wait on a task for at most waitMs, then report where it stands. Once it has
75
+ * succeeded, place the model in the user's open app if asked.
76
+ */
77
+ const awaitTask = async (taskId, options, extra) => {
78
+ const { task, timedOut } = await waitForTask(taskId, options.waitMs, progressReporter(extra));
73
79
  if (timedOut) {
74
80
  return jsonResult({
75
81
  ...task,
76
82
  id: taskId,
77
- note: "Still generating. Call hydrilla_get_task with this id in a minute; do not start the generation again.",
83
+ elapsed_seconds: elapsedSecondsOf(task),
84
+ note: STILL_GENERATING_NOTE,
78
85
  });
79
86
  }
80
87
  if (task.status !== "SUCCEEDED") {
@@ -83,6 +90,18 @@ const finishGeneration = async (taskId, options, extra) => {
83
90
  const delivery = await deliver({ ...task, id: taskId }, options.sendTo, options.name);
84
91
  return jsonResult({ ...task, id: taskId, ...delivery });
85
92
  };
93
+ /** After the create request, wait only for what is left of this call's budget. */
94
+ const finishGeneration = async (taskId, startedAt, options, extra) => {
95
+ if (!options.wait) {
96
+ return jsonResult({
97
+ id: taskId,
98
+ status: "PENDING",
99
+ note: "Generation started; it usually takes 10 to 20 minutes. Check it with hydrilla_get_task (pass send_to there to place it in an app when it is done).",
100
+ });
101
+ }
102
+ const waitMs = Math.max(0, CALL_BUDGET_MS - (Date.now() - startedAt));
103
+ return awaitTask(taskId, { waitMs, sendTo: options.sendTo, name: options.name }, extra);
104
+ };
86
105
  const server = new McpServer({
87
106
  name: "hydrilla",
88
107
  version: SERVER_VERSION,
@@ -135,27 +154,41 @@ const nameSchema = z
135
154
  .optional()
136
155
  .describe("Short name for the imported object, e.g. 'treasure-chest'");
137
156
  server.tool("hydrilla_me", "Return the Hydrilla account plan and credit balance. Free; use it to check the connection.", {}, async () => guarded(async () => jsonResult(await api("/v1/user/me"))));
138
- server.tool("hydrilla_text_to_3d", "Generate a textured 3D model (GLB) from a text prompt. Costs credits and takes a few minutes. Set send_to to place it in the user's open Blender, Unity, Unreal, Godot, Maya or 3ds Max.", {
157
+ server.tool("hydrilla_text_to_3d", "Generate a textured 3D model (GLB) from a text prompt. Costs credits and takes 10 to 20 minutes: the call returns a task_id within about a minute, then keep calling hydrilla_get_task with it until the model is ready. Set send_to to place it in the user's open Blender, Unity, Unreal, Godot, Maya or 3ds Max.", {
139
158
  prompt: z.string().min(1).describe("What to generate"),
140
- wait: z.boolean().optional().describe("Wait until the model is ready (default true)"),
159
+ wait: z
160
+ .boolean()
161
+ .optional()
162
+ .describe("Wait up to about 45 seconds for progress before answering (default true). The model is rarely ready that fast; keep checking with hydrilla_get_task."),
141
163
  send_to: sendToSchema,
142
164
  name: nameSchema,
143
165
  }, async ({ prompt, wait = true, send_to, name }, extra) => guarded(async () => {
166
+ const startedAt = Date.now();
144
167
  const created = await api("/v1/3d/text-to-3d", { method: "POST", body: JSON.stringify({ prompt }) }, CREATE_TIMEOUT_MS);
145
- return finishGeneration(created.id, { wait, sendTo: send_to, name }, extra);
168
+ return finishGeneration(created.id, startedAt, { wait, sendTo: send_to, name }, extra);
146
169
  }));
147
- server.tool("hydrilla_image_to_3d", "Generate a textured 3D model (GLB) from a public image URL. Costs credits and takes a few minutes. Set send_to to place it in the user's open app.", {
170
+ server.tool("hydrilla_image_to_3d", "Generate a textured 3D model (GLB) from a public image URL. Costs credits and takes 10 to 20 minutes: the call returns a task_id within about a minute, then keep calling hydrilla_get_task with it until the model is ready. Set send_to to place it in the user's open app.", {
148
171
  image_url: z.string().url().describe("Public image URL"),
149
- wait: z.boolean().optional().describe("Wait until the model is ready (default true)"),
172
+ wait: z
173
+ .boolean()
174
+ .optional()
175
+ .describe("Wait up to about 45 seconds for progress before answering (default true). The model is rarely ready that fast; keep checking with hydrilla_get_task."),
150
176
  send_to: sendToSchema,
151
177
  name: nameSchema,
152
178
  }, async ({ image_url, wait = true, send_to, name }, extra) => guarded(async () => {
179
+ const startedAt = Date.now();
153
180
  const created = await api("/v1/3d/image-to-3d", { method: "POST", body: JSON.stringify({ image_url }) }, CREATE_TIMEOUT_MS);
154
- return finishGeneration(created.id, { wait, sendTo: send_to, name }, extra);
181
+ return finishGeneration(created.id, startedAt, { wait, sendTo: send_to, name }, extra);
155
182
  }));
156
- server.tool("hydrilla_get_task", "Check a Hydrilla 3D task by id. Returns its status and, once it has succeeded, the mesh URL.", {
183
+ server.tool("hydrilla_get_task", "Check a Hydrilla 3D task by id, waiting up to about 45 seconds for it to finish. Returns its status and progress and, once it has succeeded, the mesh URL. While it is still generating, tell the user the progress and call this again with the same task_id. Pass send_to to place the model in an open app as soon as it is ready.", {
157
184
  task_id: z.string().min(1),
158
- }, async ({ task_id }) => guarded(async () => jsonResult(await getTask(task_id))));
185
+ wait: z
186
+ .boolean()
187
+ .optional()
188
+ .describe("Wait up to about 45 seconds for the task to finish (default true). Set false for an instant status check."),
189
+ send_to: sendToSchema,
190
+ name: nameSchema,
191
+ }, async ({ task_id, wait = true, send_to, name }, extra) => guarded(async () => awaitTask(task_id, { waitMs: wait ? CALL_BUDGET_MS : 0, sendTo: send_to, name }, extra)));
159
192
  server.tool("hydrilla_list_tasks", "List the user's recent Hydrilla 3D models, newest first. Free. Use it to find a model that already exists instead of generating it again.", {
160
193
  limit: z.number().int().min(1).max(100).optional().describe("How many to return (default 20)"),
161
194
  }, async ({ limit = 20 }) => guarded(async () => jsonResult(await api(`/v1/3d/tasks?limit=${limit}`))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hydrilla-mcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "MCP server for Hydrilla: generate 3D models and send them into Blender, Unity, Unreal, Godot, Maya or 3ds Max.",
5
5
  "keywords": [
6
6
  "mcp",