hydrilla-mcp 0.0.0-stage → 2.0.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/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # Hydrilla MCP Server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stdio MCP server for the Hydrilla developer API. Shared across Cursor, Claude Code, and other MCP clients via project-root `.mcp.json` (not under `.cursor/`).
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ cd apps/frontend/plugins/mcp
9
+ pnpm install # or npm install
10
+ pnpm run build # or npm run build
11
+ ```
12
+
13
+ ## Secrets
14
+
15
+ ```bash
16
+ cp mcp.env.example mcp.env
17
+ # edit mcp.env — never commit mcp.env
18
+ ```
19
+
20
+ | Variable | Required | Purpose |
21
+ |---|---|---|
22
+ | `HYDRILLA_API_KEY` | No, if a plugin has the key | `hyd_live_…` from `/app/api-keys`. When unset, the server uses the key pasted into a Hydrilla plugin (`~/.hydrilla/config.json`), read on every call |
23
+ | `HYDRILLA_API_BASE` | No | Defaults to production backend |
24
+ | `POSTHOG_PROJECT_TOKEN` | Recommended | `phc_…` for MCP Analytics |
25
+ | `POSTHOG_HOST` | No | Defaults to `https://us.i.posthog.com` |
26
+
27
+ ## Project MCP config (any AI)
28
+
29
+ Committed configs (no secrets):
30
+
31
+ - Workspace root: `Hydrilla_all/.mcp.json`
32
+ - Repo root: `hydrilla-ai/.mcp.json`
33
+
34
+ Both point at `dist/index.js` with a path relative to that folder. The server loads `mcp.env` from its own directory. Do not use `${workspaceFolder}` or `envFile` here: Claude Code does not expand that variable and does not read `envFile`.
35
+
36
+ Public install (same flow as the MCP page). The npm package is `hydrilla-mcp` (Node.js 20+). Replace `hyd_live_…` with a key from `/app/api-keys`. The key stays in the client config, not in chat.
37
+
38
+ Claude Code (macOS / Linux):
39
+
40
+ ```bash
41
+ claude mcp add -s user hydrilla --env HYDRILLA_API_KEY=hyd_live_… -- npx -y hydrilla-mcp
42
+ ```
43
+
44
+ Claude Code (Windows):
45
+
46
+ ```bash
47
+ claude mcp add -s user hydrilla --env HYDRILLA_API_KEY=hyd_live_… "--" cmd /c npx -y hydrilla-mcp
48
+ ```
49
+
50
+ Codex:
51
+
52
+ ```bash
53
+ codex mcp add hydrilla --env HYDRILLA_API_KEY=hyd_live_… -- npx -y hydrilla-mcp
54
+ ```
55
+
56
+ Cursor: open the install link from `/integrations/mcp`, or paste this into `~/.cursor/mcp.json` and turn `hydrilla` on:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "hydrilla": {
62
+ "command": "npx",
63
+ "args": ["-y", "hydrilla-mcp"],
64
+ "env": {
65
+ "HYDRILLA_API_KEY": "hyd_live_…"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ Local checkout of this repo:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "hydrilla": {
78
+ "command": "node",
79
+ "args": ["apps/frontend/plugins/mcp/dist/index.js"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ The path is relative to the folder that contains `.mcp.json`. The server reads `mcp.env` beside itself, so the key is not written into this file.
86
+
87
+ ## Tools
88
+
89
+ | Tool | What it does |
90
+ |---|---|
91
+ | `hydrilla_me` | Plan and credit balance. Free. |
92
+ | `hydrilla_text_to_3d` | Generate a GLB from a prompt. `send_to` places it in an open app. |
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. |
95
+ | `hydrilla_list_tasks` | Recent models on the account. Free. |
96
+ | `hydrilla_cancel_task` | Stop a running task; unused credits are refunded. |
97
+ | `hydrilla_list_apps` | Which apps have their Bridge running right now. |
98
+ | `hydrilla_send_to_app` | Put an existing model into Blender, Unity, Unreal, Godot, Maya or 3ds Max. Free. |
99
+
100
+ ## Sending models into an app
101
+
102
+ Each Hydrilla plugin has a **Run Bridge** button that listens on its own local port
103
+ (Blender 7345, Maya 7346, 3ds Max 7347, Unreal 7348, Unity 7349, Godot 7350). With the
104
+ Bridge running, `send_to: "blender"` on a generate tool, or `hydrilla_send_to_app`, makes
105
+ the plugin download the GLB and import it into the open scene. If the Bridge is off, the
106
+ tool says which button to click and the model can be sent again without regenerating.
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`.
package/dist/api.js ADDED
@@ -0,0 +1,131 @@
1
+ import { API_KEYS_URL, SERVER_VERSION, resolveApiBase, resolveApiKey } from "./config.js";
2
+ const REQUEST_TIMEOUT_MS = 60_000;
3
+ /** Text-to-3D creates its concept image before the request returns. */
4
+ export const CREATE_TIMEOUT_MS = 240_000;
5
+ const POLL_INTERVAL_MS = 2_000;
6
+ const MAX_POLL_FAILURES = 5;
7
+ 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
+ `or to set HYDRILLA_API_KEY in this MCP server's config. Keys are created at ${API_KEYS_URL}. ` +
9
+ `Do not ask the user to paste the key into the chat.`;
10
+ const BAD_KEY_MESSAGE = `Hydrilla did not accept the API key. Ask the user to check it or create a new one at ${API_KEYS_URL}, ` +
11
+ `then update it in the Hydrilla plugin or this MCP server's config. Do not ask for the key in the chat.`;
12
+ export class HydrillaError extends Error {
13
+ status;
14
+ constructor(message, status = 0) {
15
+ super(message);
16
+ this.name = "HydrillaError";
17
+ this.status = status;
18
+ }
19
+ }
20
+ const sleep = (ms) => new Promise((resolve) => {
21
+ setTimeout(resolve, ms);
22
+ });
23
+ function isTerminal(status) {
24
+ return status === "SUCCEEDED" || status === "FAILED" || status === "CANCELLED";
25
+ }
26
+ function messageFor(status, data) {
27
+ if (status === 401) {
28
+ return BAD_KEY_MESSAGE;
29
+ }
30
+ const body = (data ?? {});
31
+ const nested = typeof body.error === "object" ? body.error?.message : body.error;
32
+ const fromBody = nested || body.message;
33
+ if (fromBody) {
34
+ return String(fromBody);
35
+ }
36
+ if (status === 402) {
37
+ return "Not enough Hydrilla credits. The user can top up at hydrilla.ai.";
38
+ }
39
+ if (status === 429) {
40
+ return "Too many requests to Hydrilla. Wait a moment and try again.";
41
+ }
42
+ if (status >= 500) {
43
+ return `Hydrilla is having trouble right now (${status}). Try again shortly.`;
44
+ }
45
+ return `Hydrilla API request failed (${status}).`;
46
+ }
47
+ const fetchResponse = async (url, init, timeoutMs) => {
48
+ try {
49
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
50
+ }
51
+ catch (error) {
52
+ const reason = error instanceof Error ? error.message : String(error);
53
+ throw new HydrillaError(`Could not reach the Hydrilla API at ${new URL(url).host}: ${reason}`);
54
+ }
55
+ };
56
+ const parseJson = async (response) => {
57
+ const text = await response.text();
58
+ if (!text) {
59
+ return {};
60
+ }
61
+ try {
62
+ return JSON.parse(text);
63
+ }
64
+ catch {
65
+ // A gateway error page or other non-JSON body; the status code carries the meaning.
66
+ return {};
67
+ }
68
+ };
69
+ export async function api(path, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
70
+ const apiKey = resolveApiKey();
71
+ if (!apiKey) {
72
+ throw new HydrillaError(NO_KEY_MESSAGE, 401);
73
+ }
74
+ const headers = new Headers(init.headers);
75
+ headers.set("Authorization", `Bearer ${apiKey}`);
76
+ headers.set("Accept", "application/json");
77
+ headers.set("User-Agent", `Hydrilla-MCP/${SERVER_VERSION}`);
78
+ if (init.body && !headers.has("Content-Type")) {
79
+ headers.set("Content-Type", "application/json");
80
+ }
81
+ const response = await fetchResponse(`${resolveApiBase()}${path}`, { ...init, headers }, timeoutMs);
82
+ const data = await parseJson(response);
83
+ if (!response.ok) {
84
+ throw new HydrillaError(messageFor(response.status, data), response.status);
85
+ }
86
+ return data;
87
+ }
88
+ export const getTask = (taskId) => api(`/v1/3d/tasks/${encodeURIComponent(taskId)}`);
89
+ const pollOnce = async (taskId) => {
90
+ try {
91
+ return await getTask(taskId);
92
+ }
93
+ catch (error) {
94
+ // A brief network or server hiccup should not lose a paid task.
95
+ const retryable = error instanceof HydrillaError && (error.status === 0 || error.status >= 500);
96
+ if (!retryable) {
97
+ throw error;
98
+ }
99
+ return null;
100
+ }
101
+ };
102
+ /**
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
+ */
106
+ export async function waitForTask(taskId, maxWaitMs, onProgress) {
107
+ const deadline = Date.now() + maxWaitMs;
108
+ const state = {
109
+ failures: 0,
110
+ last: { id: taskId, status: "PENDING" },
111
+ };
112
+ while (Date.now() < deadline) {
113
+ const task = await pollOnce(taskId);
114
+ if (task) {
115
+ state.failures = 0;
116
+ state.last = task;
117
+ if (isTerminal(task.status)) {
118
+ return { task, timedOut: false };
119
+ }
120
+ await onProgress(task);
121
+ }
122
+ else {
123
+ state.failures += 1;
124
+ if (state.failures > MAX_POLL_FAILURES) {
125
+ throw new HydrillaError("Lost contact with Hydrilla while waiting. Check the task again with hydrilla_get_task.");
126
+ }
127
+ }
128
+ await sleep(POLL_INTERVAL_MS);
129
+ }
130
+ return { task: state.last, timedOut: true };
131
+ }
package/dist/bridge.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Hydrilla Bridge: each Hydrilla plugin can listen on its own local port so a
3
+ * finished model can be sent straight into the open scene or project.
4
+ */
5
+ export const APPS = ["blender", "maya", "3dsmax", "unreal", "unity", "godot"];
6
+ const BRIDGE_HOST = "127.0.0.1";
7
+ const BRIDGE_SERVICE = "hydrilla-bridge";
8
+ const HEALTH_TIMEOUT_MS = 1_500;
9
+ /** The plugin downloads the mesh before it replies. */
10
+ const IMPORT_TIMEOUT_MS = 5 * 60_000;
11
+ const BRIDGE_PORTS = {
12
+ blender: 7345,
13
+ maya: 7346,
14
+ "3dsmax": 7347,
15
+ unreal: 7348,
16
+ unity: 7349,
17
+ godot: 7350,
18
+ };
19
+ const APP_NAMES = {
20
+ blender: "Blender",
21
+ maya: "Maya",
22
+ "3dsmax": "3ds Max",
23
+ unreal: "Unreal Editor",
24
+ unity: "Unity",
25
+ godot: "Godot",
26
+ };
27
+ const BRIDGE_HINTS = {
28
+ blender: "In Blender, press N in the 3D viewport, open the Hydrilla tab and click Run Bridge.",
29
+ maya: "In Maya, open the Hydrilla window (import hydrilla_maya; hydrilla_maya.show()) and click Run Bridge.",
30
+ "3dsmax": "In 3ds Max, open the Hydrilla window (import hydrilla_max; hydrilla_max.show()) and click Run Bridge.",
31
+ unreal: "In Unreal Editor, choose Hydrilla > Run Bridge from the main menu.",
32
+ unity: "In Unity, open Window > Hydrilla and click Run Bridge.",
33
+ godot: "In Godot, open the Hydrilla dock and click Run Bridge.",
34
+ };
35
+ export class BridgeError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "BridgeError";
39
+ }
40
+ }
41
+ const bridgeUrl = (app) => `http://${BRIDGE_HOST}:${BRIDGE_PORTS[app]}`;
42
+ export const appName = (app) => APP_NAMES[app];
43
+ /** True when that app's Hydrilla plugin has its Bridge running. */
44
+ export async function isAppListening(app) {
45
+ try {
46
+ const response = await fetch(`${bridgeUrl(app)}/health`, {
47
+ headers: { Accept: "application/json" },
48
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
49
+ });
50
+ if (!response.ok) {
51
+ return false;
52
+ }
53
+ const data = (await response.json());
54
+ return data.service === BRIDGE_SERVICE;
55
+ }
56
+ catch {
57
+ // Nothing is listening on that port, or it is not a Hydrilla Bridge.
58
+ return false;
59
+ }
60
+ }
61
+ export async function listListeningApps() {
62
+ const checks = await Promise.all(APPS.map(async (app) => ({ app, listening: await isAppListening(app) })));
63
+ return checks.filter((check) => check.listening).map((check) => check.app);
64
+ }
65
+ const postImport = async (app, body) => {
66
+ try {
67
+ return await fetch(`${bridgeUrl(app)}/import`, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
70
+ body: JSON.stringify(body),
71
+ signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS),
72
+ });
73
+ }
74
+ catch (error) {
75
+ const reason = error instanceof Error ? error.message : String(error);
76
+ throw new BridgeError(`${APP_NAMES[app]} stopped responding while receiving the model (${reason}).`);
77
+ }
78
+ };
79
+ const readBridgeError = async (response) => {
80
+ try {
81
+ const data = (await response.json());
82
+ return data.error || `status ${response.status}`;
83
+ }
84
+ catch {
85
+ // The plugin replied without a JSON body; the status code is all there is.
86
+ return `status ${response.status}`;
87
+ }
88
+ };
89
+ /** Hand a finished mesh to the app. The plugin downloads and imports it. */
90
+ export async function sendMeshToApp(app, mesh) {
91
+ if (!(await isAppListening(app))) {
92
+ throw new BridgeError(`${APP_NAMES[app]} is not ready to receive models. ${BRIDGE_HINTS[app]} Then call hydrilla_send_to_app again with the same task_id; the model does not need to be generated again.`);
93
+ }
94
+ const response = await postImport(app, {
95
+ mesh_url: mesh.meshUrl,
96
+ name: mesh.name,
97
+ job_id: mesh.taskId,
98
+ });
99
+ if (!response.ok) {
100
+ throw new BridgeError(`${APP_NAMES[app]} could not import the model: ${await readBridgeError(response)}`);
101
+ }
102
+ }
package/dist/config.js ADDED
@@ -0,0 +1,107 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export const SERVER_VERSION = "2.0.0";
6
+ export const DEFAULT_API_BASE = "https://hydrilla-ai-g1bm.vercel.app";
7
+ export const API_KEYS_URL = "https://hydrilla.ai/app/api-keys";
8
+ /** Hosts that do not serve the developer API (old backend, GPU gateway). */
9
+ const RETIRED_API_HOSTS = ["hydrilla-backend.vercel.app", "api.hydrilla.co", "api.hydrilla.ai"];
10
+ function unquoteEnvValue(value) {
11
+ const quoted = (value.startsWith("\"") && value.endsWith("\"")) ||
12
+ (value.startsWith("'") && value.endsWith("'"));
13
+ if (quoted && value.length >= 2) {
14
+ return value.slice(1, -1);
15
+ }
16
+ return value;
17
+ }
18
+ /** Load mcp.env next to the server. Claude Code does not read an envFile field. */
19
+ export function loadLocalEnv() {
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const envPath = [join(here, "mcp.env"), join(here, "..", "mcp.env")].find((path) => existsSync(path));
22
+ if (!envPath) {
23
+ return;
24
+ }
25
+ const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
26
+ for (const line of lines) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith("#")) {
29
+ continue;
30
+ }
31
+ const eq = trimmed.indexOf("=");
32
+ if (eq <= 0) {
33
+ continue;
34
+ }
35
+ const key = trimmed.slice(0, eq).trim();
36
+ if (!key || process.env[key] !== undefined) {
37
+ continue;
38
+ }
39
+ process.env[key] = unquoteEnvValue(trimmed.slice(eq + 1).trim());
40
+ }
41
+ }
42
+ /**
43
+ * Config the Hydrilla plugins write when a key is pasted into one of them.
44
+ * Read on every call, so a key added in Blender works without restarting the agent.
45
+ */
46
+ function readSharedConfig() {
47
+ const folder = (process.env.HYDRILLA_CONFIG_DIR || "").trim() || join(homedir(), ".hydrilla");
48
+ const path = join(folder, "config.json");
49
+ if (!existsSync(path)) {
50
+ return {};
51
+ }
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
54
+ if (parsed && typeof parsed === "object") {
55
+ return parsed;
56
+ }
57
+ return {};
58
+ }
59
+ catch {
60
+ // An unreadable shared file just means no shared key.
61
+ return {};
62
+ }
63
+ }
64
+ function cleanApiKey(value) {
65
+ const text = typeof value === "string" ? value.trim().replace(/^["']|["']$/g, "").trim() : "";
66
+ if (text.toLowerCase().startsWith("bearer ")) {
67
+ return text.slice(7).trim();
68
+ }
69
+ return text;
70
+ }
71
+ /** Key to use: the agent's MCP config first, else the one saved by a Hydrilla plugin. */
72
+ export function resolveApiKey() {
73
+ const fromEnv = cleanApiKey(process.env.HYDRILLA_API_KEY || process.env.HYDRILLA_API_TOKEN);
74
+ if (fromEnv) {
75
+ return fromEnv;
76
+ }
77
+ return cleanApiKey(readSharedConfig().api_key);
78
+ }
79
+ export function normalizeApiBase(value) {
80
+ const text = typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
81
+ if (!/^https?:\/\/\S+$/i.test(text)) {
82
+ return DEFAULT_API_BASE;
83
+ }
84
+ if (RETIRED_API_HOSTS.some((host) => text.toLowerCase().includes(host))) {
85
+ return DEFAULT_API_BASE;
86
+ }
87
+ return text;
88
+ }
89
+ export function resolveApiBase() {
90
+ const fromEnv = (process.env.HYDRILLA_API_BASE || "").trim();
91
+ if (fromEnv) {
92
+ return normalizeApiBase(fromEnv);
93
+ }
94
+ return normalizeApiBase(readSharedConfig().api_base);
95
+ }
96
+ /** Project token from env only. Secrets are never hardcoded. */
97
+ export function getPostHogProjectToken() {
98
+ return (process.env.POSTHOG_PROJECT_TOKEN ||
99
+ process.env.POSTHOG_API_KEY ||
100
+ process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN ||
101
+ process.env.NEXT_PUBLIC_POSTHOG_KEY);
102
+ }
103
+ export function getPostHogHost() {
104
+ return (process.env.POSTHOG_HOST ||
105
+ process.env.NEXT_PUBLIC_POSTHOG_HOST ||
106
+ "https://us.i.posthog.com");
107
+ }
package/dist/index.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { PostHog, instrument } from "@posthog/mcp";
5
+ import { z } from "zod";
6
+ import { CREATE_TIMEOUT_MS, api, getTask, waitForTask } from "./api.js";
7
+ import { APPS, appName, listListeningApps, sendMeshToApp } from "./bridge.js";
8
+ import { SERVER_VERSION, getPostHogHost, getPostHogProjectToken, loadLocalEnv } from "./config.js";
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;
12
+ const jsonResult = (value) => ({
13
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
14
+ });
15
+ const errorResult = (error) => ({
16
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
17
+ isError: true,
18
+ });
19
+ /** Run a tool body and turn any failure into a readable tool error. */
20
+ const guarded = async (work) => {
21
+ try {
22
+ return await work();
23
+ }
24
+ catch (error) {
25
+ return errorResult(error);
26
+ }
27
+ };
28
+ /** Progress keeps long generations from hitting the client's request timeout. */
29
+ const progressReporter = (extra) => async (task) => {
30
+ const progressToken = extra._meta?.progressToken;
31
+ if (progressToken === undefined) {
32
+ return;
33
+ }
34
+ try {
35
+ await extra.sendNotification({
36
+ method: "notifications/progress",
37
+ params: {
38
+ progressToken,
39
+ progress: Math.max(0, Math.min(99, Math.round(task.progress ?? 0))),
40
+ total: 100,
41
+ message: task.status_message || (task.status === "PENDING" ? "Waiting for a GPU" : "Generating"),
42
+ },
43
+ });
44
+ }
45
+ catch {
46
+ // Progress is a courtesy; the tool result still arrives without it.
47
+ }
48
+ };
49
+ const meshUrlOf = (task) => task.result?.mesh_url || "";
50
+ /** Send a finished model to an app, reporting a failure without losing the result. */
51
+ const deliver = async (task, app, name) => {
52
+ if (!app) {
53
+ return {};
54
+ }
55
+ try {
56
+ await sendMeshToApp(app, { meshUrl: meshUrlOf(task), name, taskId: task.id });
57
+ return { sent_to: appName(app) };
58
+ }
59
+ catch (error) {
60
+ return { send_error: error instanceof Error ? error.message : String(error) };
61
+ }
62
+ };
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));
73
+ if (timedOut) {
74
+ return jsonResult({
75
+ ...task,
76
+ id: taskId,
77
+ note: "Still generating. Call hydrilla_get_task with this id in a minute; do not start the generation again.",
78
+ });
79
+ }
80
+ if (task.status !== "SUCCEEDED") {
81
+ return errorResult(new Error(task.error || `Generation ${task.status.toLowerCase()}.`));
82
+ }
83
+ const delivery = await deliver({ ...task, id: taskId }, options.sendTo, options.name);
84
+ return jsonResult({ ...task, id: taskId, ...delivery });
85
+ };
86
+ const server = new McpServer({
87
+ name: "hydrilla",
88
+ version: SERVER_VERSION,
89
+ });
90
+ const posthogToken = getPostHogProjectToken();
91
+ const posthog = posthogToken
92
+ ? new PostHog(posthogToken, {
93
+ host: getPostHogHost(),
94
+ // Stdio processes exit quickly; flush aggressively so tool-call events land.
95
+ flushAt: 1,
96
+ flushInterval: 0,
97
+ })
98
+ : null;
99
+ if (posthog) {
100
+ instrument(server, posthog, {
101
+ // Stdio must not write analytics warnings to stdout (corrupts MCP framing).
102
+ logger: (message) => {
103
+ process.stderr.write(`[posthog-mcp] ${message}\n`);
104
+ },
105
+ });
106
+ }
107
+ const shutdownPostHog = async () => {
108
+ if (!posthog) {
109
+ return;
110
+ }
111
+ try {
112
+ await posthog.shutdown();
113
+ }
114
+ catch {
115
+ // Analytics must never break the MCP process.
116
+ }
117
+ };
118
+ const exitAfterFlush = async () => {
119
+ await shutdownPostHog();
120
+ process.exit(0);
121
+ };
122
+ process.once("SIGTERM", () => {
123
+ void exitAfterFlush();
124
+ });
125
+ process.once("SIGINT", () => {
126
+ void exitAfterFlush();
127
+ });
128
+ const sendToSchema = z
129
+ .enum(APPS)
130
+ .optional()
131
+ .describe("Put the finished model straight into this open app (the app's Hydrilla plugin must have its Bridge running). Use when the user asks for the model in their scene or project.");
132
+ const nameSchema = z
133
+ .string()
134
+ .max(48)
135
+ .optional()
136
+ .describe("Short name for the imported object, e.g. 'treasure-chest'");
137
+ 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.", {
139
+ prompt: z.string().min(1).describe("What to generate"),
140
+ wait: z.boolean().optional().describe("Wait until the model is ready (default true)"),
141
+ send_to: sendToSchema,
142
+ name: nameSchema,
143
+ }, async ({ prompt, wait = true, send_to, name }, extra) => guarded(async () => {
144
+ 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);
146
+ }));
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.", {
148
+ image_url: z.string().url().describe("Public image URL"),
149
+ wait: z.boolean().optional().describe("Wait until the model is ready (default true)"),
150
+ send_to: sendToSchema,
151
+ name: nameSchema,
152
+ }, async ({ image_url, wait = true, send_to, name }, extra) => guarded(async () => {
153
+ 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);
155
+ }));
156
+ server.tool("hydrilla_get_task", "Check a Hydrilla 3D task by id. Returns its status and, once it has succeeded, the mesh URL.", {
157
+ task_id: z.string().min(1),
158
+ }, async ({ task_id }) => guarded(async () => jsonResult(await getTask(task_id))));
159
+ 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
+ limit: z.number().int().min(1).max(100).optional().describe("How many to return (default 20)"),
161
+ }, async ({ limit = 20 }) => guarded(async () => jsonResult(await api(`/v1/3d/tasks?limit=${limit}`))));
162
+ server.tool("hydrilla_cancel_task", "Stop a Hydrilla 3D task that is still generating. Unused credits are refunded.", {
163
+ task_id: z.string().min(1),
164
+ }, async ({ task_id }) => guarded(async () => jsonResult(await api(`/v1/3d/tasks/${encodeURIComponent(task_id)}`, { method: "DELETE" }))));
165
+ server.tool("hydrilla_list_apps", "List which of the user's apps (Blender, Maya, 3ds Max, Unreal, Unity, Godot) are open with the Hydrilla Bridge running and can receive a model right now. Free.", {}, async () => guarded(async () => {
166
+ const listening = await listListeningApps();
167
+ return jsonResult({
168
+ ready: listening,
169
+ note: listening.length > 0
170
+ ? "Pass one of these as send_to, or use hydrilla_send_to_app."
171
+ : "No app is listening. Ask the user to open the Hydrilla plugin in their app and click Run Bridge.",
172
+ });
173
+ }));
174
+ server.tool("hydrilla_send_to_app", "Put an already generated model into the user's open app (Blender, Unity, Unreal, Godot, Maya or 3ds Max). Free; does not generate anything.", {
175
+ app: z.enum(APPS).describe("Which app to send the model to"),
176
+ task_id: z.string().min(1).describe("Id of a task that has succeeded"),
177
+ name: nameSchema,
178
+ }, async ({ app, task_id, name }) => guarded(async () => {
179
+ const task = await getTask(task_id);
180
+ if (task.status !== "SUCCEEDED" || !meshUrlOf(task)) {
181
+ return errorResult(new Error(`Task ${task_id} is ${task.status}, so there is no model to send yet. Check it with hydrilla_get_task.`));
182
+ }
183
+ await sendMeshToApp(app, { meshUrl: meshUrlOf(task), name, taskId: task_id });
184
+ return jsonResult({ id: task_id, sent_to: appName(app) });
185
+ }));
186
+ const transport = new StdioServerTransport();
187
+ await server.connect(transport);
188
+ process.once("beforeExit", () => {
189
+ void shutdownPostHog();
190
+ });
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "hydrilla-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "MCP server for Hydrilla: generate 3D models and send them into Blender, Unity, Unreal, Godot, Maya or 3ds Max.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "hydrilla",
9
+ "3d",
10
+ "text-to-3d",
11
+ "image-to-3d",
12
+ "blender",
13
+ "unity",
14
+ "unreal",
15
+ "godot"
16
+ ],
17
+ "homepage": "https://hydrilla.ai/integrations/mcp",
18
+ "license": "UNLICENSED",
19
+ "type": "module",
20
+ "bin": {
21
+ "hydrilla-mcp": "./dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "start": "node dist/index.js",
33
+ "dev": "tsx src/index.ts",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "engines": {
37
+ "node": "^20.20.0 || >=22.22.0"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.32.2",
41
+ "@posthog/mcp": "^1.0.0",
42
+ "posthog-node": "^5.63.1",
43
+ "zod": "^3.24.2"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.13.10",
47
+ "tsx": "^4.19.3",
48
+ "typescript": "^5.8.2"
49
+ }
50
+ }