helipod-mcp 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use [Helipod](https://helipod.io) from AI coding tools: deploy apps and databases, run VPS servers, buy domains and edit DNS, and send email, by asking in plain language.
4
4
 
5
- - **MCP server** (`helipod-mcp`): 219 tools over the Container, Database, VPS, Domain, Email, Account and AI APIs. Works in Claude Code, Codex, Cursor, VS Code, Windsurf, Claude Desktop and any MCP client.
5
+ - **MCP server** (`helipod-mcp`): 229 tools over the Container, Database, VPS, Domain, Email, Account and AI APIs. Works in Claude Code, Codex, Cursor, VS Code, Windsurf, Claude Desktop and any MCP client.
6
6
  - **Skills** (`skills/`): step-by-step playbooks so the agent deploys, prices and confirms the way Helipod expects (price first, confirm before deleting, read build logs on failure).
7
7
 
8
8
  Step-by-step guides for each tool (Claude Code, Codex, Cursor, VS Code, Gemini CLI, opencode, Windsurf, Claude Desktop, Cline and others): **https://docs.helipod.io/docs/helipod/ai-agents**
package/dist/http.js CHANGED
@@ -36219,6 +36219,7 @@ var password = exports_external.string().min(12).max(30).describe("12-30 charact
36219
36219
  var period = exports_external.enum(["monthly", "3month", "6month", "yearly"]);
36220
36220
  var pointId = exports_external.string().describe("Restore point id (from list_vps_restore_points)");
36221
36221
  var backupPath = exports_external.string().max(1024).describe("Absolute path inside the backup, e.g. /etc/nginx or /var/www/index.html");
36222
+ var migrationId = exports_external.string().describe("Migration id (from list_vps_migrations)");
36222
36223
  var vpsTools = [
36223
36224
  defineTool({
36224
36225
  name: "list_vps_plans",
@@ -36598,6 +36599,84 @@ var vpsTools = [
36598
36599
  description: "Status of a prepared backup download; when ready, a short-lived url for the user to open (it expires at expiresAt). The link is for the user to download in a browser.",
36599
36600
  input: { vpsId, downloadId: exports_external.string() },
36600
36601
  run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/backup/downloads/${a.downloadId}`)
36602
+ }),
36603
+ defineTool({
36604
+ name: "list_vps_migrations",
36605
+ title: "List VPS migrations",
36606
+ kind: "read",
36607
+ description: "Migrations onto this VPS, newest first: imports from servers outside Helipod (kind=import) and copies from another VPS's restore point (kind=copy). Each has status (waiting, uploading, inspecting, uploaded, awaiting_command, restoring, restored, failed, expired, cancelled), the uploaded server's OS and size, who ran the command (fetchedIp), progress and pause state of a running job, and the result.",
36608
+ input: { vpsId },
36609
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations`)
36610
+ }),
36611
+ defineTool({
36612
+ name: "get_vps_migration",
36613
+ title: "Get a VPS migration",
36614
+ kind: "read",
36615
+ description: "One migration onto this VPS (see list_vps_migrations). While an upload or restore runs, result.progress has percent (of data read), bytesDone/bytesTotal, sentBytes and sendRate; result.stored the bytes confirmed in storage; result.control the pause state (want vs the job's reported state). Poll it every 15-30 s while it runs.",
36616
+ input: { vpsId, migrationId },
36617
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
36618
+ }),
36619
+ defineTool({
36620
+ name: "create_vps_import",
36621
+ title: "Start a server import (Migrate to Helipod)",
36622
+ kind: "write",
36623
+ description: "Start moving a server from OUTSIDE Helipod (any Linux server with root) onto this running VPS. Free; one import per VPS, at most 3 per account, kept 7 days, the upload is capped at this VPS's disk. Returns a one-time command (curl ... | sudo bash) that works ONCE and expires in 1 hour: give it to the user to run as root on the OLD server; never run it yourself and do not store it. Pass sourceIp (the old server's public IP, e.g. from curl -4 ifconfig.me there) so the command only works from that server; recommended. Then follow it with get_vps_migration: waiting → uploading → inspecting → uploaded.",
36624
+ input: { vpsId, sourceIp: exports_external.string().max(64).optional().describe("The old server's public IP: the command then only works from it (recommended)") },
36625
+ run: ({ vpsId: id2, sourceIp }, api2) => api2.api("POST", `/vps/${id2}/migrations/import`, { body: { sourceIp: sourceIp ?? null } })
36626
+ }),
36627
+ defineTool({
36628
+ name: "new_vps_migration_command",
36629
+ title: "Get a new migration command",
36630
+ kind: "write",
36631
+ description: "A fresh one-time command for an import (status waiting, uploading or failed before an upload finished) or for a restore that waits for its command (awaiting_command). The previous command stops working, and a job still running with it is told to stop. Shown once: give it to the user to run as root on the right server.",
36632
+ input: { vpsId, migrationId },
36633
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/command`)
36634
+ }),
36635
+ defineTool({
36636
+ name: "restore_vps_import",
36637
+ title: "Restore an import onto the VPS",
36638
+ kind: "destructive",
36639
+ description: "Restore an uploaded import (status uploaded, restored, or failed after an upload) onto its VPS. mode=files restores 1-50 paths (e.g. /home, /var/www, /etc/nginx, /var/backups/helipod-db for database dumps): target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the whole VPS into a copy of the old server: EVERYTHING on the VPS is replaced, only allowed when the VPS runs the same OS as the upload (sourceOs) and is of the full-VM kind. The first restore needs confirmSource=true: before setting it, show the user fetchedIp (and the uploaded server's hostname) from get_vps_migration and ask whether that is their old server; if not, delete the import instead. Runs in the background: follow it with get_vps_migration.",
36640
+ input: {
36641
+ vpsId,
36642
+ migrationId,
36643
+ mode: exports_external.enum(["files", "full"]),
36644
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to restore"),
36645
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)"),
36646
+ confirmSource: exports_external.boolean().optional().describe("true only after the user confirmed fetchedIp is their old server (needed once)")
36647
+ },
36648
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/restore`, { body: { mode: a.mode, paths: a.paths, target: a.target, confirmSource: a.confirmSource } })
36649
+ }),
36650
+ defineTool({
36651
+ name: "control_vps_migration",
36652
+ title: "Pause, resume or stop a migration job",
36653
+ kind: "write",
36654
+ description: "Pause, resume or stop an upload or restore that runs from a one-time command on a server (it must have checked in: result.control is set). The job follows within about 5 seconds; result.control.state shows when it did. Stop is final: the job ends as failed (stopped); a new command (new_vps_migration_command) starts the upload again and reuses what was already uploaded where possible. The user can do the same on that server with sudo helipod-migrate pause | resume | stop.",
36655
+ input: { vpsId, migrationId, action: exports_external.enum(["pause", "resume", "stop"]) },
36656
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/control`, { body: { action: a.action } })
36657
+ }),
36658
+ defineTool({
36659
+ name: "delete_vps_migration",
36660
+ title: "Delete a VPS migration",
36661
+ kind: "destructive",
36662
+ description: "Delete a migration; an import's uploaded files are deleted from storage and cannot be restored any more. A running upload is told to stop. Not while a restore or the upload check runs.",
36663
+ input: { vpsId, migrationId },
36664
+ run: (a, api2) => api2.api("DELETE", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
36665
+ }),
36666
+ defineTool({
36667
+ name: "copy_vps_backup",
36668
+ title: "Copy a restore point to another VPS",
36669
+ kind: "destructive",
36670
+ description: "Copy a restore point of one VPS (vpsId, pointId from list_vps_restore_points) onto ANOTHER VPS of the same account (targetVpsId; to bring the same VPS back use restore_vps_backup). Free. mode=files copies 1-50 paths onto any running VPS: target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the target into a full copy: everything on it is replaced; only between two VPS of the same kind running the same OS and version. Runs in the background: follow it with list_vps_migrations on the TARGET VPS. If the result has a command, the target's management agent was unreachable: give the user that one-time command to run as root on the target.",
36671
+ input: {
36672
+ vpsId,
36673
+ pointId,
36674
+ targetVpsId: exports_external.string().describe("The VPS to copy onto (id from list_vps), not vpsId"),
36675
+ mode: exports_external.enum(["files", "full"]),
36676
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to copy"),
36677
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)")
36678
+ },
36679
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/backup/points/${a.pointId}/copy`, { body: { targetVpsId: a.targetVpsId, mode: a.mode, paths: a.paths, target: a.target } })
36601
36680
  })
36602
36681
  ];
36603
36682
 
@@ -36607,9 +36686,9 @@ var hostId = exports_external.number().int().positive().describe("Child nameserv
36607
36686
  var dryRun2 = exports_external.boolean().optional().describe("true = return the exact price (IDR, VAT included) and the wallet balance after it; nothing is charged or registered");
36608
36687
  var DRY = "dryRun=true returns the price and walletBalanceAfter without charging or changing anything.";
36609
36688
  var record3 = exports_external.object({
36610
- name: exports_external.string().describe('"@" for the domain itself, or a label like "www"'),
36689
+ name: exports_external.string().describe('"@" for the domain itself, or the part before the domain, e.g. "www" or "_dmarc.mail". A full name like "www.example.com" is accepted and stored as "www"'),
36611
36690
  type: exports_external.enum(["A", "AAAA", "MX", "MXE", "CNAME", "SPF", "TXT"]),
36612
- content: exports_external.string(),
36691
+ content: exports_external.string().describe('TXT without surrounding quotes (they are added by the DNS server); MX as "10 mail.example.com"'),
36613
36692
  ttl: exports_external.number().int().optional()
36614
36693
  });
36615
36694
  var domainTools = [
@@ -36710,7 +36789,7 @@ var domainTools = [
36710
36789
  name: "list_dns_records",
36711
36790
  title: "List DNS records",
36712
36791
  kind: "read",
36713
- description: "DNS records of a registered domain.",
36792
+ description: 'DNS records of a registered domain. prefix is the name relative to the domain ("@" for the domain itself); TXT content is shown with the quotes the DNS server adds. A domain with no records is not served at all (its nameservers answer REFUSED), so add at least one record for it to resolve.',
36714
36793
  input: { domainId },
36715
36794
  run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/dns`)
36716
36795
  }),
@@ -36718,7 +36797,7 @@ var domainTools = [
36718
36797
  name: "set_dns_records",
36719
36798
  title: "Replace DNS records",
36720
36799
  kind: "destructive",
36721
- description: "Replace ALL DNS records of the domain with this list. Records not included are deleted (list_dns_records returns the current ones).",
36800
+ description: "Replace ALL DNS records of the domain with this list. Records not included are deleted: read list_dns_records first and send every record that should stay (prefix as name, content as listed; quotes around TXT are removed for you).",
36722
36801
  input: { domainId, records: exports_external.array(record3).min(1) },
36723
36802
  run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/dns`, { body: { records: a.records } })
36724
36803
  }),
@@ -36772,6 +36851,22 @@ var domainTools = [
36772
36851
  input: { domainId, ...record3.shape },
36773
36852
  run: (a, api2) => api2.api("PUT", `/domains/${a.domainId}/dns`, { body: { name: a.name, type: a.type, content: a.content, ttl: a.ttl } })
36774
36853
  }),
36854
+ defineTool({
36855
+ name: "list_domain_email_records",
36856
+ title: "Email records for a domain",
36857
+ kind: "read",
36858
+ description: "Helipod Email sending domains inside this registered domain (e.g. mail.example.com), each DNS record they need and whether the domain has it: present, missing, or conflict (with what to change).",
36859
+ input: { domainId },
36860
+ run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/email-records`)
36861
+ }),
36862
+ defineTool({
36863
+ name: "add_domain_email_records",
36864
+ title: "Add email records to a domain",
36865
+ kind: "write",
36866
+ description: "Add the DNS records a Helipod Email sending domain still needs to this registered domain (Helipod DNS only), then start its verification. Records that would clash (a CNAME on a name in use, a second SPF) are not changed and are listed in conflicts. emailDomainId comes from list_domain_email_records.",
36867
+ input: { domainId, emailDomainId: exports_external.string().uuid() },
36868
+ run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/email-records`, { body: { emailDomainId: a.emailDomainId } })
36869
+ }),
36775
36870
  defineTool({
36776
36871
  name: "list_child_nameservers",
36777
36872
  title: "List child nameservers",
@@ -36997,7 +37092,7 @@ var emailTools = [
36997
37092
  name: "verify_email_domain",
36998
37093
  title: "Verify sending domain",
36999
37094
  kind: "write",
37000
- description: "Check a sending domain's DNS records again.",
37095
+ description: "Check a sending domain's DNS records again now. Pending domains are also re-checked automatically every 30 minutes for 3 days.",
37001
37096
  input: { projectId, domainId: exports_external.string() },
37002
37097
  run: (a, api2) => api2.email("POST", `/domains/${a.domainId}/verify`, { project: a.projectId })
37003
37098
  }),
@@ -37045,7 +37140,7 @@ var emailTools = [
37045
37140
  name: "get_email_domain",
37046
37141
  title: "Get sending domain",
37047
37142
  kind: "read",
37048
- description: "One sending domain: verification status and the DNS records to add.",
37143
+ description: "One sending domain: verification status and the DNS records to add. ownershipVerified is true once the ownership TXT is confirmed; status turns verified only when DKIM passes too. When the domain is registered at Helipod, add_domain_email_records adds the records in one call.",
37049
37144
  input: { projectId, domainId: exports_external.string() },
37050
37145
  run: (a, api2) => api2.email("GET", `/domains/${a.domainId}`, { project: a.projectId })
37051
37146
  }),
@@ -37981,14 +38076,15 @@ var generalTools = [
37981
38076
  ];
37982
38077
 
37983
38078
  // src/server.ts
37984
- var VERSION = "0.5.4";
38079
+ var VERSION = "0.5.7";
37985
38080
  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.
37986
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.
37987
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.
37988
- - Destructive tools (delete, terminate, reinstall, rollback, restore a backup, 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.
38083
+ - 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.
37989
38084
  - 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.
37990
38085
  - 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.
37991
38086
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
38087
+ - 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.
37992
38088
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
37993
38089
  - 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.
37994
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.
package/dist/index.js CHANGED
@@ -35364,6 +35364,7 @@ var password = exports_external.string().min(12).max(30).describe("12-30 charact
35364
35364
  var period = exports_external.enum(["monthly", "3month", "6month", "yearly"]);
35365
35365
  var pointId = exports_external.string().describe("Restore point id (from list_vps_restore_points)");
35366
35366
  var backupPath = exports_external.string().max(1024).describe("Absolute path inside the backup, e.g. /etc/nginx or /var/www/index.html");
35367
+ var migrationId = exports_external.string().describe("Migration id (from list_vps_migrations)");
35367
35368
  var vpsTools = [
35368
35369
  defineTool({
35369
35370
  name: "list_vps_plans",
@@ -35743,6 +35744,84 @@ var vpsTools = [
35743
35744
  description: "Status of a prepared backup download; when ready, a short-lived url for the user to open (it expires at expiresAt). The link is for the user to download in a browser.",
35744
35745
  input: { vpsId, downloadId: exports_external.string() },
35745
35746
  run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/backup/downloads/${a.downloadId}`)
35747
+ }),
35748
+ defineTool({
35749
+ name: "list_vps_migrations",
35750
+ title: "List VPS migrations",
35751
+ kind: "read",
35752
+ description: "Migrations onto this VPS, newest first: imports from servers outside Helipod (kind=import) and copies from another VPS's restore point (kind=copy). Each has status (waiting, uploading, inspecting, uploaded, awaiting_command, restoring, restored, failed, expired, cancelled), the uploaded server's OS and size, who ran the command (fetchedIp), progress and pause state of a running job, and the result.",
35753
+ input: { vpsId },
35754
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations`)
35755
+ }),
35756
+ defineTool({
35757
+ name: "get_vps_migration",
35758
+ title: "Get a VPS migration",
35759
+ kind: "read",
35760
+ description: "One migration onto this VPS (see list_vps_migrations). While an upload or restore runs, result.progress has percent (of data read), bytesDone/bytesTotal, sentBytes and sendRate; result.stored the bytes confirmed in storage; result.control the pause state (want vs the job's reported state). Poll it every 15-30 s while it runs.",
35761
+ input: { vpsId, migrationId },
35762
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
35763
+ }),
35764
+ defineTool({
35765
+ name: "create_vps_import",
35766
+ title: "Start a server import (Migrate to Helipod)",
35767
+ kind: "write",
35768
+ description: "Start moving a server from OUTSIDE Helipod (any Linux server with root) onto this running VPS. Free; one import per VPS, at most 3 per account, kept 7 days, the upload is capped at this VPS's disk. Returns a one-time command (curl ... | sudo bash) that works ONCE and expires in 1 hour: give it to the user to run as root on the OLD server; never run it yourself and do not store it. Pass sourceIp (the old server's public IP, e.g. from curl -4 ifconfig.me there) so the command only works from that server; recommended. Then follow it with get_vps_migration: waiting → uploading → inspecting → uploaded.",
35769
+ input: { vpsId, sourceIp: exports_external.string().max(64).optional().describe("The old server's public IP: the command then only works from it (recommended)") },
35770
+ run: ({ vpsId: id2, sourceIp }, api2) => api2.api("POST", `/vps/${id2}/migrations/import`, { body: { sourceIp: sourceIp ?? null } })
35771
+ }),
35772
+ defineTool({
35773
+ name: "new_vps_migration_command",
35774
+ title: "Get a new migration command",
35775
+ kind: "write",
35776
+ description: "A fresh one-time command for an import (status waiting, uploading or failed before an upload finished) or for a restore that waits for its command (awaiting_command). The previous command stops working, and a job still running with it is told to stop. Shown once: give it to the user to run as root on the right server.",
35777
+ input: { vpsId, migrationId },
35778
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/command`)
35779
+ }),
35780
+ defineTool({
35781
+ name: "restore_vps_import",
35782
+ title: "Restore an import onto the VPS",
35783
+ kind: "destructive",
35784
+ description: "Restore an uploaded import (status uploaded, restored, or failed after an upload) onto its VPS. mode=files restores 1-50 paths (e.g. /home, /var/www, /etc/nginx, /var/backups/helipod-db for database dumps): target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the whole VPS into a copy of the old server: EVERYTHING on the VPS is replaced, only allowed when the VPS runs the same OS as the upload (sourceOs) and is of the full-VM kind. The first restore needs confirmSource=true: before setting it, show the user fetchedIp (and the uploaded server's hostname) from get_vps_migration and ask whether that is their old server; if not, delete the import instead. Runs in the background: follow it with get_vps_migration.",
35785
+ input: {
35786
+ vpsId,
35787
+ migrationId,
35788
+ mode: exports_external.enum(["files", "full"]),
35789
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to restore"),
35790
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)"),
35791
+ confirmSource: exports_external.boolean().optional().describe("true only after the user confirmed fetchedIp is their old server (needed once)")
35792
+ },
35793
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/restore`, { body: { mode: a.mode, paths: a.paths, target: a.target, confirmSource: a.confirmSource } })
35794
+ }),
35795
+ defineTool({
35796
+ name: "control_vps_migration",
35797
+ title: "Pause, resume or stop a migration job",
35798
+ kind: "write",
35799
+ description: "Pause, resume or stop an upload or restore that runs from a one-time command on a server (it must have checked in: result.control is set). The job follows within about 5 seconds; result.control.state shows when it did. Stop is final: the job ends as failed (stopped); a new command (new_vps_migration_command) starts the upload again and reuses what was already uploaded where possible. The user can do the same on that server with sudo helipod-migrate pause | resume | stop.",
35800
+ input: { vpsId, migrationId, action: exports_external.enum(["pause", "resume", "stop"]) },
35801
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/control`, { body: { action: a.action } })
35802
+ }),
35803
+ defineTool({
35804
+ name: "delete_vps_migration",
35805
+ title: "Delete a VPS migration",
35806
+ kind: "destructive",
35807
+ description: "Delete a migration; an import's uploaded files are deleted from storage and cannot be restored any more. A running upload is told to stop. Not while a restore or the upload check runs.",
35808
+ input: { vpsId, migrationId },
35809
+ run: (a, api2) => api2.api("DELETE", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
35810
+ }),
35811
+ defineTool({
35812
+ name: "copy_vps_backup",
35813
+ title: "Copy a restore point to another VPS",
35814
+ kind: "destructive",
35815
+ description: "Copy a restore point of one VPS (vpsId, pointId from list_vps_restore_points) onto ANOTHER VPS of the same account (targetVpsId; to bring the same VPS back use restore_vps_backup). Free. mode=files copies 1-50 paths onto any running VPS: target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the target into a full copy: everything on it is replaced; only between two VPS of the same kind running the same OS and version. Runs in the background: follow it with list_vps_migrations on the TARGET VPS. If the result has a command, the target's management agent was unreachable: give the user that one-time command to run as root on the target.",
35816
+ input: {
35817
+ vpsId,
35818
+ pointId,
35819
+ targetVpsId: exports_external.string().describe("The VPS to copy onto (id from list_vps), not vpsId"),
35820
+ mode: exports_external.enum(["files", "full"]),
35821
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to copy"),
35822
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)")
35823
+ },
35824
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/backup/points/${a.pointId}/copy`, { body: { targetVpsId: a.targetVpsId, mode: a.mode, paths: a.paths, target: a.target } })
35746
35825
  })
35747
35826
  ];
35748
35827
 
@@ -35752,9 +35831,9 @@ var hostId = exports_external.number().int().positive().describe("Child nameserv
35752
35831
  var dryRun2 = exports_external.boolean().optional().describe("true = return the exact price (IDR, VAT included) and the wallet balance after it; nothing is charged or registered");
35753
35832
  var DRY = "dryRun=true returns the price and walletBalanceAfter without charging or changing anything.";
35754
35833
  var record3 = exports_external.object({
35755
- name: exports_external.string().describe('"@" for the domain itself, or a label like "www"'),
35834
+ name: exports_external.string().describe('"@" for the domain itself, or the part before the domain, e.g. "www" or "_dmarc.mail". A full name like "www.example.com" is accepted and stored as "www"'),
35756
35835
  type: exports_external.enum(["A", "AAAA", "MX", "MXE", "CNAME", "SPF", "TXT"]),
35757
- content: exports_external.string(),
35836
+ content: exports_external.string().describe('TXT without surrounding quotes (they are added by the DNS server); MX as "10 mail.example.com"'),
35758
35837
  ttl: exports_external.number().int().optional()
35759
35838
  });
35760
35839
  var domainTools = [
@@ -35855,7 +35934,7 @@ var domainTools = [
35855
35934
  name: "list_dns_records",
35856
35935
  title: "List DNS records",
35857
35936
  kind: "read",
35858
- description: "DNS records of a registered domain.",
35937
+ description: 'DNS records of a registered domain. prefix is the name relative to the domain ("@" for the domain itself); TXT content is shown with the quotes the DNS server adds. A domain with no records is not served at all (its nameservers answer REFUSED), so add at least one record for it to resolve.',
35859
35938
  input: { domainId },
35860
35939
  run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/dns`)
35861
35940
  }),
@@ -35863,7 +35942,7 @@ var domainTools = [
35863
35942
  name: "set_dns_records",
35864
35943
  title: "Replace DNS records",
35865
35944
  kind: "destructive",
35866
- description: "Replace ALL DNS records of the domain with this list. Records not included are deleted (list_dns_records returns the current ones).",
35945
+ description: "Replace ALL DNS records of the domain with this list. Records not included are deleted: read list_dns_records first and send every record that should stay (prefix as name, content as listed; quotes around TXT are removed for you).",
35867
35946
  input: { domainId, records: exports_external.array(record3).min(1) },
35868
35947
  run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/dns`, { body: { records: a.records } })
35869
35948
  }),
@@ -35917,6 +35996,22 @@ var domainTools = [
35917
35996
  input: { domainId, ...record3.shape },
35918
35997
  run: (a, api2) => api2.api("PUT", `/domains/${a.domainId}/dns`, { body: { name: a.name, type: a.type, content: a.content, ttl: a.ttl } })
35919
35998
  }),
35999
+ defineTool({
36000
+ name: "list_domain_email_records",
36001
+ title: "Email records for a domain",
36002
+ kind: "read",
36003
+ description: "Helipod Email sending domains inside this registered domain (e.g. mail.example.com), each DNS record they need and whether the domain has it: present, missing, or conflict (with what to change).",
36004
+ input: { domainId },
36005
+ run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/email-records`)
36006
+ }),
36007
+ defineTool({
36008
+ name: "add_domain_email_records",
36009
+ title: "Add email records to a domain",
36010
+ kind: "write",
36011
+ description: "Add the DNS records a Helipod Email sending domain still needs to this registered domain (Helipod DNS only), then start its verification. Records that would clash (a CNAME on a name in use, a second SPF) are not changed and are listed in conflicts. emailDomainId comes from list_domain_email_records.",
36012
+ input: { domainId, emailDomainId: exports_external.string().uuid() },
36013
+ run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/email-records`, { body: { emailDomainId: a.emailDomainId } })
36014
+ }),
35920
36015
  defineTool({
35921
36016
  name: "list_child_nameservers",
35922
36017
  title: "List child nameservers",
@@ -36142,7 +36237,7 @@ var emailTools = [
36142
36237
  name: "verify_email_domain",
36143
36238
  title: "Verify sending domain",
36144
36239
  kind: "write",
36145
- description: "Check a sending domain's DNS records again.",
36240
+ description: "Check a sending domain's DNS records again now. Pending domains are also re-checked automatically every 30 minutes for 3 days.",
36146
36241
  input: { projectId, domainId: exports_external.string() },
36147
36242
  run: (a, api2) => api2.email("POST", `/domains/${a.domainId}/verify`, { project: a.projectId })
36148
36243
  }),
@@ -36190,7 +36285,7 @@ var emailTools = [
36190
36285
  name: "get_email_domain",
36191
36286
  title: "Get sending domain",
36192
36287
  kind: "read",
36193
- description: "One sending domain: verification status and the DNS records to add.",
36288
+ description: "One sending domain: verification status and the DNS records to add. ownershipVerified is true once the ownership TXT is confirmed; status turns verified only when DKIM passes too. When the domain is registered at Helipod, add_domain_email_records adds the records in one call.",
36194
36289
  input: { projectId, domainId: exports_external.string() },
36195
36290
  run: (a, api2) => api2.email("GET", `/domains/${a.domainId}`, { project: a.projectId })
36196
36291
  }),
@@ -37126,14 +37221,15 @@ var generalTools = [
37126
37221
  ];
37127
37222
 
37128
37223
  // src/server.ts
37129
- var VERSION = "0.5.4";
37224
+ var VERSION = "0.5.7";
37130
37225
  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.
37131
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.
37132
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.
37133
- - Destructive tools (delete, terminate, reinstall, rollback, restore a backup, 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.
37228
+ - 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.
37134
37229
  - 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.
37135
37230
  - 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.
37136
37231
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37232
+ - 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.
37137
37233
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
37138
37234
  - 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.
37139
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.
package/dist/stdio.js CHANGED
@@ -35471,6 +35471,7 @@ var password = exports_external.string().min(12).max(30).describe("12-30 charact
35471
35471
  var period = exports_external.enum(["monthly", "3month", "6month", "yearly"]);
35472
35472
  var pointId = exports_external.string().describe("Restore point id (from list_vps_restore_points)");
35473
35473
  var backupPath = exports_external.string().max(1024).describe("Absolute path inside the backup, e.g. /etc/nginx or /var/www/index.html");
35474
+ var migrationId = exports_external.string().describe("Migration id (from list_vps_migrations)");
35474
35475
  var vpsTools = [
35475
35476
  defineTool({
35476
35477
  name: "list_vps_plans",
@@ -35850,6 +35851,84 @@ var vpsTools = [
35850
35851
  description: "Status of a prepared backup download; when ready, a short-lived url for the user to open (it expires at expiresAt). The link is for the user to download in a browser.",
35851
35852
  input: { vpsId, downloadId: exports_external.string() },
35852
35853
  run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/backup/downloads/${a.downloadId}`)
35854
+ }),
35855
+ defineTool({
35856
+ name: "list_vps_migrations",
35857
+ title: "List VPS migrations",
35858
+ kind: "read",
35859
+ description: "Migrations onto this VPS, newest first: imports from servers outside Helipod (kind=import) and copies from another VPS's restore point (kind=copy). Each has status (waiting, uploading, inspecting, uploaded, awaiting_command, restoring, restored, failed, expired, cancelled), the uploaded server's OS and size, who ran the command (fetchedIp), progress and pause state of a running job, and the result.",
35860
+ input: { vpsId },
35861
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations`)
35862
+ }),
35863
+ defineTool({
35864
+ name: "get_vps_migration",
35865
+ title: "Get a VPS migration",
35866
+ kind: "read",
35867
+ description: "One migration onto this VPS (see list_vps_migrations). While an upload or restore runs, result.progress has percent (of data read), bytesDone/bytesTotal, sentBytes and sendRate; result.stored the bytes confirmed in storage; result.control the pause state (want vs the job's reported state). Poll it every 15-30 s while it runs.",
35868
+ input: { vpsId, migrationId },
35869
+ run: (a, api2) => api2.api("GET", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
35870
+ }),
35871
+ defineTool({
35872
+ name: "create_vps_import",
35873
+ title: "Start a server import (Migrate to Helipod)",
35874
+ kind: "write",
35875
+ description: "Start moving a server from OUTSIDE Helipod (any Linux server with root) onto this running VPS. Free; one import per VPS, at most 3 per account, kept 7 days, the upload is capped at this VPS's disk. Returns a one-time command (curl ... | sudo bash) that works ONCE and expires in 1 hour: give it to the user to run as root on the OLD server; never run it yourself and do not store it. Pass sourceIp (the old server's public IP, e.g. from curl -4 ifconfig.me there) so the command only works from that server; recommended. Then follow it with get_vps_migration: waiting → uploading → inspecting → uploaded.",
35876
+ input: { vpsId, sourceIp: exports_external.string().max(64).optional().describe("The old server's public IP: the command then only works from it (recommended)") },
35877
+ run: ({ vpsId: id2, sourceIp }, api2) => api2.api("POST", `/vps/${id2}/migrations/import`, { body: { sourceIp: sourceIp ?? null } })
35878
+ }),
35879
+ defineTool({
35880
+ name: "new_vps_migration_command",
35881
+ title: "Get a new migration command",
35882
+ kind: "write",
35883
+ description: "A fresh one-time command for an import (status waiting, uploading or failed before an upload finished) or for a restore that waits for its command (awaiting_command). The previous command stops working, and a job still running with it is told to stop. Shown once: give it to the user to run as root on the right server.",
35884
+ input: { vpsId, migrationId },
35885
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/command`)
35886
+ }),
35887
+ defineTool({
35888
+ name: "restore_vps_import",
35889
+ title: "Restore an import onto the VPS",
35890
+ kind: "destructive",
35891
+ description: "Restore an uploaded import (status uploaded, restored, or failed after an upload) onto its VPS. mode=files restores 1-50 paths (e.g. /home, /var/www, /etc/nginx, /var/backups/helipod-db for database dumps): target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the whole VPS into a copy of the old server: EVERYTHING on the VPS is replaced, only allowed when the VPS runs the same OS as the upload (sourceOs) and is of the full-VM kind. The first restore needs confirmSource=true: before setting it, show the user fetchedIp (and the uploaded server's hostname) from get_vps_migration and ask whether that is their old server; if not, delete the import instead. Runs in the background: follow it with get_vps_migration.",
35892
+ input: {
35893
+ vpsId,
35894
+ migrationId,
35895
+ mode: exports_external.enum(["files", "full"]),
35896
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to restore"),
35897
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)"),
35898
+ confirmSource: exports_external.boolean().optional().describe("true only after the user confirmed fetchedIp is their old server (needed once)")
35899
+ },
35900
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/restore`, { body: { mode: a.mode, paths: a.paths, target: a.target, confirmSource: a.confirmSource } })
35901
+ }),
35902
+ defineTool({
35903
+ name: "control_vps_migration",
35904
+ title: "Pause, resume or stop a migration job",
35905
+ kind: "write",
35906
+ description: "Pause, resume or stop an upload or restore that runs from a one-time command on a server (it must have checked in: result.control is set). The job follows within about 5 seconds; result.control.state shows when it did. Stop is final: the job ends as failed (stopped); a new command (new_vps_migration_command) starts the upload again and reuses what was already uploaded where possible. The user can do the same on that server with sudo helipod-migrate pause | resume | stop.",
35907
+ input: { vpsId, migrationId, action: exports_external.enum(["pause", "resume", "stop"]) },
35908
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/migrations/${a.migrationId}/control`, { body: { action: a.action } })
35909
+ }),
35910
+ defineTool({
35911
+ name: "delete_vps_migration",
35912
+ title: "Delete a VPS migration",
35913
+ kind: "destructive",
35914
+ description: "Delete a migration; an import's uploaded files are deleted from storage and cannot be restored any more. A running upload is told to stop. Not while a restore or the upload check runs.",
35915
+ input: { vpsId, migrationId },
35916
+ run: (a, api2) => api2.api("DELETE", `/vps/${a.vpsId}/migrations/${a.migrationId}`)
35917
+ }),
35918
+ defineTool({
35919
+ name: "copy_vps_backup",
35920
+ title: "Copy a restore point to another VPS",
35921
+ kind: "destructive",
35922
+ description: "Copy a restore point of one VPS (vpsId, pointId from list_vps_restore_points) onto ANOTHER VPS of the same account (targetVpsId; to bring the same VPS back use restore_vps_backup). Free. mode=files copies 1-50 paths onto any running VPS: target=folder (default) into /root/restore-<date>, target=inplace OVERWRITES files at the same paths. mode=full turns the target into a full copy: everything on it is replaced; only between two VPS of the same kind running the same OS and version. Runs in the background: follow it with list_vps_migrations on the TARGET VPS. If the result has a command, the target's management agent was unreachable: give the user that one-time command to run as root on the target.",
35923
+ input: {
35924
+ vpsId,
35925
+ pointId,
35926
+ targetVpsId: exports_external.string().describe("The VPS to copy onto (id from list_vps), not vpsId"),
35927
+ mode: exports_external.enum(["files", "full"]),
35928
+ paths: exports_external.array(backupPath).min(1).max(50).optional().describe("mode=files: files or folders to copy"),
35929
+ target: exports_external.enum(["folder", "inplace"]).optional().describe("mode=files: folder (default, safe) or inplace (overwrite)")
35930
+ },
35931
+ run: (a, api2) => api2.api("POST", `/vps/${a.vpsId}/backup/points/${a.pointId}/copy`, { body: { targetVpsId: a.targetVpsId, mode: a.mode, paths: a.paths, target: a.target } })
35853
35932
  })
35854
35933
  ];
35855
35934
 
@@ -35859,9 +35938,9 @@ var hostId = exports_external.number().int().positive().describe("Child nameserv
35859
35938
  var dryRun2 = exports_external.boolean().optional().describe("true = return the exact price (IDR, VAT included) and the wallet balance after it; nothing is charged or registered");
35860
35939
  var DRY = "dryRun=true returns the price and walletBalanceAfter without charging or changing anything.";
35861
35940
  var record3 = exports_external.object({
35862
- name: exports_external.string().describe('"@" for the domain itself, or a label like "www"'),
35941
+ name: exports_external.string().describe('"@" for the domain itself, or the part before the domain, e.g. "www" or "_dmarc.mail". A full name like "www.example.com" is accepted and stored as "www"'),
35863
35942
  type: exports_external.enum(["A", "AAAA", "MX", "MXE", "CNAME", "SPF", "TXT"]),
35864
- content: exports_external.string(),
35943
+ content: exports_external.string().describe('TXT without surrounding quotes (they are added by the DNS server); MX as "10 mail.example.com"'),
35865
35944
  ttl: exports_external.number().int().optional()
35866
35945
  });
35867
35946
  var domainTools = [
@@ -35962,7 +36041,7 @@ var domainTools = [
35962
36041
  name: "list_dns_records",
35963
36042
  title: "List DNS records",
35964
36043
  kind: "read",
35965
- description: "DNS records of a registered domain.",
36044
+ description: 'DNS records of a registered domain. prefix is the name relative to the domain ("@" for the domain itself); TXT content is shown with the quotes the DNS server adds. A domain with no records is not served at all (its nameservers answer REFUSED), so add at least one record for it to resolve.',
35966
36045
  input: { domainId },
35967
36046
  run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/dns`)
35968
36047
  }),
@@ -35970,7 +36049,7 @@ var domainTools = [
35970
36049
  name: "set_dns_records",
35971
36050
  title: "Replace DNS records",
35972
36051
  kind: "destructive",
35973
- description: "Replace ALL DNS records of the domain with this list. Records not included are deleted (list_dns_records returns the current ones).",
36052
+ description: "Replace ALL DNS records of the domain with this list. Records not included are deleted: read list_dns_records first and send every record that should stay (prefix as name, content as listed; quotes around TXT are removed for you).",
35974
36053
  input: { domainId, records: exports_external.array(record3).min(1) },
35975
36054
  run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/dns`, { body: { records: a.records } })
35976
36055
  }),
@@ -36024,6 +36103,22 @@ var domainTools = [
36024
36103
  input: { domainId, ...record3.shape },
36025
36104
  run: (a, api2) => api2.api("PUT", `/domains/${a.domainId}/dns`, { body: { name: a.name, type: a.type, content: a.content, ttl: a.ttl } })
36026
36105
  }),
36106
+ defineTool({
36107
+ name: "list_domain_email_records",
36108
+ title: "Email records for a domain",
36109
+ kind: "read",
36110
+ description: "Helipod Email sending domains inside this registered domain (e.g. mail.example.com), each DNS record they need and whether the domain has it: present, missing, or conflict (with what to change).",
36111
+ input: { domainId },
36112
+ run: (a, api2) => api2.api("GET", `/domains/${a.domainId}/email-records`)
36113
+ }),
36114
+ defineTool({
36115
+ name: "add_domain_email_records",
36116
+ title: "Add email records to a domain",
36117
+ kind: "write",
36118
+ description: "Add the DNS records a Helipod Email sending domain still needs to this registered domain (Helipod DNS only), then start its verification. Records that would clash (a CNAME on a name in use, a second SPF) are not changed and are listed in conflicts. emailDomainId comes from list_domain_email_records.",
36119
+ input: { domainId, emailDomainId: exports_external.string().uuid() },
36120
+ run: (a, api2) => api2.api("POST", `/domains/${a.domainId}/email-records`, { body: { emailDomainId: a.emailDomainId } })
36121
+ }),
36027
36122
  defineTool({
36028
36123
  name: "list_child_nameservers",
36029
36124
  title: "List child nameservers",
@@ -36249,7 +36344,7 @@ var emailTools = [
36249
36344
  name: "verify_email_domain",
36250
36345
  title: "Verify sending domain",
36251
36346
  kind: "write",
36252
- description: "Check a sending domain's DNS records again.",
36347
+ description: "Check a sending domain's DNS records again now. Pending domains are also re-checked automatically every 30 minutes for 3 days.",
36253
36348
  input: { projectId, domainId: exports_external.string() },
36254
36349
  run: (a, api2) => api2.email("POST", `/domains/${a.domainId}/verify`, { project: a.projectId })
36255
36350
  }),
@@ -36297,7 +36392,7 @@ var emailTools = [
36297
36392
  name: "get_email_domain",
36298
36393
  title: "Get sending domain",
36299
36394
  kind: "read",
36300
- description: "One sending domain: verification status and the DNS records to add.",
36395
+ description: "One sending domain: verification status and the DNS records to add. ownershipVerified is true once the ownership TXT is confirmed; status turns verified only when DKIM passes too. When the domain is registered at Helipod, add_domain_email_records adds the records in one call.",
36301
36396
  input: { projectId, domainId: exports_external.string() },
36302
36397
  run: (a, api2) => api2.email("GET", `/domains/${a.domainId}`, { project: a.projectId })
36303
36398
  }),
@@ -37233,14 +37328,15 @@ var generalTools = [
37233
37328
  ];
37234
37329
 
37235
37330
  // src/server.ts
37236
- var VERSION = "0.5.4";
37331
+ var VERSION = "0.5.7";
37237
37332
  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.
37238
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.
37239
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.
37240
- - Destructive tools (delete, terminate, reinstall, rollback, restore a backup, 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.
37335
+ - 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.
37241
37336
  - 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.
37242
37337
  - 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.
37243
37338
  - Databases: get_database_connection returns a password; put it in a secret variable (DATABASE_URL) instead of code or chat.
37339
+ - 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.
37244
37340
  - Deploys and VPS actions run in the background: poll get_deployment / get_vps until they settle; on a failed deploy read get_deployment_logs.
37245
37341
  - 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.
37246
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.
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.5",
4
+ "version": "0.5.7",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: helipod-ai-gateway
3
- description: Add AI features to an application using the Helipod AI Gateway (ai.helipod.io) - OpenAI and Anthropic compatible endpoints, one key for many models, billed per token in Rupiah. Use when the user wants to call LLMs from their code through Helipod or switch an OpenAI/Anthropic integration to Helipod.
3
+ description: Add AI features to an application using the Helipod AI Gateway (ai.helipod.io) - OpenAI and Anthropic compatible endpoints plus image generation, one key for many models, billed per token in Rupiah. Use when the user wants to call LLMs from their code through Helipod or switch an OpenAI/Anthropic integration to Helipod.
4
4
  ---
5
5
 
6
6
  # Helipod AI Gateway
@@ -38,6 +38,18 @@ ai = OpenAI(base_url="https://ai.helipod.io/v1", api_key=os.environ["HELIPOD_AI_
38
38
  - Prices per 1M tokens (input / output / cached) are on https://helipod.io/ai-gateway. Some models cost more at peak hours (Mon to Fri 08:00 to 11:00 and 13:00 to 17:00 WIB).
39
39
  - Small/fast for classification and chat; larger models for reasoning and code.
40
40
 
41
+ ## Images
42
+
43
+ `POST https://ai.helipod.io/v1/images/generations` (OpenAI images format, same key): `{ model, prompt, size?, n?, images?: [https urls], response_format?: "url"|"b64_json", async?: true }`.
44
+
45
+ ```ts
46
+ const img = await ai.images.generate({ model: "seedream-image-v5.0-flash", prompt: "A wooden boat at sunrise, watercolor" });
47
+ ```
48
+
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
+ - **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
+ - 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
+
41
53
  ## Practical rules
42
54
 
43
55
  - Stream (`stream: true`) for long answers and reasoning models. One request may take at most 10 minutes; set the client timeout to at least 10 minutes. Failed requests are not charged.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: helipod-vps
3
- description: Create and manage Helipod VPS servers - pick a plan and OS, order, power on/off, reinstall, get SSH/RDP login, firewall rules, snapshots, renewals and upgrades. Use when the user wants a virtual server with root access on Helipod.
3
+ description: Create and manage Helipod VPS servers - pick a plan and OS, order, power on/off, reinstall, get SSH/RDP login, firewall rules, snapshots, renewals and upgrades, and migrate a server from another provider (or copy between VPS). Use when the user wants a virtual server with root access on Helipod, or to move a server to Helipod.
4
4
  ---
5
5
 
6
6
  # Helipod VPS
@@ -25,4 +25,16 @@ Follow the `helipod` skill rules. A VPS is prepaid per period: creating one char
25
25
  - Billing: `get_vps_renewal_options` → `renew_vps`; `upgrade_vps` to a larger plan (charges the difference, restarts); `toggle_vps_auto_renew`.
26
26
  - Terminate: `terminate_vps` is permanent, no refund: confirm explicitly.
27
27
 
28
+ ## Migrate a server to Helipod (free)
29
+
30
+ For a server at another provider (Zeabur, DigitalOcean, any Linux server with root):
31
+
32
+ 1. The target is a running Helipod VPS with enough disk for the old server's data (create one first if needed). Whole-server restores need the same OS and version as the old server: ask which OS it runs before picking one.
33
+ 2. Ask for the old server's public IP (`curl -4 ifconfig.me` there), then `create_vps_import` with `sourceIp`. Give the user the returned command to run as root on the OLD server. It works once, for 1 hour; never run it yourself. Lost or expired: `new_vps_migration_command`.
34
+ 3. `get_vps_migration` every 15-30 s: `waiting` → `uploading` (progress, sent bytes; pause / resume / stop with `control_vps_migration`) → `inspecting` → `uploaded` (`sourceOs`, `sourceBytes`, `fetchedIp`).
35
+ 4. Show the user `fetchedIp` and the server's hostname and ask whether that is their old server. Only then `restore_vps_import` with `confirmSource: true`. Suggest folders first (`/var/www`, `/home`, `/etc/nginx`, `/var/backups/helipod-db` for database dumps) into a new folder (`target: "folder"`); `inplace` overwrites and `mode: "full"` replaces the whole VPS: confirm explicitly.
36
+ 5. The upload is kept 7 days, so the user can restore again; `delete_vps_migration` removes it sooner.
37
+
38
+ Copy between Helipod VPS: `list_vps_restore_points` on the source, then `copy_vps_backup` with `targetVpsId` (folders onto any VPS; whole server only between the same kind and OS), then follow `list_vps_migrations` on the target.
39
+
28
40
  Backups (paid add-on) have dedicated tools; to read anything else use `helipod_api_get` with paths from https://docs.helipod.io/docs/vps-api