@selfhost.dev/mcp-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +205 -43
  2. package/dist/client.d.ts +7 -0
  3. package/dist/client.js +2 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.js +21 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/tools/alerts.js +12 -2
  8. package/dist/tools/alerts.js.map +1 -1
  9. package/dist/tools/backups.js +29 -5
  10. package/dist/tools/backups.js.map +1 -1
  11. package/dist/tools/billing.js +233 -39
  12. package/dist/tools/billing.js.map +1 -1
  13. package/dist/tools/database-users.js +53 -6
  14. package/dist/tools/database-users.js.map +1 -1
  15. package/dist/tools/deployments.js +47 -3
  16. package/dist/tools/deployments.js.map +1 -1
  17. package/dist/tools/github.js +21 -4
  18. package/dist/tools/github.js.map +1 -1
  19. package/dist/tools/instance-logs.js +2 -2
  20. package/dist/tools/instance-logs.js.map +1 -1
  21. package/dist/tools/instances.js +423 -88
  22. package/dist/tools/instances.js.map +1 -1
  23. package/dist/tools/organizations.js +5 -1
  24. package/dist/tools/organizations.js.map +1 -1
  25. package/dist/tools/pitr.d.ts +22 -0
  26. package/dist/tools/pitr.js +75 -10
  27. package/dist/tools/pitr.js.map +1 -1
  28. package/dist/tools/postgres-extensions.js +176 -57
  29. package/dist/tools/postgres-extensions.js.map +1 -1
  30. package/dist/tools/project-backups.js +130 -0
  31. package/dist/tools/project-backups.js.map +1 -1
  32. package/dist/tools/project-databases.js +16 -23
  33. package/dist/tools/project-databases.js.map +1 -1
  34. package/dist/tools/project-services.js +544 -53
  35. package/dist/tools/project-services.js.map +1 -1
  36. package/dist/tools/project-ssh.d.ts +2 -0
  37. package/dist/tools/project-ssh.js +338 -0
  38. package/dist/tools/project-ssh.js.map +1 -0
  39. package/dist/tools/projects.js +21 -3
  40. package/dist/tools/projects.js.map +1 -1
  41. package/dist/tools/scaling.js +1 -1
  42. package/dist/tools/scaling.js.map +1 -1
  43. package/dist/types/tiers.js +27 -4
  44. package/dist/types/tiers.js.map +1 -1
  45. package/package.json +2 -1
@@ -2,15 +2,20 @@ import { z } from "zod";
2
2
  import { apiRequest } from "../client.js";
3
3
  import { session } from "../session.js";
4
4
  /**
5
- * Project services (CoolifyService) - one-click application templates deployed
6
- * into a project. FOUR templates are live, not one: the backend resolves them
7
- * from `ServiceProvisioning::Strategy.all`.
5
+ * Project services (CoolifyService) - one-click application templates deployed into a project.
6
+ * TWENTY-SIX templates are live, resolved backend-side from `ServiceProvisioning::Strategy.all`
7
+ * (one strategy file per template, auto-discovered). This module shipped knowing four of them
8
+ * until the 2026-09 sync, which meant an agent could only reach a sixth of the catalogue.
8
9
  *
9
10
  * Endpoints:
10
11
  * - GET /api/v1/platform/projects/:pid/services
11
12
  * - GET /api/v1/platform/projects/:pid/services/:service_pid (the ONLY source of credentials)
12
13
  * - POST /api/v1/platform/projects/:pid/services/:template_type (202 - async provisioning)
13
14
  * - DELETE /api/v1/platform/projects/:pid/services/:service_pid (202; body {name} must match)
15
+ * - POST /api/v1/platform/projects/:pid/services/:pid/custom_domain
16
+ * - DELETE /api/v1/platform/projects/:pid/services/:pid/custom_domain
17
+ * - POST/GET /api/v1/platform/projects/:pid/services/:pid/logs (async request-then-read)
18
+ * - POST/GET /api/v1/platform/projects/:pid/services/:pid/stats (async request-then-read)
14
19
  *
15
20
  * The project must be `active` before creating a service (409 otherwise).
16
21
  *
@@ -20,50 +25,353 @@ import { session } from "../session.js";
20
25
  *
21
26
  * The backend ships templates ahead of its clients, so `list_project_services` can
22
27
  * return a `template_type` this module does not know. Report it by its wire string
23
- * rather than guessing - list and delete work on any type.
28
+ * rather than guessing - list, delete, logs, stats and custom domains work on any type.
29
+ *
30
+ * THREE CREDENTIAL SHAPES, and getting this wrong is the main failure mode here:
31
+ * - `dashboardLogin` - the create takes `dashboard_username` + `dashboard_password`, and they
32
+ * become the app's admin login. The username's accepted FORM varies:
33
+ * any string, a slug (`[a-zA-Z0-9_-]+`), or an email address (Krayin
34
+ * signs in by email, so a bare `admin` creates an unusable account).
35
+ * - `fixedRootPassword` - password only; the app owns the username (GitLab is always `root`,
36
+ * Elasticsearch always `elastic`). Sending a username is refused here.
37
+ * - `none` - no admin env at all. THE FIRST VISITOR TO THE PUBLIC URL CLAIMS THE
38
+ * OWNER ACCOUNT. This is a real security consequence and every create
39
+ * of one of these says so unprompted.
40
+ *
41
+ * CAPACITY IS A DECLARE-TIME REFUSAL, NOT A RUNTIME DEGRADATION. A project is ONE VM.
42
+ * `heavy` templates (many containers, large pulls, first-boot migrations) are held to
43
+ * stricter free-memory limits than light ones, some carry a hard minimum on the host's TOTAL
44
+ * RAM, at most 5 children can exist per project, and Stalwart is a singleton because its mail
45
+ * ports are fixed on the host. A project server cannot be resized, so "out of RAM" means a
46
+ * NEW project - which is why the numbers are surfaced before the call rather than after.
24
47
  */
25
48
  const PROJECTS_BASE = "/api/v1/platform/projects";
26
49
  /** Wire values accepted at `POST .../services/:template_type`. */
27
- const TEMPLATE_TYPES = ["supabase", "n8n", "n8n-with-postgresql", "twenty"];
28
- /**
29
- * Per-template truth. `dashboardCreds` is the one that changes the call shape:
30
- * Supabase takes its Studio credentials as env at deploy, while n8n and Twenty
31
- * have no admin env at all - their first admin is whoever opens the URL first.
32
- */
50
+ const TEMPLATE_TYPES = [
51
+ "supabase", "n8n", "n8n-with-postgresql", "twenty",
52
+ "wordpress", "gitlab", "elasticsearch", "elasticsearch-with-kibana",
53
+ "immich", "nextcloud", "jenkins", "grafana",
54
+ "grafana-with-postgresql", "strapi", "metabase", "anythingllm",
55
+ "stalwart", "chatwoot", "mautic", "minio",
56
+ "open-webui-with-ollama", "litellm", "odoo", "paperless",
57
+ "espocrm", "krayin",
58
+ ];
33
59
  const TEMPLATES = {
34
- supabase: {
60
+ "supabase": {
35
61
  label: "Supabase",
36
- dashboardCreds: true,
37
- boot: "about 5 minutes",
38
- firstAdmin: "the dashboard username/password you pass here",
39
- credentials: "dashboard username + password, `public_url`, and the anon key when present",
40
- },
41
- n8n: {
42
- label: "n8n (workflow automation)",
43
- dashboardCreds: false,
44
- boot: "up to 10 minutes",
45
- firstAdmin: "THE FIRST PERSON TO OPEN THE URL - n8n shows an owner-setup screen to whoever gets there first",
46
- credentials: "`public_url` only",
62
+ tagline: "Postgres, Auth, storage and auto-generated APIs.",
63
+ recommendedRamGb: 8,
64
+ heavy: true,
65
+ creds: "dashboardLogin",
66
+ usernameShape: "any",
67
+ minPasswordLength: 8,
68
+ bootMinutes: 7,
69
+ },
70
+ "n8n": {
71
+ label: "n8n",
72
+ tagline: "Workflow automation with hundreds of integrations.",
73
+ recommendedRamGb: 4,
74
+ heavy: false,
75
+ creds: "none",
76
+ bootMinutes: 4,
47
77
  },
48
78
  "n8n-with-postgresql": {
49
- label: "n8n backed by PostgreSQL",
50
- dashboardCreds: false,
51
- boot: "up to 10 minutes",
52
- firstAdmin: "THE FIRST PERSON TO OPEN THE URL - n8n shows an owner-setup screen to whoever gets there first",
53
- credentials: "`public_url` only",
79
+ label: "n8n (Postgres)",
80
+ tagline: "The same n8n, backed by its own Postgres instead of SQLite.",
81
+ recommendedRamGb: 4,
82
+ heavy: false,
83
+ creds: "none",
84
+ bootMinutes: 5,
54
85
  },
55
- twenty: {
86
+ "twenty": {
56
87
  label: "Twenty (CRM)",
57
- dashboardCreds: false,
58
- boot: "up to 15 minutes - it pulls images and runs migrations, so do not call it stuck early",
59
- firstAdmin: "THE FIRST PERSON TO OPEN THE URL - Twenty shows a sign-up screen to whoever gets there first",
60
- credentials: "`public_url` only",
88
+ tagline: "Open-source CRM for contacts, deals and pipelines.",
89
+ recommendedRamGb: 8,
90
+ heavy: true,
91
+ creds: "none",
92
+ bootMinutes: 14,
93
+ },
94
+ "wordpress": {
95
+ label: "WordPress",
96
+ tagline: "MariaDB-backed WordPress for publishing and sites.",
97
+ recommendedRamGb: 4,
98
+ heavy: false,
99
+ creds: "none",
100
+ bootMinutes: 6,
101
+ },
102
+ "gitlab": {
103
+ label: "GitLab CE",
104
+ tagline: "Repositories, reviews and CI.",
105
+ recommendedRamGb: 8,
106
+ heavy: true,
107
+ creds: "fixedRootPassword",
108
+ fixedUsername: "root",
109
+ minPasswordLength: 12,
110
+ hostFloorGb: 8,
111
+ bootMinutes: 25,
112
+ loginNote: "GitLab ships with public sign-up disabled.",
113
+ },
114
+ "elasticsearch": {
115
+ label: "Elasticsearch",
116
+ tagline: "Search and analytics over your own documents.",
117
+ recommendedRamGb: 8,
118
+ heavy: true,
119
+ creds: "fixedRootPassword",
120
+ fixedUsername: "elastic",
121
+ minPasswordLength: 8,
122
+ bootMinutes: 10,
123
+ loginNote: "This password is the only lock on the endpoint, so keep it somewhere safe.",
124
+ },
125
+ "elasticsearch-with-kibana": {
126
+ label: "Elasticsearch + Kibana",
127
+ tagline: "Search and analytics with a UI in front of it.",
128
+ recommendedRamGb: 8,
129
+ heavy: true,
130
+ creds: "fixedRootPassword",
131
+ fixedUsername: "elastic",
132
+ minPasswordLength: 8,
133
+ hostFloorGb: 4,
134
+ bootMinutes: 8,
135
+ loginNote: "The URL is KIBANA, not the raw Elasticsearch API. Query the API from Kibana's Dev Tools, or deploy plain `elasticsearch` when an app needs to reach it directly.",
136
+ },
137
+ "immich": {
138
+ label: "Immich",
139
+ tagline: "Photo and video backup for your phone.",
140
+ recommendedRamGb: 8,
141
+ heavy: true,
142
+ creds: "none",
143
+ bootMinutes: 20,
144
+ },
145
+ "nextcloud": {
146
+ label: "Nextcloud",
147
+ tagline: "Files, calendar and contacts you host yourself.",
148
+ recommendedRamGb: 8,
149
+ heavy: true,
150
+ creds: "dashboardLogin",
151
+ usernameShape: "any",
152
+ minPasswordLength: 8,
153
+ bootMinutes: 15,
154
+ },
155
+ "jenkins": {
156
+ label: "Jenkins",
157
+ tagline: "The CI server for your builds.",
158
+ recommendedRamGb: 4,
159
+ heavy: true,
160
+ creds: "dashboardLogin",
161
+ usernameShape: "slug",
162
+ minPasswordLength: 8,
163
+ hostFloorGb: 2,
164
+ bootMinutes: 15,
165
+ loginNote: "No setup wizard and no unlock key to find: the admin is seeded before the first boot.",
166
+ },
167
+ "grafana": {
168
+ label: "Grafana",
169
+ tagline: "Dashboards and alerts over your metrics.",
170
+ recommendedRamGb: 4,
171
+ heavy: false,
172
+ creds: "dashboardLogin",
173
+ usernameShape: "slug",
174
+ minPasswordLength: 8,
175
+ bootMinutes: 5,
176
+ loginNote: "It never boots on the well-known admin/admin default.",
177
+ },
178
+ "grafana-with-postgresql": {
179
+ label: "Grafana (Postgres)",
180
+ tagline: "The same Grafana, with its dashboards and alerts in a bundled Postgres.",
181
+ recommendedRamGb: 4,
182
+ heavy: false,
183
+ creds: "dashboardLogin",
184
+ usernameShape: "slug",
185
+ minPasswordLength: 8,
186
+ bootMinutes: 5,
187
+ loginNote: "It never boots on the well-known admin/admin default.",
188
+ },
189
+ "strapi": {
190
+ label: "Strapi",
191
+ tagline: "A headless CMS with an admin panel and a content API.",
192
+ recommendedRamGb: 4,
193
+ heavy: false,
194
+ creds: "none",
195
+ bootMinutes: 6,
196
+ },
197
+ "metabase": {
198
+ label: "Metabase",
199
+ tagline: "Dashboards and questions over your data.",
200
+ recommendedRamGb: 4,
201
+ heavy: true,
202
+ creds: "none",
203
+ hostFloorGb: 2,
204
+ bootMinutes: 7,
205
+ },
206
+ "anythingllm": {
207
+ label: "AnythingLLM",
208
+ tagline: "Chat over your own documents.",
209
+ recommendedRamGb: 4,
210
+ heavy: false,
211
+ creds: "fixedRootPassword",
212
+ minPasswordLength: 8,
213
+ bootMinutes: 6,
214
+ loginNote: "The unlock screen asks for this password ALONE, with no username. Without it AnythingLLM would boot open to anyone with the link.",
215
+ },
216
+ "stalwart": {
217
+ label: "Stalwart (mail server)",
218
+ tagline: "A full mail server, SMTP through JMAP.",
219
+ recommendedRamGb: 4,
220
+ heavy: false,
221
+ creds: "fixedRootPassword",
222
+ fixedUsername: "admin",
223
+ minPasswordLength: 8,
224
+ singleton: true,
225
+ bootMinutes: 4,
226
+ loginNote: "Hetzner blocks outbound port 25 on new accounts, so RECEIVING mail works at once but SENDING needs an unblock request or a relay. ONE STALWART PER PROJECT - the mail ports are fixed on the host, so a second one is refused at declare time.",
227
+ },
228
+ "chatwoot": {
229
+ label: "Chatwoot",
230
+ tagline: "Live chat and a shared support inbox.",
231
+ recommendedRamGb: 8,
232
+ heavy: true,
233
+ creds: "none",
234
+ bootMinutes: 12,
61
235
  },
236
+ "mautic": {
237
+ label: "Mautic",
238
+ tagline: "Marketing automation and campaigns.",
239
+ recommendedRamGb: 8,
240
+ heavy: true,
241
+ creds: "none",
242
+ bootMinutes: 8,
243
+ },
244
+ "minio": {
245
+ label: "MinIO",
246
+ tagline: "S3-compatible object storage.",
247
+ recommendedRamGb: 4,
248
+ heavy: false,
249
+ creds: "dashboardLogin",
250
+ usernameShape: "any",
251
+ minPasswordLength: 8,
252
+ bootMinutes: 3,
253
+ loginNote: "The username is the S3 ACCESS KEY and the password is the SECRET. There is no web console: manage buckets with the `mc` CLI or any S3 SDK.",
254
+ },
255
+ "open-webui-with-ollama": {
256
+ label: "Open WebUI + Ollama",
257
+ tagline: "Chat with local models on your own box.",
258
+ recommendedRamGb: 8,
259
+ heavy: true,
260
+ creds: "none",
261
+ bootMinutes: 11,
262
+ loginNote: "Project servers are CPU ONLY, so stay with small models (1B-3B). Nothing ships with a model - you pull the first one yourself.",
263
+ },
264
+ "litellm": {
265
+ label: "LiteLLM",
266
+ tagline: "One OpenAI-compatible endpoint in front of every model provider.",
267
+ recommendedRamGb: 4,
268
+ heavy: false,
269
+ creds: "fixedRootPassword",
270
+ fixedUsername: "admin",
271
+ minPasswordLength: 8,
272
+ bootMinutes: 6,
273
+ loginNote: "This password is ALSO the API master key, as `sk-<password>`. Mint scoped virtual keys in the UI rather than handing that one out.",
274
+ },
275
+ "odoo": {
276
+ label: "Odoo",
277
+ tagline: "The open-source ERP and business suite.",
278
+ recommendedRamGb: 8,
279
+ heavy: true,
280
+ creds: "fixedRootPassword",
281
+ minPasswordLength: 8,
282
+ bootMinutes: 8,
283
+ loginNote: "This is the DATABASE-MANAGER MASTER PASSWORD, not a login. You use it once to create the database and pick your own admin email and password.",
284
+ },
285
+ "paperless": {
286
+ label: "Paperless-ngx",
287
+ tagline: "A searchable, OCR-indexed archive of your documents.",
288
+ recommendedRamGb: 8,
289
+ heavy: true,
290
+ creds: "dashboardLogin",
291
+ usernameShape: "any",
292
+ minPasswordLength: 8,
293
+ bootMinutes: 8,
294
+ },
295
+ "espocrm": {
296
+ label: "EspoCRM",
297
+ tagline: "Contacts, accounts and deals in a CRM you own.",
298
+ recommendedRamGb: 8,
299
+ heavy: true,
300
+ creds: "dashboardLogin",
301
+ usernameShape: "slug",
302
+ minPasswordLength: 8,
303
+ bootMinutes: 12,
304
+ loginNote: "No install wizard: the admin is seeded before the first boot, so it never comes up on the documented admin/password default.",
305
+ },
306
+ "krayin": {
307
+ label: "Krayin (CRM)",
308
+ tagline: "Leads, quotes and deals in an open-source CRM you own.",
309
+ recommendedRamGb: 8,
310
+ heavy: true,
311
+ creds: "dashboardLogin",
312
+ fixedUsername: "",
313
+ usernameShape: "email",
314
+ minPasswordLength: 8,
315
+ unsupportedArchitectures: ["arm"],
316
+ bootMinutes: 15,
317
+ loginNote: "Krayin signs in with an EMAIL ADDRESS, so dashboard_username must be one - a bare `admin` creates an account nobody can sign in as. The admin is seeded before the first boot.",
318
+ },
319
+ };
320
+ /** Username validation per shape, mirroring the backend validators. */
321
+ const USERNAME_SHAPE_RULES = {
322
+ any: { test: (v) => v.length > 0, hint: "any non-empty string" },
323
+ slug: { test: (v) => /^[a-zA-Z0-9_-]+$/.test(v), hint: "letters, digits, hyphen and underscore only" },
324
+ email: { test: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v), hint: "an email address - this app signs in by email" },
62
325
  };
326
+ /** Rendered into the create description so the catalogue cannot drift from the code. */
327
+ const TEMPLATE_CATALOGUE_TEXT = TEMPLATE_TYPES.map((t) => {
328
+ const f = TEMPLATES[t];
329
+ const creds = f.creds === "dashboardLogin"
330
+ ? `username + password (${USERNAME_SHAPE_RULES[f.usernameShape ?? "any"].hint})`
331
+ : f.creds === "fixedRootPassword"
332
+ ? `password only${f.fixedUsername ? `, user is always \`${f.fixedUsername}\`` : ""}`
333
+ : "NO admin creds - first visitor owns it";
334
+ const flags = [
335
+ f.heavy ? "heavy" : null,
336
+ f.hostFloorGb ? `needs a ${f.hostFloorGb} GB+ host` : null,
337
+ f.singleton ? "one per project" : null,
338
+ f.unsupportedArchitectures?.length ? `no ${f.unsupportedArchitectures.join("/")} hosts` : null,
339
+ ].filter(Boolean).join(", ");
340
+ return `- \`${t}\` (${f.label}) - ${f.tagline} ${creds}. ~${f.bootMinutes} min, ${f.recommendedRamGb} GB recommended${flags ? ` [${flags}]` : ""}`;
341
+ }).join("\n");
63
342
  const NO_ORG = "No active organization. Call `list_organizations` then `select_organization` first.";
64
343
  function hasOrg() {
65
344
  return session.getActiveOrgId() !== null;
66
345
  }
346
+ /**
347
+ * Container inspection covers project SERVICES and project DATABASES with one contract - both
348
+ * are Docker containers on the project's single box, and the backend shares one
349
+ * `ContainerInspectable` concern for them. `resource_type` picks the collection rather than
350
+ * this module carrying two near-identical copies of four tools.
351
+ *
352
+ * REQUEST-THEN-READ, like the AWS instance-logs surface: POST dispatches a fetch to the host
353
+ * agent and returns a `pending` record; GET settles pending fetches against their tasks and
354
+ * returns the recent ones (newest first, up to 20).
355
+ *
356
+ * READ-ONLY BY CONSTRUCTION. The backend emits only allow-listed `docker ps / logs / stats /
357
+ * inspect` commands through the agent - no shell, no Docker socket, and the docker filter is
358
+ * scoped to this resource's containers. There is deliberately NO start / stop / restart /
359
+ * remove / exec surface anywhere on the platform, so do not go looking for one or promise it.
360
+ */
361
+ const INSPECT_LINES_DEFAULT = 200;
362
+ const INSPECT_LINES_MAX = 1000;
363
+ const INSPECT_RESOURCE_TYPES = ["service", "database"];
364
+ const FETCH_POLL_NOTE = "The response is a `fetches` array, newest first (up to 20). Each carries `status` (`pending` | `completed` | `failed`), `output` once completed, `error` when it failed, and `lines_requested`. A fetch still `pending` after 3 minutes settles as `failed` - the agent's reply was lost, so request a fresh one rather than polling on. An empty array means nothing has been requested for this resource yet.";
365
+ function inspectPath(projectPid, resourceType, resourcePid, operation) {
366
+ const collection = resourceType === "service" ? "services" : "databases";
367
+ return `${PROJECTS_BASE}/${projectPid}/${collection}/${resourcePid}/${operation}`;
368
+ }
369
+ const inspectArgs = {
370
+ project_pid: z.string().min(1),
371
+ resource_type: z.enum(INSPECT_RESOURCE_TYPES)
372
+ .describe("`service` for a one-click template, `database` for a project Postgres/Redis/MySQL/MongoDB. Picks which collection the pid is looked up in."),
373
+ resource_pid: z.string().min(1).describe("The service or database pid, as returned by list_project_services / list_project_databases"),
374
+ };
67
375
  export function registerProjectServiceTools(server) {
68
376
  server.tool("list_project_services", `List the one-click application templates deployed in a project. Returns pid, name, template_type, status (\`creating\` | \`active\` | \`inactive\` | \`failed\` | \`unknown\`), is_public, public_port, external_url, \`error_message\` when it failed, and a \`provisioning\` block with the per-stage progress.
69
377
 
@@ -104,40 +412,73 @@ Call this after \`create_project_service\` reports \`active\`, and give the user
104
412
  }
105
413
  return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
106
414
  });
107
- server.tool("create_project_service", `Deploy a one-click application template into a project. FOUR are available:
415
+ server.tool("create_project_service", `Deploy a one-click application template into a project. TWENTY-SIX are available:
416
+
417
+ ${TEMPLATE_CATALOGUE_TEXT}
108
418
 
109
- - \`supabase\` - Postgres + Studio + auth/storage/realtime. The ONLY one that takes admin credentials up front: \`dashboard_username\` and \`dashboard_password\` (8+ chars) are REQUIRED and become the Studio login. Boots in about 5 minutes.
110
- - \`n8n\` - workflow automation, data in a bundled SQLite volume. Name only.
111
- - \`n8n-with-postgresql\` - the same n8n, backed by a per-service PostgreSQL sidecar instead of SQLite. Pick this for anything beyond light use; the difference is durability and concurrency, and it cannot be switched afterwards. Name only.
112
- - \`twenty\` - open-source CRM. Name only. Boots in up to 15 minutes (images + migrations) - do not declare it stuck early.
419
+ CREDENTIALS DIFFER PER TEMPLATE AND THE WRONG SHAPE IS REFUSED HERE rather than sent and silently dropped:
420
+ - Templates taking a username + password: pass \`dashboard_username\` and \`dashboard_password\`. The accepted username FORM varies - a slug for Jenkins/Grafana/EspoCRM, an EMAIL for Krayin, anything for the rest. The catalogue above says which.
421
+ - Templates taking a password only: pass \`dashboard_password\` alone. The app owns the username (GitLab is \`root\`, Elasticsearch is \`elastic\`). Passing a username is refused.
422
+ - Templates taking nothing: pass neither.
113
423
 
114
- FIRST-ADMIN WARNING for n8n, n8n-with-postgresql and Twenty: these have no admin credentials at all. The first person to open the public URL gets the owner account. Tell the user to complete setup immediately, and do not post the URL anywhere until they have. Passing \`dashboard_username\`/\`dashboard_password\` to them does nothing - they are rejected here rather than sent and silently ignored.
424
+ FIRST-ADMIN SECURITY WARNING, for every template marked "first visitor owns it": these ship with no admin credentials whatsoever. Whoever opens the public URL first gets the owner account. Say this UNPROMPTED, tell the user to finish setup the moment it is up, and tell them not to share the URL before then.
115
425
 
116
426
  \`is_public\` is REQUIRED with no default: ASK THE USER. These are web apps whose first-run setup happens at the public URL, so \`true\` is usually what they want, but do not assume it.
117
427
 
428
+ CAPACITY: a project is ONE server, and these limits are refusals at declare time, not slow degradation. \`heavy\` templates must leave more headroom (65% memory / 75% disk vs 80% / 85%); some need a minimum TOTAL host RAM whatever is free; at most 5 children per project; Stalwart is one-per-project. A project server CANNOT be resized, so exhausting it means creating a new project - check the recommended RAM against the project's server BEFORE promising a deploy.
429
+
118
430
  The project must be \`active\` (409 otherwise). Returns 202 with status \`creating\`; poll \`list_project_services\`, then call \`get_project_service\` for the credentials once it is \`active\`.
119
431
 
120
- EXPECT THESE 422s: a duplicate service name inside the same project; \`public_port\` already claimed by another database or service in the project; and the host being out of RAM (a capacity guard refuses new services rather than thrashing the box - the fix is a bigger project server, and there is no in-place resize for projects, so it means a new project).`, {
432
+ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_port\` already claimed by another database or service; the capacity guard refusing the template; a second Stalwart; and Krayin on an ARM (\`cax*\`) host, whose image has no ARM manifest at any tag.`, {
121
433
  project_pid: z.string().min(1),
122
- template_type: z.enum(TEMPLATE_TYPES).describe("supabase | n8n | n8n-with-postgresql | twenty"),
434
+ template_type: z.enum(TEMPLATE_TYPES).describe("One of the 26 template slugs - see the catalogue in the description"),
123
435
  name: z.string().min(1).max(255).describe("Unique within the project"),
124
436
  is_public: z.boolean().describe("Required choice (ask the user, do not assume): true = internet-reachable; false = private to the project. First-run setup for these apps happens at the public URL."),
125
- dashboard_username: z.string().min(1).max(255).optional().describe("Supabase ONLY - the Studio dashboard username. Required for supabase, rejected for every other template."),
126
- dashboard_password: z.string().min(8).max(255).optional().describe("Supabase ONLY - the Studio dashboard password, 8+ chars. Required for supabase, rejected for every other template."),
437
+ dashboard_username: z.string().min(1).max(255).optional().describe("For templates that take a username + password only. The accepted form varies per template (slug / email / any) - see the catalogue. Refused for password-only and no-credential templates."),
438
+ dashboard_password: z.string().min(8).max(255).optional().describe("For templates that take any password. 8+ chars (GitLab requires 12+). Refused for no-credential templates."),
127
439
  public_port: z.number().int().min(1024).max(65535).optional().describe("Optional. Must not collide with another database or service in this project."),
128
440
  }, async ({ project_pid, template_type, name, is_public, dashboard_username, dashboard_password, public_port }) => {
129
441
  if (!hasOrg())
130
442
  return { content: [{ type: "text", text: NO_ORG }] };
131
443
  const template = TEMPLATES[template_type];
132
- // Fail loudly on a credential/template mismatch instead of sending fields the
133
- // backend drops. Silently ignoring them is how "I set a Twenty admin password"
134
- // becomes a false belief the user acts on.
135
- if (template.dashboardCreds) {
444
+ const minPw = template.minPasswordLength ?? 8;
445
+ // Validate the credential SHAPE before sending. The backend drops fields a template
446
+ // does not accept, so a silently-ignored password leaves the user believing they set
447
+ // one - which for a "first visitor owns it" template is a security problem, not a
448
+ // cosmetic one.
449
+ if (template.creds === "dashboardLogin") {
136
450
  if (!dashboard_username || !dashboard_password) {
137
451
  return {
138
452
  content: [{
139
453
  type: "text",
140
- text: `\`${template_type}\` requires both dashboard_username and dashboard_password (8+ chars) - they become the Supabase Studio login. Ask the user for them.`,
454
+ text: `\`${template_type}\` (${template.label}) requires BOTH dashboard_username and dashboard_password - they become its admin login. Username must be ${USERNAME_SHAPE_RULES[template.usernameShape ?? "any"].hint}; password ${minPw}+ chars. Ask the user for them.`,
455
+ }],
456
+ };
457
+ }
458
+ const rule = USERNAME_SHAPE_RULES[template.usernameShape ?? "any"];
459
+ if (!rule.test(dashboard_username)) {
460
+ return {
461
+ content: [{
462
+ type: "text",
463
+ text: `dashboard_username \`${dashboard_username}\` is not valid for ${template.label}: it must be ${rule.hint}.${template.usernameShape === "email" ? " Krayin signs in by email address, so a non-email username creates an admin account nobody can log in as." : ""}`,
464
+ }],
465
+ };
466
+ }
467
+ }
468
+ else if (template.creds === "fixedRootPassword") {
469
+ if (!dashboard_password) {
470
+ return {
471
+ content: [{
472
+ type: "text",
473
+ text: `\`${template_type}\` (${template.label}) requires dashboard_password (${minPw}+ chars).${template.fixedUsername ? ` The username is fixed by the app: \`${template.fixedUsername}\`.` : " The app does not use a username here."}${template.loginNote ? `\n\n${template.loginNote}` : ""}`,
474
+ }],
475
+ };
476
+ }
477
+ if (dashboard_username) {
478
+ return {
479
+ content: [{
480
+ type: "text",
481
+ text: `\`${template_type}\` does not accept a username - ${template.fixedUsername ? `the app's account is always \`${template.fixedUsername}\`` : "it has no username"}, so dashboard_username was not sent. Re-run with the password alone.`,
141
482
  }],
142
483
  };
143
484
  }
@@ -146,15 +487,26 @@ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_po
146
487
  return {
147
488
  content: [{
148
489
  type: "text",
149
- text: `\`${template_type}\` has no admin credentials to set, so dashboard_username / dashboard_password do not apply and were not sent. Its first admin is ${template.firstAdmin}. Re-run without them.`,
490
+ text: `\`${template_type}\` (${template.label}) has no admin credentials to set, so dashboard_username / dashboard_password do not apply and were NOT sent. Its owner account goes to the first person who opens the public URL. Re-run without them.`,
491
+ }],
492
+ };
493
+ }
494
+ if (dashboard_password && dashboard_password.length < minPw) {
495
+ return {
496
+ content: [{
497
+ type: "text",
498
+ text: `${template.label} requires a password of at least ${minPw} characters (got ${dashboard_password.length}).`,
150
499
  }],
151
500
  };
152
501
  }
153
502
  const body = { name, is_public, instant_deploy: true };
154
- if (template.dashboardCreds) {
503
+ if (template.creds === "dashboardLogin") {
155
504
  body.dashboard_username = dashboard_username;
156
505
  body.dashboard_password = dashboard_password;
157
506
  }
507
+ else if (template.creds === "fixedRootPassword") {
508
+ body.dashboard_password = dashboard_password;
509
+ }
158
510
  if (public_port !== undefined)
159
511
  body.public_port = public_port;
160
512
  const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${template_type}`, {
@@ -166,11 +518,14 @@ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_po
166
518
  return { content: [{ type: "text", text: `Failed to create service (${result.statusCode}): ${result.message}` }] };
167
519
  }
168
520
  const lines = [
169
- `${template.label} service \`${name}\` is provisioning (status: creating). Expect ${template.boot}.`,
170
- `Poll \`list_project_services\`, then call \`get_project_service\` for ${template.credentials}.`,
521
+ `${template.label} service \`${name}\` is provisioning (status: creating). Expect around ${template.bootMinutes} minute${template.bootMinutes === 1 ? "" : "s"} - do not call it stuck before then.`,
522
+ "Poll `list_project_services`, then call `get_project_service` (with include_credentials) once it is `active`. Credentials live on that endpoint only.",
171
523
  ];
172
- if (!template.dashboardCreds) {
173
- lines.push(`SECURITY: the first admin is ${template.firstAdmin}. Tell the user to open the URL and finish setup as soon as it is up, and not to share it before then.`);
524
+ if (template.creds === "none") {
525
+ lines.push(`SECURITY - ACT ON THIS NOW: ${template.label} has no admin account. THE FIRST PERSON TO OPEN THE PUBLIC URL BECOMES THE OWNER. Tell the user to open it and finish setup as soon as it is up, and not to share the URL until they have.`);
526
+ }
527
+ if (template.loginNote) {
528
+ lines.push(template.loginNote);
174
529
  }
175
530
  return {
176
531
  content: [
@@ -200,5 +555,141 @@ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_po
200
555
  }
201
556
  return { content: [{ type: "text", text: "Service deletion queued." }] };
202
557
  });
558
+ server.tool("request_project_container_logs", `Ask the project's host to capture container logs for one service or database, then read them with \`get_project_container_logs\`. THIS DOES NOT RETURN LOGS: it dispatches a fetch to the host agent and returns a \`pending\` record, because the round-trip is too slow to block a request on.
559
+
560
+ Reach for this when a service or database is \`active\` but misbehaving. This is the container's own stdout/stderr, which is where a crash loop, a failed migration or a bad env var actually shows up - the platform's own status fields will only tell you it is unhealthy, not why.
561
+
562
+ Read-only: the backend runs an allow-listed \`docker logs\` scoped to this resource's containers. There is no start/stop/restart/exec anywhere on this surface.`, {
563
+ ...inspectArgs,
564
+ lines: z.number().int().min(1).max(INSPECT_LINES_MAX).optional()
565
+ .describe(`Tail length (default ${INSPECT_LINES_DEFAULT}, max ${INSPECT_LINES_MAX}; clamped server-side)`),
566
+ }, async ({ project_pid, resource_type, resource_pid, lines }) => {
567
+ if (!hasOrg())
568
+ return { content: [{ type: "text", text: NO_ORG }] };
569
+ const body = {};
570
+ if (lines !== undefined)
571
+ body.lines = lines;
572
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "logs"), {
573
+ method: "POST",
574
+ body,
575
+ toolName: "request_project_container_logs",
576
+ });
577
+ if (!result.success) {
578
+ return { content: [{ type: "text", text: `Failed to request container logs (${result.statusCode}): ${result.message}` }] };
579
+ }
580
+ return {
581
+ content: [
582
+ { type: "text", text: "Log fetch dispatched (`pending`). Call `get_project_container_logs` with the same ids to read it - give the agent a second or two first." },
583
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
584
+ ],
585
+ };
586
+ });
587
+ server.tool("get_project_container_logs", `Read container logs previously requested with \`request_project_container_logs\`. ${FETCH_POLL_NOTE}`, inspectArgs, async ({ project_pid, resource_type, resource_pid }) => {
588
+ if (!hasOrg())
589
+ return { content: [{ type: "text", text: NO_ORG }] };
590
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "logs"), {
591
+ toolName: "get_project_container_logs",
592
+ });
593
+ if (!result.success) {
594
+ return { content: [{ type: "text", text: `Failed to read container logs (${result.statusCode}): ${result.message}` }] };
595
+ }
596
+ return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
597
+ });
598
+ server.tool("request_project_container_stats", `Ask the project's host for a live resource snapshot of one service's or database's containers - \`docker stats\`: CPU, memory, network and block IO. Dispatches a fetch and returns a \`pending\` record; read it with \`get_project_container_stats\`.
599
+
600
+ THIS IS THE TOOL FOR "why is my project server full?". A project is ONE VM shared by every service and database in it, it cannot be resized, and the capacity guard refuses new children once it fills up. Identifying which container is eating the memory is the only actionable answer - the alternative is telling the user to build a new project.`, inspectArgs, async ({ project_pid, resource_type, resource_pid }) => {
601
+ if (!hasOrg())
602
+ return { content: [{ type: "text", text: NO_ORG }] };
603
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "stats"), {
604
+ method: "POST",
605
+ toolName: "request_project_container_stats",
606
+ });
607
+ if (!result.success) {
608
+ return { content: [{ type: "text", text: `Failed to request container stats (${result.statusCode}): ${result.message}` }] };
609
+ }
610
+ return {
611
+ content: [
612
+ { type: "text", text: "Stats fetch dispatched (`pending`). Call `get_project_container_stats` with the same ids to read it." },
613
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
614
+ ],
615
+ };
616
+ });
617
+ server.tool("get_project_container_stats", `Read the resource snapshot previously requested with \`request_project_container_stats\`. ${FETCH_POLL_NOTE}`, inspectArgs, async ({ project_pid, resource_type, resource_pid }) => {
618
+ if (!hasOrg())
619
+ return { content: [{ type: "text", text: NO_ORG }] };
620
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "stats"), {
621
+ toolName: "get_project_container_stats",
622
+ });
623
+ if (!result.success) {
624
+ return { content: [{ type: "text", text: `Failed to read container stats (${result.statusCode}): ${result.message}` }] };
625
+ }
626
+ return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
627
+ });
628
+ server.tool("set_project_service_custom_domain", `Point a custom domain at a project service's web UI, replacing its platform-generated hostname. Every one of the 26 templates exposes a web component, so this works for all of them.
629
+
630
+ DNS MUST ALREADY BE POINTING HERE BEFORE YOU CALL THIS. The backend resolves the domain and refuses if it does not already answer with the project's public IP - deliberately, because applying a domain before DNS lands burns Let's Encrypt rate limits on certificate requests that are guaranteed to fail. The correct order is:
631
+ 1. \`get_project\` for the project's \`public_ip\`.
632
+ 2. Have the user create a DNS **A record** for the domain pointing at that IP (a CNAME chain resolving to it also passes).
633
+ 3. Wait for propagation.
634
+ 4. Call this. A 422 naming the IP means step 2 or 3 is not done - relay it and retry later, do not loop.
635
+
636
+ Pass a BARE HOSTNAME you own: no scheme, no path, and not a platform-generated suffix. The service must already be provisioned (\`uuid\` present) - a 422 says it is not ready yet.
637
+
638
+ Templates whose containers bake their own public URL from the domain (Twenty's SERVER_URL, n8n's WEBHOOK_URL) re-derive it on the redeploy this triggers, so there is no env var for you to fix afterwards. Routing and TLS are verified after the call returns - re-read \`get_project_service\` to see the domain's status settle.`, {
639
+ project_pid: z.string().min(1),
640
+ service_pid: z.string().min(1),
641
+ domain: z.string().min(1).describe("Bare hostname, e.g. crm.example.com - no https://, no trailing path"),
642
+ }, async ({ project_pid, service_pid, domain }) => {
643
+ if (!hasOrg())
644
+ return { content: [{ type: "text", text: NO_ORG }] };
645
+ const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${service_pid}/custom_domain`, {
646
+ method: "POST",
647
+ body: { domain },
648
+ toolName: "set_project_service_custom_domain",
649
+ });
650
+ if (!result.success) {
651
+ const dnsHint = result.statusCode === 422
652
+ ? " If the message names an IP, the domain is not resolving to the project server yet: create the A record, wait for propagation, then retry."
653
+ : "";
654
+ return { content: [{ type: "text", text: `Failed to set custom domain (${result.statusCode}): ${result.message}.${dnsHint}` }] };
655
+ }
656
+ return {
657
+ content: [
658
+ { type: "text", text: `\`${domain}\` applied. The platform is now verifying routing and issuing TLS, which is not instant - re-read \`get_project_service\` to watch the domain status settle rather than assuming it is live.` },
659
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
660
+ ],
661
+ };
662
+ });
663
+ server.tool("remove_project_service_custom_domain", `Remove a project service's custom domain and re-pin its platform-generated hostname. A 422 means no custom domain is set on that service.
664
+
665
+ The service redeploys so its containers pick the platform URL back up, which means a brief interruption. Anything referencing the custom domain - bookmarks, webhooks registered with third parties, OAuth callback URLs - stops working, so say so before doing it.`, {
666
+ project_pid: z.string().min(1),
667
+ service_pid: z.string().min(1),
668
+ confirm: z.boolean().describe("Must be true. The service redeploys and anything pointing at the custom domain - webhooks, OAuth callbacks - breaks."),
669
+ }, async ({ project_pid, service_pid, confirm }) => {
670
+ if (!hasOrg())
671
+ return { content: [{ type: "text", text: NO_ORG }] };
672
+ if (!confirm) {
673
+ return {
674
+ content: [{
675
+ type: "text",
676
+ text: `About to remove the custom domain from service ${service_pid}. It goes back to its platform hostname and redeploys (brief interruption), and any webhook or OAuth callback registered against the custom domain will break.\n\nCall again with confirm=true.`,
677
+ }],
678
+ };
679
+ }
680
+ const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${service_pid}/custom_domain`, {
681
+ method: "DELETE",
682
+ toolName: "remove_project_service_custom_domain",
683
+ });
684
+ if (!result.success) {
685
+ return { content: [{ type: "text", text: `Failed to remove custom domain (${result.statusCode}): ${result.message}` }] };
686
+ }
687
+ return {
688
+ content: [
689
+ { type: "text", text: "Custom domain removed and the platform hostname restored. Re-read `get_project_service` for the current `public_url`." },
690
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
691
+ ],
692
+ };
693
+ });
203
694
  }
204
695
  //# sourceMappingURL=project-services.js.map