helipod-mcp 0.5.7 → 0.5.8

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/dist/http.js CHANGED
@@ -35621,6 +35621,33 @@ class HelipodClient {
35621
35621
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
35622
35622
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
35623
35623
  }
35624
+ async image(link, maxBytes) {
35625
+ let url2;
35626
+ try {
35627
+ url2 = new URL(link);
35628
+ } catch {
35629
+ return null;
35630
+ }
35631
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
35632
+ return null;
35633
+ try {
35634
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
35635
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
35636
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
35637
+ return null;
35638
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
35639
+ return null;
35640
+ const buf = new Uint8Array(await res.arrayBuffer());
35641
+ if (buf.byteLength > maxBytes)
35642
+ return null;
35643
+ let bin = "";
35644
+ for (let i = 0;i < buf.length; i += 32768)
35645
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
35646
+ return { data: btoa(bin), mimeType };
35647
+ } catch {
35648
+ return null;
35649
+ }
35650
+ }
35624
35651
  async request(base, key, method, path, opts) {
35625
35652
  const raw = path.startsWith("/") ? path : `/${path}`;
35626
35653
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -35680,7 +35707,8 @@ function register(server, api2, defs) {
35680
35707
  }, async (args) => {
35681
35708
  try {
35682
35709
  const data = await d.run(args, api2);
35683
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
35710
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
35711
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
35684
35712
  } catch (e) {
35685
35713
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
35686
35714
  return {
@@ -37960,12 +37988,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37960
37988
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37961
37989
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37962
37990
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
37991
+ var imageProject = exports_external.string().uuid().optional().describe("Project the images belong to (required with a whole-account key; a project key uses its own)");
37992
+ var INLINE_MAX_BYTES = 1500000;
37993
+ var INLINE_TOTAL_BYTES = 4000000;
37994
+ var INLINE_MAX_IMAGES = 4;
37995
+ var POLL_MS = 3000;
37996
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37997
+ async function showImages(job, api2) {
37998
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
37999
+ return [];
38000
+ const out = [];
38001
+ let total = 0;
38002
+ for (const [i, img] of job.data.entries()) {
38003
+ const name = `${job.id}-${i + 1}`;
38004
+ const inline = out.filter((x) => x.type === "image").length < INLINE_MAX_IMAGES ? await api2.image(img.url, Math.min(INLINE_MAX_BYTES, INLINE_TOTAL_BYTES - total)) : null;
38005
+ if (inline) {
38006
+ total += Math.ceil(inline.data.length * 3 / 4);
38007
+ out.push({ type: "image", ...inline });
38008
+ }
38009
+ out.push({ type: "resource_link", uri: img.url, name, description: `Generated image ${i + 1} (link valid until the image is deleted, ${job.expires_at ? new Date(job.expires_at * 1000).toISOString().slice(0, 10) : "in 7 days"})` });
38010
+ }
38011
+ return out;
38012
+ }
37963
38013
  var aiTools = [
37964
38014
  defineTool({
37965
38015
  name: "list_ai_models",
37966
38016
  title: "List AI models and prices",
37967
38017
  kind: "read",
37968
- description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running.",
38018
+ description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running. imageModels lists the image models (generate_ai_image) with their sizes and price per image.",
37969
38019
  input: {},
37970
38020
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37971
38021
  }),
@@ -38009,6 +38059,60 @@ var aiTools = [
38009
38059
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
38010
38060
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
38011
38061
  }),
38062
+ defineTool({
38063
+ name: "generate_ai_image",
38064
+ title: "Generate images",
38065
+ kind: "write",
38066
+ description: "Generate images with a Helipod AI Gateway image model (list_ai_models shows them under imageModels, with sizes and the price per image). COSTS MONEY from the AI balance: call with dryRun=true first to get the price, tell the user, and wait for a yes. The real call waits up to `wait` seconds (default 50) and returns the images (shown inline when small, plus links); if it is still running it returns the id: call get_ai_image. Images are kept 7 days, then deleted: tell the user to download the ones they keep. Failed images are not charged.",
38067
+ input: {
38068
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
38069
+ prompt: exports_external.string().min(1).max(4000),
38070
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
38071
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
38072
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
38073
+ projectId: imageProject,
38074
+ dryRun: dryRun5,
38075
+ wait: exports_external.number().int().min(0).max(55).optional().describe("Seconds to wait for the result (default 50; 0 = return the id at once)"),
38076
+ idempotencyKey: exports_external.string().regex(/^[A-Za-z0-9_.:-]{8,128}$/).optional().describe("Idempotency-Key for the real request. Generated when omitted; pass the same value when retrying so it is never charged twice")
38077
+ },
38078
+ run: async (a, api2) => {
38079
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
38080
+ if (a.dryRun)
38081
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
38082
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
38083
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
38084
+ let job = { ...started };
38085
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
38086
+ await sleep(POLL_MS);
38087
+ job = await api2.api("GET", `/ai/images/${started.id}`);
38088
+ }
38089
+ if (job.status === "processing")
38090
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
38091
+ return job;
38092
+ },
38093
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
38094
+ }),
38095
+ defineTool({
38096
+ name: "get_ai_image",
38097
+ title: "Get an image request",
38098
+ kind: "read",
38099
+ description: "One image request by id (from generate_ai_image): status (processing / succeeded / failed), the images (shown inline when small, plus fresh links), the date they are deleted, or the error.",
38100
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
38101
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
38102
+ present: (data, api2) => showImages(data, api2)
38103
+ }),
38104
+ defineTool({
38105
+ name: "list_ai_images",
38106
+ title: "List generated images",
38107
+ kind: "read",
38108
+ description: "Image requests of a project from the last 7 days (older ones are deleted), newest first: id, model, prompt, size, status, fresh links. Page with `before` (the last id of the previous page).",
38109
+ input: {
38110
+ projectId: imageProject,
38111
+ limit: exports_external.number().int().min(1).max(50).optional(),
38112
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
38113
+ },
38114
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
38115
+ }),
38012
38116
  defineTool({
38013
38117
  name: "topup_ai_balance",
38014
38118
  title: "Top up AI balance",
@@ -38076,15 +38180,16 @@ var generalTools = [
38076
38180
  ];
38077
38181
 
38078
38182
  // src/server.ts
38079
- var VERSION = "0.5.7";
38183
+ var VERSION = "0.5.8";
38080
38184
  var INSTRUCTIONS = `Helipod is a cloud platform (Indonesia): containers, managed Postgres databases, VPS, domains, email and an AI gateway, billed from a prepaid wallet in Rupiah.
38081
- - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups) cover the AI gateway.
38082
- - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
38185
+ - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups, generate_ai_image) cover the AI gateway.
38186
+ - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up, generating images): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
38083
38187
  - Destructive tools (delete, terminate, reinstall, rollback, restore a backup or an import, copy a backup onto another VPS, replace DNS, revoke a key, remove an SSH key or port forward, send a broadcast, execute_database_sql): confirm with the user and name what will be lost. Before execute_database_sql, run classify_database_sql.
38084
38188
  - Email: when a project has no email yet (email_status shows activated false) or its plan ended, start it with buy_email_plan (dryRun first; the FREE plan costs nothing, still confirm). That also sets email up for the project; then add and verify a sending domain.
38085
38189
  - Before replacing all DNS records (set_dns_records), read list_dns_records and keep every record the user still needs. Before sending or scheduling a broadcast, tell the user how many contacts it reaches. Before deleting or replacing code, files or variables, read the current value. After renaming a service, fix the variable references listed in warnings.
38086
38190
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
38087
38191
  - Migrate (free): create_vps_import returns a one-time command for the USER to run as root on their old server (never run it yourself); before the first restore_vps_import, show fetchedIp and ask the user to confirm it is their server.
38192
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
38088
38193
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
38089
38194
  - Never print secrets (passwords, secret variable values, secret files) unless the user asks for them. Domain owner details are personal data: use only what the user gave.
38090
38195
  - Errors come back as { status, code, error }: 401/403 means the key lacks access (keys choose products and projects in Settings → API keys; wallet, invoices, tickets and AI money need a whole-account key: with a sign-in, reconnect and choose Whole account), 402 means plan limit or low balance.
package/dist/index.js CHANGED
@@ -34766,6 +34766,33 @@ class HelipodClient {
34766
34766
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
34767
34767
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
34768
34768
  }
34769
+ async image(link, maxBytes) {
34770
+ let url2;
34771
+ try {
34772
+ url2 = new URL(link);
34773
+ } catch {
34774
+ return null;
34775
+ }
34776
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
34777
+ return null;
34778
+ try {
34779
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
34780
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
34781
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
34782
+ return null;
34783
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
34784
+ return null;
34785
+ const buf = new Uint8Array(await res.arrayBuffer());
34786
+ if (buf.byteLength > maxBytes)
34787
+ return null;
34788
+ let bin = "";
34789
+ for (let i = 0;i < buf.length; i += 32768)
34790
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
34791
+ return { data: btoa(bin), mimeType };
34792
+ } catch {
34793
+ return null;
34794
+ }
34795
+ }
34769
34796
  async request(base, key, method, path, opts) {
34770
34797
  const raw = path.startsWith("/") ? path : `/${path}`;
34771
34798
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -34825,7 +34852,8 @@ function register(server, api2, defs) {
34825
34852
  }, async (args) => {
34826
34853
  try {
34827
34854
  const data = await d.run(args, api2);
34828
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
34855
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
34856
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
34829
34857
  } catch (e) {
34830
34858
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
34831
34859
  return {
@@ -37105,12 +37133,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37105
37133
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37106
37134
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37107
37135
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
37136
+ var imageProject = exports_external.string().uuid().optional().describe("Project the images belong to (required with a whole-account key; a project key uses its own)");
37137
+ var INLINE_MAX_BYTES = 1500000;
37138
+ var INLINE_TOTAL_BYTES = 4000000;
37139
+ var INLINE_MAX_IMAGES = 4;
37140
+ var POLL_MS = 3000;
37141
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37142
+ async function showImages(job, api2) {
37143
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
37144
+ return [];
37145
+ const out = [];
37146
+ let total = 0;
37147
+ for (const [i, img] of job.data.entries()) {
37148
+ const name = `${job.id}-${i + 1}`;
37149
+ const inline = out.filter((x) => x.type === "image").length < INLINE_MAX_IMAGES ? await api2.image(img.url, Math.min(INLINE_MAX_BYTES, INLINE_TOTAL_BYTES - total)) : null;
37150
+ if (inline) {
37151
+ total += Math.ceil(inline.data.length * 3 / 4);
37152
+ out.push({ type: "image", ...inline });
37153
+ }
37154
+ out.push({ type: "resource_link", uri: img.url, name, description: `Generated image ${i + 1} (link valid until the image is deleted, ${job.expires_at ? new Date(job.expires_at * 1000).toISOString().slice(0, 10) : "in 7 days"})` });
37155
+ }
37156
+ return out;
37157
+ }
37108
37158
  var aiTools = [
37109
37159
  defineTool({
37110
37160
  name: "list_ai_models",
37111
37161
  title: "List AI models and prices",
37112
37162
  kind: "read",
37113
- description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running.",
37163
+ description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running. imageModels lists the image models (generate_ai_image) with their sizes and price per image.",
37114
37164
  input: {},
37115
37165
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37116
37166
  }),
@@ -37154,6 +37204,60 @@ var aiTools = [
37154
37204
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
37155
37205
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
37156
37206
  }),
37207
+ defineTool({
37208
+ name: "generate_ai_image",
37209
+ title: "Generate images",
37210
+ kind: "write",
37211
+ description: "Generate images with a Helipod AI Gateway image model (list_ai_models shows them under imageModels, with sizes and the price per image). COSTS MONEY from the AI balance: call with dryRun=true first to get the price, tell the user, and wait for a yes. The real call waits up to `wait` seconds (default 50) and returns the images (shown inline when small, plus links); if it is still running it returns the id: call get_ai_image. Images are kept 7 days, then deleted: tell the user to download the ones they keep. Failed images are not charged.",
37212
+ input: {
37213
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
37214
+ prompt: exports_external.string().min(1).max(4000),
37215
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
37216
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
37217
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
37218
+ projectId: imageProject,
37219
+ dryRun: dryRun5,
37220
+ wait: exports_external.number().int().min(0).max(55).optional().describe("Seconds to wait for the result (default 50; 0 = return the id at once)"),
37221
+ idempotencyKey: exports_external.string().regex(/^[A-Za-z0-9_.:-]{8,128}$/).optional().describe("Idempotency-Key for the real request. Generated when omitted; pass the same value when retrying so it is never charged twice")
37222
+ },
37223
+ run: async (a, api2) => {
37224
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
37225
+ if (a.dryRun)
37226
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
37227
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
37228
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
37229
+ let job = { ...started };
37230
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
37231
+ await sleep(POLL_MS);
37232
+ job = await api2.api("GET", `/ai/images/${started.id}`);
37233
+ }
37234
+ if (job.status === "processing")
37235
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
37236
+ return job;
37237
+ },
37238
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
37239
+ }),
37240
+ defineTool({
37241
+ name: "get_ai_image",
37242
+ title: "Get an image request",
37243
+ kind: "read",
37244
+ description: "One image request by id (from generate_ai_image): status (processing / succeeded / failed), the images (shown inline when small, plus fresh links), the date they are deleted, or the error.",
37245
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
37246
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
37247
+ present: (data, api2) => showImages(data, api2)
37248
+ }),
37249
+ defineTool({
37250
+ name: "list_ai_images",
37251
+ title: "List generated images",
37252
+ kind: "read",
37253
+ description: "Image requests of a project from the last 7 days (older ones are deleted), newest first: id, model, prompt, size, status, fresh links. Page with `before` (the last id of the previous page).",
37254
+ input: {
37255
+ projectId: imageProject,
37256
+ limit: exports_external.number().int().min(1).max(50).optional(),
37257
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
37258
+ },
37259
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
37260
+ }),
37157
37261
  defineTool({
37158
37262
  name: "topup_ai_balance",
37159
37263
  title: "Top up AI balance",
@@ -37221,15 +37325,16 @@ var generalTools = [
37221
37325
  ];
37222
37326
 
37223
37327
  // src/server.ts
37224
- var VERSION = "0.5.7";
37328
+ var VERSION = "0.5.8";
37225
37329
  var INSTRUCTIONS = `Helipod is a cloud platform (Indonesia): containers, managed Postgres databases, VPS, domains, email and an AI gateway, billed from a prepaid wallet in Rupiah.
37226
- - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups) cover the AI gateway.
37227
- - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
37330
+ - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups, generate_ai_image) cover the AI gateway.
37331
+ - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up, generating images): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
37228
37332
  - Destructive tools (delete, terminate, reinstall, rollback, restore a backup or an import, copy a backup onto another VPS, replace DNS, revoke a key, remove an SSH key or port forward, send a broadcast, execute_database_sql): confirm with the user and name what will be lost. Before execute_database_sql, run classify_database_sql.
37229
37333
  - Email: when a project has no email yet (email_status shows activated false) or its plan ended, start it with buy_email_plan (dryRun first; the FREE plan costs nothing, still confirm). That also sets email up for the project; then add and verify a sending domain.
37230
37334
  - Before replacing all DNS records (set_dns_records), read list_dns_records and keep every record the user still needs. Before sending or scheduling a broadcast, tell the user how many contacts it reaches. Before deleting or replacing code, files or variables, read the current value. After renaming a service, fix the variable references listed in warnings.
37231
37335
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37232
37336
  - Migrate (free): create_vps_import returns a one-time command for the USER to run as root on their old server (never run it yourself); before the first restore_vps_import, show fetchedIp and ask the user to confirm it is their server.
37337
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
37233
37338
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
37234
37339
  - Never print secrets (passwords, secret variable values, secret files) unless the user asks for them. Domain owner details are personal data: use only what the user gave.
37235
37340
  - Errors come back as { status, code, error }: 401/403 means the key lacks access (keys choose products and projects in Settings → API keys; wallet, invoices, tickets and AI money need a whole-account key: with a sign-in, reconnect and choose Whole account), 402 means plan limit or low balance.
package/dist/stdio.js CHANGED
@@ -34873,6 +34873,33 @@ class HelipodClient {
34873
34873
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
34874
34874
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
34875
34875
  }
34876
+ async image(link, maxBytes) {
34877
+ let url2;
34878
+ try {
34879
+ url2 = new URL(link);
34880
+ } catch {
34881
+ return null;
34882
+ }
34883
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
34884
+ return null;
34885
+ try {
34886
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
34887
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
34888
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
34889
+ return null;
34890
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
34891
+ return null;
34892
+ const buf = new Uint8Array(await res.arrayBuffer());
34893
+ if (buf.byteLength > maxBytes)
34894
+ return null;
34895
+ let bin = "";
34896
+ for (let i = 0;i < buf.length; i += 32768)
34897
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
34898
+ return { data: btoa(bin), mimeType };
34899
+ } catch {
34900
+ return null;
34901
+ }
34902
+ }
34876
34903
  async request(base, key, method, path, opts) {
34877
34904
  const raw = path.startsWith("/") ? path : `/${path}`;
34878
34905
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -34932,7 +34959,8 @@ function register(server, api2, defs) {
34932
34959
  }, async (args) => {
34933
34960
  try {
34934
34961
  const data = await d.run(args, api2);
34935
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
34962
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
34963
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
34936
34964
  } catch (e) {
34937
34965
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
34938
34966
  return {
@@ -37212,12 +37240,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37212
37240
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37213
37241
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37214
37242
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
37243
+ var imageProject = exports_external.string().uuid().optional().describe("Project the images belong to (required with a whole-account key; a project key uses its own)");
37244
+ var INLINE_MAX_BYTES = 1500000;
37245
+ var INLINE_TOTAL_BYTES = 4000000;
37246
+ var INLINE_MAX_IMAGES = 4;
37247
+ var POLL_MS = 3000;
37248
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37249
+ async function showImages(job, api2) {
37250
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
37251
+ return [];
37252
+ const out = [];
37253
+ let total = 0;
37254
+ for (const [i, img] of job.data.entries()) {
37255
+ const name = `${job.id}-${i + 1}`;
37256
+ const inline = out.filter((x) => x.type === "image").length < INLINE_MAX_IMAGES ? await api2.image(img.url, Math.min(INLINE_MAX_BYTES, INLINE_TOTAL_BYTES - total)) : null;
37257
+ if (inline) {
37258
+ total += Math.ceil(inline.data.length * 3 / 4);
37259
+ out.push({ type: "image", ...inline });
37260
+ }
37261
+ out.push({ type: "resource_link", uri: img.url, name, description: `Generated image ${i + 1} (link valid until the image is deleted, ${job.expires_at ? new Date(job.expires_at * 1000).toISOString().slice(0, 10) : "in 7 days"})` });
37262
+ }
37263
+ return out;
37264
+ }
37215
37265
  var aiTools = [
37216
37266
  defineTool({
37217
37267
  name: "list_ai_models",
37218
37268
  title: "List AI models and prices",
37219
37269
  kind: "read",
37220
- description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running.",
37270
+ description: "Models available through the Helipod AI Gateway with the price this account pays (IDR per 1M input / cached / output tokens) and any promos running. imageModels lists the image models (generate_ai_image) with their sizes and price per image.",
37221
37271
  input: {},
37222
37272
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37223
37273
  }),
@@ -37261,6 +37311,60 @@ var aiTools = [
37261
37311
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
37262
37312
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
37263
37313
  }),
37314
+ defineTool({
37315
+ name: "generate_ai_image",
37316
+ title: "Generate images",
37317
+ kind: "write",
37318
+ description: "Generate images with a Helipod AI Gateway image model (list_ai_models shows them under imageModels, with sizes and the price per image). COSTS MONEY from the AI balance: call with dryRun=true first to get the price, tell the user, and wait for a yes. The real call waits up to `wait` seconds (default 50) and returns the images (shown inline when small, plus links); if it is still running it returns the id: call get_ai_image. Images are kept 7 days, then deleted: tell the user to download the ones they keep. Failed images are not charged.",
37319
+ input: {
37320
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
37321
+ prompt: exports_external.string().min(1).max(4000),
37322
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
37323
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
37324
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
37325
+ projectId: imageProject,
37326
+ dryRun: dryRun5,
37327
+ wait: exports_external.number().int().min(0).max(55).optional().describe("Seconds to wait for the result (default 50; 0 = return the id at once)"),
37328
+ idempotencyKey: exports_external.string().regex(/^[A-Za-z0-9_.:-]{8,128}$/).optional().describe("Idempotency-Key for the real request. Generated when omitted; pass the same value when retrying so it is never charged twice")
37329
+ },
37330
+ run: async (a, api2) => {
37331
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
37332
+ if (a.dryRun)
37333
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
37334
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
37335
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
37336
+ let job = { ...started };
37337
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
37338
+ await sleep(POLL_MS);
37339
+ job = await api2.api("GET", `/ai/images/${started.id}`);
37340
+ }
37341
+ if (job.status === "processing")
37342
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
37343
+ return job;
37344
+ },
37345
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
37346
+ }),
37347
+ defineTool({
37348
+ name: "get_ai_image",
37349
+ title: "Get an image request",
37350
+ kind: "read",
37351
+ description: "One image request by id (from generate_ai_image): status (processing / succeeded / failed), the images (shown inline when small, plus fresh links), the date they are deleted, or the error.",
37352
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
37353
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
37354
+ present: (data, api2) => showImages(data, api2)
37355
+ }),
37356
+ defineTool({
37357
+ name: "list_ai_images",
37358
+ title: "List generated images",
37359
+ kind: "read",
37360
+ description: "Image requests of a project from the last 7 days (older ones are deleted), newest first: id, model, prompt, size, status, fresh links. Page with `before` (the last id of the previous page).",
37361
+ input: {
37362
+ projectId: imageProject,
37363
+ limit: exports_external.number().int().min(1).max(50).optional(),
37364
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
37365
+ },
37366
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
37367
+ }),
37264
37368
  defineTool({
37265
37369
  name: "topup_ai_balance",
37266
37370
  title: "Top up AI balance",
@@ -37328,15 +37432,16 @@ var generalTools = [
37328
37432
  ];
37329
37433
 
37330
37434
  // src/server.ts
37331
- var VERSION = "0.5.7";
37435
+ var VERSION = "0.5.8";
37332
37436
  var INSTRUCTIONS = `Helipod is a cloud platform (Indonesia): containers, managed Postgres databases, VPS, domains, email and an AI gateway, billed from a prepaid wallet in Rupiah.
37333
- - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups) cover the AI gateway.
37334
- - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
37437
+ - Start with list_projects to find projects and environment ids. Account tools (get_wallet, invoices, statements, notifications, get_project_activity, support tickets) cover the account; AI tools (list_ai_models, get_ai_overview, get_ai_usage, AI keys, top-ups, generate_ai_image) cover the AI gateway.
37438
+ - Anything that costs money (create/deploy/scale services, resize volumes, upgrade a database plan, create/renew/upgrade VPS, VPS backups, register/renew/transfer domains, email plans and inbox add-ons, AI top-ups and auto top-up, generating images): call it with dryRun=true first (or the price lookup when it has no dry run), tell the user the cost in IDR and the wallet balance after, and wait for an explicit yes before the real call. Never treat silence or an earlier general OK as a yes.
37335
37439
  - Destructive tools (delete, terminate, reinstall, rollback, restore a backup or an import, copy a backup onto another VPS, replace DNS, revoke a key, remove an SSH key or port forward, send a broadcast, execute_database_sql): confirm with the user and name what will be lost. Before execute_database_sql, run classify_database_sql.
37336
37440
  - Email: when a project has no email yet (email_status shows activated false) or its plan ended, start it with buy_email_plan (dryRun first; the FREE plan costs nothing, still confirm). That also sets email up for the project; then add and verify a sending domain.
37337
37441
  - Before replacing all DNS records (set_dns_records), read list_dns_records and keep every record the user still needs. Before sending or scheduling a broadcast, tell the user how many contacts it reaches. Before deleting or replacing code, files or variables, read the current value. After renaming a service, fix the variable references listed in warnings.
37338
37442
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37339
37443
  - Migrate (free): create_vps_import returns a one-time command for the USER to run as root on their old server (never run it yourself); before the first restore_vps_import, show fetchedIp and ask the user to confirm it is their server.
37444
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
37340
37445
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
37341
37446
  - Never print secrets (passwords, secret variable values, secret files) unless the user asks for them. Domain owner details are personal data: use only what the user gave.
37342
37447
  - Errors come back as { status, code, error }: 401/403 means the key lacks access (keys choose products and projects in Settings → API keys; wallet, invoices, tickets and AI money need a whole-account key: with a sign-in, reconnect and choose Whole account), 402 means plan limit or low balance.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "helipod-mcp",
3
3
  "mcpName": "io.helipod/helipod",
4
- "version": "0.5.7",
4
+ "version": "0.5.8",
5
5
  "description": "MCP server for Helipod: containers, databases, VPS, domains and email from Claude Code, Codex, Cursor and other AI tools.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -49,6 +49,7 @@ const img = await ai.images.generate({ model: "seedream-image-v5.0-flash", promp
49
49
  - Image models are NOT in `/v1/models`; they are listed with sizes and prices at https://docs.helipod.io/docs/ai-gateway/images (cheapest: `seedream-image-v5.0-flash`, about Rp 320 per image).
50
50
  - **Images are kept for 7 days, then deleted with their links** (`expires_at` in every answer). When an app needs images longer, download them right away and store them in the app's own storage; never save only the returned URL. Tell the user this when you build it.
51
51
  - The request waits up to ~80 s; otherwise it answers `202` with an `id`: poll `GET /v1/images/generations/{id}` until `status` is `succeeded` or `failed`. Failed images are not charged.
52
+ - To make an image for the user right now (not in their code), use the MCP tools instead: `generate_ai_image` (dryRun=true first for the price, then the real call; it returns the images and their links), `get_ai_image`, `list_ai_images`. They bill the AI balance the same way.
52
53
 
53
54
  ## Practical rules
54
55