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 +150 -11
- package/dist/index.js +150 -11
- package/dist/stdio.js +150 -11
- package/package.json +1 -1
- package/skills/helipod-ai-gateway/SKILL.md +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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.
|
|
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.
|
|
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
|
|