helipod-mcp 0.5.7 → 0.5.9

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
@@ -35582,10 +35582,12 @@ var EMPTY_COMPLETION_RESULT = {
35582
35582
  class HelipodError extends Error {
35583
35583
  status;
35584
35584
  code;
35585
- constructor(status, code, message) {
35585
+ details;
35586
+ constructor(status, code, message, details) {
35586
35587
  super(message);
35587
35588
  this.status = status;
35588
35589
  this.code = code;
35590
+ this.details = details;
35589
35591
  this.name = "HelipodError";
35590
35592
  }
35591
35593
  }
@@ -35621,6 +35623,33 @@ class HelipodClient {
35621
35623
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
35622
35624
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
35623
35625
  }
35626
+ async image(link, maxBytes) {
35627
+ let url2;
35628
+ try {
35629
+ url2 = new URL(link);
35630
+ } catch {
35631
+ return null;
35632
+ }
35633
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
35634
+ return null;
35635
+ try {
35636
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
35637
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
35638
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
35639
+ return null;
35640
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
35641
+ return null;
35642
+ const buf = new Uint8Array(await res.arrayBuffer());
35643
+ if (buf.byteLength > maxBytes)
35644
+ return null;
35645
+ let bin = "";
35646
+ for (let i = 0;i < buf.length; i += 32768)
35647
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
35648
+ return { data: btoa(bin), mimeType };
35649
+ } catch {
35650
+ return null;
35651
+ }
35652
+ }
35624
35653
  async request(base, key, method, path, opts) {
35625
35654
  const raw = path.startsWith("/") ? path : `/${path}`;
35626
35655
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -35654,7 +35683,8 @@ class HelipodClient {
35654
35683
  if (!res.ok || json2?.success === false) {
35655
35684
  const retry = res.headers.get("retry-after");
35656
35685
  const msg = json2?.error ?? (text.slice(0, 300) || res.statusText);
35657
- throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg));
35686
+ const details = json2?.details && typeof json2.details === "object" && !Array.isArray(json2.details) ? json2.details : undefined;
35687
+ throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg), details);
35658
35688
  }
35659
35689
  return json2 && "data" in json2 ? json2.data : json2;
35660
35690
  }
@@ -35680,12 +35710,13 @@ function register(server, api2, defs) {
35680
35710
  }, async (args) => {
35681
35711
  try {
35682
35712
  const data = await d.run(args, api2);
35683
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
35713
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
35714
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
35684
35715
  } catch (e) {
35685
35716
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
35686
35717
  return {
35687
35718
  isError: true,
35688
- content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message }, null, 2) }]
35719
+ content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message, ...err.details ? { details: err.details } : {} }, null, 2) }]
35689
35720
  };
35690
35721
  }
35691
35722
  });
@@ -35819,18 +35850,48 @@ var containerTools = [
35819
35850
  input: { serviceId: id("Service") },
35820
35851
  run: (a, api2) => api2.api("GET", `${C}/services/${a.serviceId}`)
35821
35852
  }),
35853
+ defineTool({
35854
+ name: "get_git_connections",
35855
+ title: "Git connections",
35856
+ kind: "read",
35857
+ description: "Whether the user connected GitHub (sign-in + the Helipod GitHub App, with the accounts it is installed on) and GitLab, plus connectUrl: the Helipod page where the user connects them. Check this before deploying from a private repo. Connecting needs the user in a browser: give them connectUrl, never try to connect yourself.",
35858
+ input: {},
35859
+ run: (_a3, api2) => api2.api("GET", `${C}/git/connections`)
35860
+ }),
35861
+ defineTool({
35862
+ name: "list_git_repos",
35863
+ title: "List Git repositories",
35864
+ kind: "read",
35865
+ description: "Repositories the user's connected GitHub App installations (host github, default) or GitLab account (host gitlab) can reach: fullName, url, private, defaultBranch, newest first. Pass one url to create_service as repo. Not connected: 409 with details.connectUrl for the user.",
35866
+ input: {
35867
+ host: exports_external.enum(["github", "gitlab"]).optional().describe("github (default) or gitlab"),
35868
+ installationId: exports_external.number().int().positive().optional().describe("Only this GitHub installation (id from get_git_connections)"),
35869
+ search: exports_external.string().max(200).optional().describe("Filter by part of owner/name"),
35870
+ limit: exports_external.number().int().min(1).max(100).optional().describe("Default 50"),
35871
+ offset: exports_external.number().int().min(0).optional()
35872
+ },
35873
+ run: (a, api2) => api2.api("GET", `${C}/git/repos`, {
35874
+ query: {
35875
+ host: a.host,
35876
+ installationId: a.installationId !== undefined ? String(a.installationId) : undefined,
35877
+ search: a.search,
35878
+ limit: a.limit !== undefined ? String(a.limit) : undefined,
35879
+ offset: a.offset !== undefined ? String(a.offset) : undefined
35880
+ }
35881
+ })
35882
+ }),
35822
35883
  defineTool({
35823
35884
  name: "create_service",
35824
35885
  title: "Create service",
35825
35886
  kind: "write",
35826
- description: "Create a service from a container image, a public Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
35887
+ description: "Create a service from a container image, a Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. Private GitHub / GitLab repos work once connected (get_git_connections, list_git_repos); such services also redeploy on every push. A private repo that is not connected is refused (409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE, nothing created): give the user details.connectUrl and retry after they connect. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
35827
35888
  input: {
35828
35889
  environmentId: id("Environment"),
35829
35890
  name: exports_external.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,39}$/).describe("Service name (letters, digits, _ -; starts with a letter). Also used in references like ${{name.KEY}}"),
35830
35891
  image: exports_external.string().optional().describe("Container image, e.g. nginx:alpine or ghcr.io/acme/app:1.2"),
35831
35892
  registryUsername: exports_external.string().optional().describe("Private registry user (with registryPassword)"),
35832
35893
  registryPassword: exports_external.string().optional().describe("Private registry password or token"),
35833
- repo: exports_external.string().optional().describe("Public HTTPS Git repository URL (instead of image)"),
35894
+ repo: exports_external.string().optional().describe("Git repository: https URL or owner/name for GitHub (instead of image). Private repos need a connected GitHub / GitLab (list_git_repos)"),
35834
35895
  branch: exports_external.string().optional().describe("Git branch (default main)"),
35835
35896
  rootDirectory: exports_external.string().optional().describe("Monorepo: folder to build, e.g. apps/web"),
35836
35897
  dockerfile: exports_external.string().optional().describe("Inline Dockerfile content (instead of image/repo)"),
@@ -37960,12 +38021,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37960
38021
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37961
38022
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37962
38023
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
38024
+ 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)");
38025
+ var INLINE_MAX_BYTES = 1500000;
38026
+ var INLINE_TOTAL_BYTES = 4000000;
38027
+ var INLINE_MAX_IMAGES = 4;
38028
+ var POLL_MS = 3000;
38029
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38030
+ async function showImages(job, api2) {
38031
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
38032
+ return [];
38033
+ const out = [];
38034
+ let total = 0;
38035
+ for (const [i, img] of job.data.entries()) {
38036
+ const name = `${job.id}-${i + 1}`;
38037
+ 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;
38038
+ if (inline) {
38039
+ total += Math.ceil(inline.data.length * 3 / 4);
38040
+ out.push({ type: "image", ...inline });
38041
+ }
38042
+ 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"})` });
38043
+ }
38044
+ return out;
38045
+ }
37963
38046
  var aiTools = [
37964
38047
  defineTool({
37965
38048
  name: "list_ai_models",
37966
38049
  title: "List AI models and prices",
37967
38050
  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.",
38051
+ 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
38052
  input: {},
37970
38053
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37971
38054
  }),
@@ -38009,6 +38092,60 @@ var aiTools = [
38009
38092
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
38010
38093
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
38011
38094
  }),
38095
+ defineTool({
38096
+ name: "generate_ai_image",
38097
+ title: "Generate images",
38098
+ kind: "write",
38099
+ 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.",
38100
+ input: {
38101
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
38102
+ prompt: exports_external.string().min(1).max(4000),
38103
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
38104
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
38105
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
38106
+ projectId: imageProject,
38107
+ dryRun: dryRun5,
38108
+ 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)"),
38109
+ 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")
38110
+ },
38111
+ run: async (a, api2) => {
38112
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
38113
+ if (a.dryRun)
38114
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
38115
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
38116
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
38117
+ let job = { ...started };
38118
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
38119
+ await sleep(POLL_MS);
38120
+ job = await api2.api("GET", `/ai/images/${started.id}`);
38121
+ }
38122
+ if (job.status === "processing")
38123
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
38124
+ return job;
38125
+ },
38126
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
38127
+ }),
38128
+ defineTool({
38129
+ name: "get_ai_image",
38130
+ title: "Get an image request",
38131
+ kind: "read",
38132
+ 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.",
38133
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
38134
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
38135
+ present: (data, api2) => showImages(data, api2)
38136
+ }),
38137
+ defineTool({
38138
+ name: "list_ai_images",
38139
+ title: "List generated images",
38140
+ kind: "read",
38141
+ 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).",
38142
+ input: {
38143
+ projectId: imageProject,
38144
+ limit: exports_external.number().int().min(1).max(50).optional(),
38145
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
38146
+ },
38147
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
38148
+ }),
38012
38149
  defineTool({
38013
38150
  name: "topup_ai_balance",
38014
38151
  title: "Top up AI balance",
@@ -38076,18 +38213,20 @@ var generalTools = [
38076
38213
  ];
38077
38214
 
38078
38215
  // src/server.ts
38079
- var VERSION = "0.5.7";
38216
+ var VERSION = "0.5.9";
38080
38217
  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.
38218
+ - 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.
38219
+ - 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
38220
  - 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
38221
  - 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
38222
  - 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
38223
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
38087
38224
  - 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.
38225
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
38226
+ - Deploying from Git: public repos need nothing. For a private repo check get_git_connections / list_git_repos first; when GitHub or GitLab is not connected (or a call returns 409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE), give the user details.connectUrl to open in their browser and try again after they confirm. Never ask for a GitHub token.
38088
38227
  - 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
38228
  - 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
- - 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.
38229
+ - Errors come back as { status, code, error, details? }: 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.
38091
38230
  Docs: https://docs.helipod.io`;
38092
38231
  var ALL_TOOLS = [...containerTools, ...databaseTools, ...vpsTools, ...domainTools, ...emailTools, ...accountTools, ...aiTools, ...generalTools];
38093
38232
  function createHelipodServer(config2) {
package/dist/index.js CHANGED
@@ -34727,10 +34727,12 @@ var EMPTY_COMPLETION_RESULT = {
34727
34727
  class HelipodError extends Error {
34728
34728
  status;
34729
34729
  code;
34730
- constructor(status, code, message) {
34730
+ details;
34731
+ constructor(status, code, message, details) {
34731
34732
  super(message);
34732
34733
  this.status = status;
34733
34734
  this.code = code;
34735
+ this.details = details;
34734
34736
  this.name = "HelipodError";
34735
34737
  }
34736
34738
  }
@@ -34766,6 +34768,33 @@ class HelipodClient {
34766
34768
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
34767
34769
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
34768
34770
  }
34771
+ async image(link, maxBytes) {
34772
+ let url2;
34773
+ try {
34774
+ url2 = new URL(link);
34775
+ } catch {
34776
+ return null;
34777
+ }
34778
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
34779
+ return null;
34780
+ try {
34781
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
34782
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
34783
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
34784
+ return null;
34785
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
34786
+ return null;
34787
+ const buf = new Uint8Array(await res.arrayBuffer());
34788
+ if (buf.byteLength > maxBytes)
34789
+ return null;
34790
+ let bin = "";
34791
+ for (let i = 0;i < buf.length; i += 32768)
34792
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
34793
+ return { data: btoa(bin), mimeType };
34794
+ } catch {
34795
+ return null;
34796
+ }
34797
+ }
34769
34798
  async request(base, key, method, path, opts) {
34770
34799
  const raw = path.startsWith("/") ? path : `/${path}`;
34771
34800
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -34799,7 +34828,8 @@ class HelipodClient {
34799
34828
  if (!res.ok || json2?.success === false) {
34800
34829
  const retry = res.headers.get("retry-after");
34801
34830
  const msg = json2?.error ?? (text.slice(0, 300) || res.statusText);
34802
- throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg));
34831
+ const details = json2?.details && typeof json2.details === "object" && !Array.isArray(json2.details) ? json2.details : undefined;
34832
+ throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg), details);
34803
34833
  }
34804
34834
  return json2 && "data" in json2 ? json2.data : json2;
34805
34835
  }
@@ -34825,12 +34855,13 @@ function register(server, api2, defs) {
34825
34855
  }, async (args) => {
34826
34856
  try {
34827
34857
  const data = await d.run(args, api2);
34828
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
34858
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
34859
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
34829
34860
  } catch (e) {
34830
34861
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
34831
34862
  return {
34832
34863
  isError: true,
34833
- content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message }, null, 2) }]
34864
+ content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message, ...err.details ? { details: err.details } : {} }, null, 2) }]
34834
34865
  };
34835
34866
  }
34836
34867
  });
@@ -34964,18 +34995,48 @@ var containerTools = [
34964
34995
  input: { serviceId: id("Service") },
34965
34996
  run: (a, api2) => api2.api("GET", `${C}/services/${a.serviceId}`)
34966
34997
  }),
34998
+ defineTool({
34999
+ name: "get_git_connections",
35000
+ title: "Git connections",
35001
+ kind: "read",
35002
+ description: "Whether the user connected GitHub (sign-in + the Helipod GitHub App, with the accounts it is installed on) and GitLab, plus connectUrl: the Helipod page where the user connects them. Check this before deploying from a private repo. Connecting needs the user in a browser: give them connectUrl, never try to connect yourself.",
35003
+ input: {},
35004
+ run: (_a3, api2) => api2.api("GET", `${C}/git/connections`)
35005
+ }),
35006
+ defineTool({
35007
+ name: "list_git_repos",
35008
+ title: "List Git repositories",
35009
+ kind: "read",
35010
+ description: "Repositories the user's connected GitHub App installations (host github, default) or GitLab account (host gitlab) can reach: fullName, url, private, defaultBranch, newest first. Pass one url to create_service as repo. Not connected: 409 with details.connectUrl for the user.",
35011
+ input: {
35012
+ host: exports_external.enum(["github", "gitlab"]).optional().describe("github (default) or gitlab"),
35013
+ installationId: exports_external.number().int().positive().optional().describe("Only this GitHub installation (id from get_git_connections)"),
35014
+ search: exports_external.string().max(200).optional().describe("Filter by part of owner/name"),
35015
+ limit: exports_external.number().int().min(1).max(100).optional().describe("Default 50"),
35016
+ offset: exports_external.number().int().min(0).optional()
35017
+ },
35018
+ run: (a, api2) => api2.api("GET", `${C}/git/repos`, {
35019
+ query: {
35020
+ host: a.host,
35021
+ installationId: a.installationId !== undefined ? String(a.installationId) : undefined,
35022
+ search: a.search,
35023
+ limit: a.limit !== undefined ? String(a.limit) : undefined,
35024
+ offset: a.offset !== undefined ? String(a.offset) : undefined
35025
+ }
35026
+ })
35027
+ }),
34967
35028
  defineTool({
34968
35029
  name: "create_service",
34969
35030
  title: "Create service",
34970
35031
  kind: "write",
34971
- description: "Create a service from a container image, a public Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
35032
+ description: "Create a service from a container image, a Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. Private GitHub / GitLab repos work once connected (get_git_connections, list_git_repos); such services also redeploy on every push. A private repo that is not connected is refused (409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE, nothing created): give the user details.connectUrl and retry after they connect. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
34972
35033
  input: {
34973
35034
  environmentId: id("Environment"),
34974
35035
  name: exports_external.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,39}$/).describe("Service name (letters, digits, _ -; starts with a letter). Also used in references like ${{name.KEY}}"),
34975
35036
  image: exports_external.string().optional().describe("Container image, e.g. nginx:alpine or ghcr.io/acme/app:1.2"),
34976
35037
  registryUsername: exports_external.string().optional().describe("Private registry user (with registryPassword)"),
34977
35038
  registryPassword: exports_external.string().optional().describe("Private registry password or token"),
34978
- repo: exports_external.string().optional().describe("Public HTTPS Git repository URL (instead of image)"),
35039
+ repo: exports_external.string().optional().describe("Git repository: https URL or owner/name for GitHub (instead of image). Private repos need a connected GitHub / GitLab (list_git_repos)"),
34979
35040
  branch: exports_external.string().optional().describe("Git branch (default main)"),
34980
35041
  rootDirectory: exports_external.string().optional().describe("Monorepo: folder to build, e.g. apps/web"),
34981
35042
  dockerfile: exports_external.string().optional().describe("Inline Dockerfile content (instead of image/repo)"),
@@ -37105,12 +37166,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37105
37166
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37106
37167
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37107
37168
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
37169
+ 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)");
37170
+ var INLINE_MAX_BYTES = 1500000;
37171
+ var INLINE_TOTAL_BYTES = 4000000;
37172
+ var INLINE_MAX_IMAGES = 4;
37173
+ var POLL_MS = 3000;
37174
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37175
+ async function showImages(job, api2) {
37176
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
37177
+ return [];
37178
+ const out = [];
37179
+ let total = 0;
37180
+ for (const [i, img] of job.data.entries()) {
37181
+ const name = `${job.id}-${i + 1}`;
37182
+ 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;
37183
+ if (inline) {
37184
+ total += Math.ceil(inline.data.length * 3 / 4);
37185
+ out.push({ type: "image", ...inline });
37186
+ }
37187
+ 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"})` });
37188
+ }
37189
+ return out;
37190
+ }
37108
37191
  var aiTools = [
37109
37192
  defineTool({
37110
37193
  name: "list_ai_models",
37111
37194
  title: "List AI models and prices",
37112
37195
  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.",
37196
+ 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
37197
  input: {},
37115
37198
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37116
37199
  }),
@@ -37154,6 +37237,60 @@ var aiTools = [
37154
37237
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
37155
37238
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
37156
37239
  }),
37240
+ defineTool({
37241
+ name: "generate_ai_image",
37242
+ title: "Generate images",
37243
+ kind: "write",
37244
+ 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.",
37245
+ input: {
37246
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
37247
+ prompt: exports_external.string().min(1).max(4000),
37248
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
37249
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
37250
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
37251
+ projectId: imageProject,
37252
+ dryRun: dryRun5,
37253
+ 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)"),
37254
+ 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")
37255
+ },
37256
+ run: async (a, api2) => {
37257
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
37258
+ if (a.dryRun)
37259
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
37260
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
37261
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
37262
+ let job = { ...started };
37263
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
37264
+ await sleep(POLL_MS);
37265
+ job = await api2.api("GET", `/ai/images/${started.id}`);
37266
+ }
37267
+ if (job.status === "processing")
37268
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
37269
+ return job;
37270
+ },
37271
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
37272
+ }),
37273
+ defineTool({
37274
+ name: "get_ai_image",
37275
+ title: "Get an image request",
37276
+ kind: "read",
37277
+ 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.",
37278
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
37279
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
37280
+ present: (data, api2) => showImages(data, api2)
37281
+ }),
37282
+ defineTool({
37283
+ name: "list_ai_images",
37284
+ title: "List generated images",
37285
+ kind: "read",
37286
+ 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).",
37287
+ input: {
37288
+ projectId: imageProject,
37289
+ limit: exports_external.number().int().min(1).max(50).optional(),
37290
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
37291
+ },
37292
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
37293
+ }),
37157
37294
  defineTool({
37158
37295
  name: "topup_ai_balance",
37159
37296
  title: "Top up AI balance",
@@ -37221,18 +37358,20 @@ var generalTools = [
37221
37358
  ];
37222
37359
 
37223
37360
  // src/server.ts
37224
- var VERSION = "0.5.7";
37361
+ var VERSION = "0.5.9";
37225
37362
  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.
37363
+ - 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.
37364
+ - 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
37365
  - 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
37366
  - 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
37367
  - 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
37368
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37232
37369
  - 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.
37370
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
37371
+ - Deploying from Git: public repos need nothing. For a private repo check get_git_connections / list_git_repos first; when GitHub or GitLab is not connected (or a call returns 409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE), give the user details.connectUrl to open in their browser and try again after they confirm. Never ask for a GitHub token.
37233
37372
  - 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
37373
  - 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
- - 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.
37374
+ - Errors come back as { status, code, error, details? }: 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.
37236
37375
  Docs: https://docs.helipod.io`;
37237
37376
  var ALL_TOOLS = [...containerTools, ...databaseTools, ...vpsTools, ...domainTools, ...emailTools, ...accountTools, ...aiTools, ...generalTools];
37238
37377
  function createHelipodServer(config2) {
package/dist/stdio.js CHANGED
@@ -34834,10 +34834,12 @@ var EMPTY_COMPLETION_RESULT = {
34834
34834
  class HelipodError extends Error {
34835
34835
  status;
34836
34836
  code;
34837
- constructor(status, code, message) {
34837
+ details;
34838
+ constructor(status, code, message, details) {
34838
34839
  super(message);
34839
34840
  this.status = status;
34840
34841
  this.code = code;
34842
+ this.details = details;
34841
34843
  this.name = "HelipodError";
34842
34844
  }
34843
34845
  }
@@ -34873,6 +34875,33 @@ class HelipodClient {
34873
34875
  const headers = !this.cfg.emailApiKey && opts.project ? { "x-helipod-project": opts.project } : undefined;
34874
34876
  return this.request(this.emailApiUrl, key, method, path, { ...opts, headers });
34875
34877
  }
34878
+ async image(link, maxBytes) {
34879
+ let url2;
34880
+ try {
34881
+ url2 = new URL(link);
34882
+ } catch {
34883
+ return null;
34884
+ }
34885
+ if (url2.protocol !== "https:" || !(url2.hostname === "helipod.io" || url2.hostname.endsWith(".helipod.io")))
34886
+ return null;
34887
+ try {
34888
+ const res = await this.f(url2, { headers: { "user-agent": this.cfg.userAgent ?? "helipod-mcp" }, signal: AbortSignal.timeout(20000) });
34889
+ const mimeType = (res.headers.get("content-type") ?? "").split(";")[0].trim();
34890
+ if (!res.ok || !/^image\/(png|jpeg|webp|gif)$/.test(mimeType))
34891
+ return null;
34892
+ if (Number(res.headers.get("content-length") ?? 0) > maxBytes)
34893
+ return null;
34894
+ const buf = new Uint8Array(await res.arrayBuffer());
34895
+ if (buf.byteLength > maxBytes)
34896
+ return null;
34897
+ let bin = "";
34898
+ for (let i = 0;i < buf.length; i += 32768)
34899
+ bin += String.fromCharCode(...buf.subarray(i, i + 32768));
34900
+ return { data: btoa(bin), mimeType };
34901
+ } catch {
34902
+ return null;
34903
+ }
34904
+ }
34876
34905
  async request(base, key, method, path, opts) {
34877
34906
  const raw = path.startsWith("/") ? path : `/${path}`;
34878
34907
  if (/(^|\/)\.\.?(\/|$)|[?#\\]|\/\/|%2e|%2f|%5c/i.test(raw)) {
@@ -34906,7 +34935,8 @@ class HelipodClient {
34906
34935
  if (!res.ok || json2?.success === false) {
34907
34936
  const retry = res.headers.get("retry-after");
34908
34937
  const msg = json2?.error ?? (text.slice(0, 300) || res.statusText);
34909
- throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg));
34938
+ const details = json2?.details && typeof json2.details === "object" && !Array.isArray(json2.details) ? json2.details : undefined;
34939
+ throw new HelipodError(res.status, json2?.code, retry ? `${msg} (retry after ${retry}s)` : String(msg), details);
34910
34940
  }
34911
34941
  return json2 && "data" in json2 ? json2.data : json2;
34912
34942
  }
@@ -34932,12 +34962,13 @@ function register(server, api2, defs) {
34932
34962
  }, async (args) => {
34933
34963
  try {
34934
34964
  const data = await d.run(args, api2);
34935
- return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }] };
34965
+ const extra = d.present ? await d.present(data, api2, args).catch(() => []) : [];
34966
+ return { content: [{ type: "text", text: data === undefined ? "OK" : JSON.stringify(data, null, 2) }, ...extra] };
34936
34967
  } catch (e) {
34937
34968
  const err = e instanceof HelipodError ? e : new HelipodError(0, "CLIENT_ERROR", String(e?.message ?? e));
34938
34969
  return {
34939
34970
  isError: true,
34940
- content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message }, null, 2) }]
34971
+ content: [{ type: "text", text: JSON.stringify({ status: err.status, code: err.code ?? null, error: err.message, ...err.details ? { details: err.details } : {} }, null, 2) }]
34941
34972
  };
34942
34973
  }
34943
34974
  });
@@ -35071,18 +35102,48 @@ var containerTools = [
35071
35102
  input: { serviceId: id("Service") },
35072
35103
  run: (a, api2) => api2.api("GET", `${C}/services/${a.serviceId}`)
35073
35104
  }),
35105
+ defineTool({
35106
+ name: "get_git_connections",
35107
+ title: "Git connections",
35108
+ kind: "read",
35109
+ description: "Whether the user connected GitHub (sign-in + the Helipod GitHub App, with the accounts it is installed on) and GitLab, plus connectUrl: the Helipod page where the user connects them. Check this before deploying from a private repo. Connecting needs the user in a browser: give them connectUrl, never try to connect yourself.",
35110
+ input: {},
35111
+ run: (_a3, api2) => api2.api("GET", `${C}/git/connections`)
35112
+ }),
35113
+ defineTool({
35114
+ name: "list_git_repos",
35115
+ title: "List Git repositories",
35116
+ kind: "read",
35117
+ description: "Repositories the user's connected GitHub App installations (host github, default) or GitLab account (host gitlab) can reach: fullName, url, private, defaultBranch, newest first. Pass one url to create_service as repo. Not connected: 409 with details.connectUrl for the user.",
35118
+ input: {
35119
+ host: exports_external.enum(["github", "gitlab"]).optional().describe("github (default) or gitlab"),
35120
+ installationId: exports_external.number().int().positive().optional().describe("Only this GitHub installation (id from get_git_connections)"),
35121
+ search: exports_external.string().max(200).optional().describe("Filter by part of owner/name"),
35122
+ limit: exports_external.number().int().min(1).max(100).optional().describe("Default 50"),
35123
+ offset: exports_external.number().int().min(0).optional()
35124
+ },
35125
+ run: (a, api2) => api2.api("GET", `${C}/git/repos`, {
35126
+ query: {
35127
+ host: a.host,
35128
+ installationId: a.installationId !== undefined ? String(a.installationId) : undefined,
35129
+ search: a.search,
35130
+ limit: a.limit !== undefined ? String(a.limit) : undefined,
35131
+ offset: a.offset !== undefined ? String(a.offset) : undefined
35132
+ }
35133
+ })
35134
+ }),
35074
35135
  defineTool({
35075
35136
  name: "create_service",
35076
35137
  title: "Create service",
35077
35138
  kind: "write",
35078
- description: "Create a service from a container image, a public Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
35139
+ description: "Create a service from a container image, a Git repository or an inline Dockerfile, and (unless deploy=false) start its first deploy. Private GitHub / GitLab repos work once connected (get_git_connections, list_git_repos); such services also redeploy on every push. A private repo that is not connected is refused (409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE, nothing created): give the user details.connectUrl and retry after they connect. COSTS MONEY once deployed (billed daily from the wallet): call with dryRun=true first, show the dailyCost to the user and get their OK.",
35079
35140
  input: {
35080
35141
  environmentId: id("Environment"),
35081
35142
  name: exports_external.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,39}$/).describe("Service name (letters, digits, _ -; starts with a letter). Also used in references like ${{name.KEY}}"),
35082
35143
  image: exports_external.string().optional().describe("Container image, e.g. nginx:alpine or ghcr.io/acme/app:1.2"),
35083
35144
  registryUsername: exports_external.string().optional().describe("Private registry user (with registryPassword)"),
35084
35145
  registryPassword: exports_external.string().optional().describe("Private registry password or token"),
35085
- repo: exports_external.string().optional().describe("Public HTTPS Git repository URL (instead of image)"),
35146
+ repo: exports_external.string().optional().describe("Git repository: https URL or owner/name for GitHub (instead of image). Private repos need a connected GitHub / GitLab (list_git_repos)"),
35086
35147
  branch: exports_external.string().optional().describe("Git branch (default main)"),
35087
35148
  rootDirectory: exports_external.string().optional().describe("Monorepo: folder to build, e.g. apps/web"),
35088
35149
  dockerfile: exports_external.string().optional().describe("Inline Dockerfile content (instead of image/repo)"),
@@ -37212,12 +37273,34 @@ var keyId = exports_external.string().uuid().optional().describe("Limit to one A
37212
37273
  var model = exports_external.string().max(100).optional().describe("Limit to one model id");
37213
37274
  var dryRun5 = exports_external.boolean().optional().describe("true = only check and show the result; nothing is charged or changed");
37214
37275
  var ACCOUNT_KEY2 = "Needs a whole-account key (a key limited to one project gets 403 ACCOUNT_KEY_REQUIRED).";
37276
+ 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)");
37277
+ var INLINE_MAX_BYTES = 1500000;
37278
+ var INLINE_TOTAL_BYTES = 4000000;
37279
+ var INLINE_MAX_IMAGES = 4;
37280
+ var POLL_MS = 3000;
37281
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37282
+ async function showImages(job, api2) {
37283
+ if (job?.status !== "succeeded" || !Array.isArray(job.data))
37284
+ return [];
37285
+ const out = [];
37286
+ let total = 0;
37287
+ for (const [i, img] of job.data.entries()) {
37288
+ const name = `${job.id}-${i + 1}`;
37289
+ 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;
37290
+ if (inline) {
37291
+ total += Math.ceil(inline.data.length * 3 / 4);
37292
+ out.push({ type: "image", ...inline });
37293
+ }
37294
+ 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"})` });
37295
+ }
37296
+ return out;
37297
+ }
37215
37298
  var aiTools = [
37216
37299
  defineTool({
37217
37300
  name: "list_ai_models",
37218
37301
  title: "List AI models and prices",
37219
37302
  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.",
37303
+ 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
37304
  input: {},
37222
37305
  run: (_a3, api2) => api2.api("GET", "/ai/models")
37223
37306
  }),
@@ -37261,6 +37344,60 @@ var aiTools = [
37261
37344
  input: { keyId: exports_external.string().uuid().describe("AI key id from list_ai_keys") },
37262
37345
  run: (a, api2) => api2.api("DELETE", `/ai/keys/${a.keyId}`)
37263
37346
  }),
37347
+ defineTool({
37348
+ name: "generate_ai_image",
37349
+ title: "Generate images",
37350
+ kind: "write",
37351
+ 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.",
37352
+ input: {
37353
+ model: exports_external.string().min(1).max(100).describe("Image model code from list_ai_models imageModels"),
37354
+ prompt: exports_external.string().min(1).max(4000),
37355
+ size: exports_external.string().max(20).optional().describe("One of the model's sizes, e.g. 1024x1024 (default: the model's default size)"),
37356
+ n: exports_external.number().int().min(1).max(10).optional().describe("Images in this request (up to the model's maxN; default 1)"),
37357
+ images: exports_external.array(exports_external.string().url().startsWith("https://")).max(10).optional().describe("Reference images as https:// links (only models with refImages > 0)"),
37358
+ projectId: imageProject,
37359
+ dryRun: dryRun5,
37360
+ 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)"),
37361
+ 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")
37362
+ },
37363
+ run: async (a, api2) => {
37364
+ const body = { model: a.model, prompt: a.prompt, size: a.size, n: a.n, images: a.images, projectId: a.projectId };
37365
+ if (a.dryRun)
37366
+ return api2.api("POST", "/ai/images", { body, query: { dryRun: "true" } });
37367
+ const started = await api2.api("POST", "/ai/images", { body, idempotencyKey: purchaseKey(false, a.idempotencyKey) });
37368
+ const deadline = Date.now() + (a.wait ?? 50) * 1000;
37369
+ let job = { ...started };
37370
+ while (job.status === "processing" && Date.now() + POLL_MS < deadline) {
37371
+ await sleep(POLL_MS);
37372
+ job = await api2.api("GET", `/ai/images/${started.id}`);
37373
+ }
37374
+ if (job.status === "processing")
37375
+ return { ...started, next: `Still generating: call get_ai_image with id ${started.id} in a few seconds.` };
37376
+ return job;
37377
+ },
37378
+ present: (data, api2, a) => a.dryRun ? Promise.resolve([]) : showImages(data, api2)
37379
+ }),
37380
+ defineTool({
37381
+ name: "get_ai_image",
37382
+ title: "Get an image request",
37383
+ kind: "read",
37384
+ 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.",
37385
+ input: { id: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).describe("Request id, req_…") },
37386
+ run: (a, api2) => api2.api("GET", `/ai/images/${a.id}`),
37387
+ present: (data, api2) => showImages(data, api2)
37388
+ }),
37389
+ defineTool({
37390
+ name: "list_ai_images",
37391
+ title: "List generated images",
37392
+ kind: "read",
37393
+ 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).",
37394
+ input: {
37395
+ projectId: imageProject,
37396
+ limit: exports_external.number().int().min(1).max(50).optional(),
37397
+ before: exports_external.string().regex(/^req_[0-9a-f-]{36}$/).optional().describe("last_id of the previous page")
37398
+ },
37399
+ run: (a, api2) => api2.api("GET", "/ai/images", { query: { projectId: a.projectId, limit: a.limit, before: a.before } })
37400
+ }),
37264
37401
  defineTool({
37265
37402
  name: "topup_ai_balance",
37266
37403
  title: "Top up AI balance",
@@ -37328,18 +37465,20 @@ var generalTools = [
37328
37465
  ];
37329
37466
 
37330
37467
  // src/server.ts
37331
- var VERSION = "0.5.7";
37468
+ var VERSION = "0.5.9";
37332
37469
  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.
37470
+ - 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.
37471
+ - 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
37472
  - 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
37473
  - 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
37474
  - 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
37475
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37339
37476
  - 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.
37477
+ - Images from generate_ai_image are deleted after 7 days: tell the user and have them download the ones they keep.
37478
+ - Deploying from Git: public repos need nothing. For a private repo check get_git_connections / list_git_repos first; when GitHub or GitLab is not connected (or a call returns 409 GITHUB_NOT_CONNECTED / GITLAB_NOT_CONNECTED / REPO_NOT_ACCESSIBLE), give the user details.connectUrl to open in their browser and try again after they confirm. Never ask for a GitHub token.
37340
37479
  - 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
37480
  - 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
- - 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.
37481
+ - Errors come back as { status, code, error, details? }: 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.
37343
37482
  Docs: https://docs.helipod.io`;
37344
37483
  var ALL_TOOLS = [...containerTools, ...databaseTools, ...vpsTools, ...domainTools, ...emailTools, ...accountTools, ...aiTools, ...generalTools];
37345
37484
  function createHelipodServer(config2) {
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.9",
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