@selfhost.dev/mcp-server 0.9.1 → 0.11.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 +122 -37
  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 +853 -71
  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
@@ -1,16 +1,26 @@
1
+ import { isIP } from "node:net";
1
2
  import { z } from "zod";
2
3
  import { apiRequest } from "../client.js";
3
4
  import { session } from "../session.js";
4
5
  /**
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`.
6
+ * Project services (CoolifyService) - one-click application templates deployed into a project.
7
+ * THIRTY-SIX templates are live, resolved backend-side from `ServiceProvisioning::Strategy.all`
8
+ * (one strategy file per template, auto-discovered). This module shipped knowing four of them
9
+ * until the 2026-09 sync, which meant an agent could only reach a sixth of the catalogue; the
10
+ * 2026-09-25 sync added the ten the backend gained after it (Mysterium, Mysterium VPN Gateway,
11
+ * URnetwork, Nginx, WireGuard, code-server, OpenSSH server, JupyterLab, Jupyter Data Science,
12
+ * Laravel). The backend's `compose` strategy is bring-your-own Docker Compose, not a template,
13
+ * and has no tool here.
8
14
  *
9
15
  * Endpoints:
10
16
  * - GET /api/v1/platform/projects/:pid/services
11
17
  * - GET /api/v1/platform/projects/:pid/services/:service_pid (the ONLY source of credentials)
12
18
  * - POST /api/v1/platform/projects/:pid/services/:template_type (202 - async provisioning)
13
19
  * - DELETE /api/v1/platform/projects/:pid/services/:service_pid (202; body {name} must match)
20
+ * - POST /api/v1/platform/projects/:pid/services/:pid/custom_domain
21
+ * - DELETE /api/v1/platform/projects/:pid/services/:pid/custom_domain
22
+ * - POST/GET /api/v1/platform/projects/:pid/services/:pid/logs (async request-then-read)
23
+ * - POST/GET /api/v1/platform/projects/:pid/services/:pid/stats (async request-then-read)
14
24
  *
15
25
  * The project must be `active` before creating a service (409 otherwise).
16
26
  *
@@ -20,50 +30,540 @@ import { session } from "../session.js";
20
30
  *
21
31
  * The backend ships templates ahead of its clients, so `list_project_services` can
22
32
  * 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.
33
+ * rather than guessing - list, delete, logs, stats and custom domains work on any type.
34
+ *
35
+ * FIVE CREDENTIAL SHAPES, and getting this wrong is the main failure mode here:
36
+ * - `dashboardLogin` - the create takes `dashboard_username` + `dashboard_password`, and they
37
+ * become the app's admin login. The username's accepted FORM varies:
38
+ * any string, a slug (`[a-zA-Z0-9_-]+`), an email address (Krayin signs
39
+ * in by email, so a bare `admin` creates an unusable account) or a Linux
40
+ * login name (OpenSSH server). WireGuard and OpenSSH default the username
41
+ * (`admin`, `dev`), so it is optional for those two only.
42
+ * - `fixedRootPassword` - password only; the app owns the username (GitLab is always `root`,
43
+ * Elasticsearch `elastic`, Mysterium `myst`) or has none (code-server,
44
+ * JupyterLab, Jupyter Data Science, Odoo, AnythingLLM). Sending a
45
+ * username is refused here.
46
+ * - `none` - no admin env at all. THE FIRST VISITOR TO THE PUBLIC URL CLAIMS THE
47
+ * OWNER ACCOUNT. This is a real security consequence and every create
48
+ * of one of these says so unprompted.
49
+ * - `noLogin` - no login AND nothing to claim: a static site (Nginx), a fresh app with
50
+ * no accounts yet (Laravel) or an HTTP proxy with no UI (Mysterium VPN
51
+ * Gateway). Not the first-visitor warning.
52
+ * - `authCode` - a vendor token instead of a login (URnetwork's code from ur.io/app).
53
+ * It is a secret: never echo it back.
54
+ *
55
+ * THREE TEMPLATES SHARE THE SERVER'S NETWORK WITH OTHER PEOPLE OR SPEND MONEY OUTSIDE THE
56
+ * PLATFORM (Mysterium, Mysterium VPN Gateway, URnetwork), so their create demands
57
+ * `acknowledge_network_terms: true`, the same gate the console puts in front of them. Four
58
+ * templates have NO web component (Mysterium, the gateway, URnetwork, OpenSSH server): no
59
+ * public URL and no custom domain. Four are only reachable through a firewall rule the
60
+ * backend opens when `is_public` is true (Mysterium's panel, the gateway's proxy, WireGuard's
61
+ * UDP port, the OpenSSH port), so `is_public: false` is refused for them.
62
+ *
63
+ * CAPACITY IS A DECLARE-TIME REFUSAL, NOT A RUNTIME DEGRADATION. A project is ONE VM.
64
+ * `heavy` templates (many containers, large pulls, first-boot migrations) are held to
65
+ * stricter free-memory limits than light ones, some carry a hard minimum on the host's TOTAL
66
+ * RAM, at most 5 children can exist per project, and Stalwart is a singleton because its mail
67
+ * ports are fixed on the host. A project server cannot be resized, so "out of RAM" means a
68
+ * NEW project - which is why the numbers are surfaced before the call rather than after.
24
69
  */
25
70
  const PROJECTS_BASE = "/api/v1/platform/projects";
26
71
  /** 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
- */
72
+ const TEMPLATE_TYPES = [
73
+ "supabase", "n8n", "n8n-with-postgresql", "twenty",
74
+ "wordpress", "gitlab", "elasticsearch", "elasticsearch-with-kibana",
75
+ "immich", "nextcloud", "jenkins", "grafana",
76
+ "grafana-with-postgresql", "strapi", "metabase", "anythingllm",
77
+ "stalwart", "chatwoot", "mautic", "minio",
78
+ "open-webui-with-ollama", "litellm", "odoo", "paperless",
79
+ "espocrm", "krayin", "mysterium", "mysterium-gateway",
80
+ "urnetwork", "nginx", "wireguard", "code-server",
81
+ "openssh-server", "jupyterlab", "jupyter-datascience", "laravel",
82
+ ];
33
83
  const TEMPLATES = {
34
- supabase: {
84
+ "supabase": {
35
85
  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",
86
+ tagline: "Postgres, Auth, storage and auto-generated APIs.",
87
+ recommendedRamGb: 8,
88
+ heavy: true,
89
+ creds: "dashboardLogin",
90
+ usernameShape: "any",
91
+ minPasswordLength: 8,
92
+ bootMinutes: 7,
93
+ },
94
+ "n8n": {
95
+ label: "n8n",
96
+ tagline: "Workflow automation with hundreds of integrations.",
97
+ recommendedRamGb: 4,
98
+ heavy: false,
99
+ creds: "none",
100
+ bootMinutes: 4,
47
101
  },
48
102
  "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",
103
+ label: "n8n (Postgres)",
104
+ tagline: "The same n8n, backed by its own Postgres instead of SQLite.",
105
+ recommendedRamGb: 4,
106
+ heavy: false,
107
+ creds: "none",
108
+ bootMinutes: 5,
54
109
  },
55
- twenty: {
110
+ "twenty": {
56
111
  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",
112
+ tagline: "Open-source CRM for contacts, deals and pipelines.",
113
+ recommendedRamGb: 8,
114
+ heavy: true,
115
+ creds: "none",
116
+ bootMinutes: 14,
117
+ },
118
+ "wordpress": {
119
+ label: "WordPress",
120
+ tagline: "MariaDB-backed WordPress for publishing and sites.",
121
+ recommendedRamGb: 4,
122
+ heavy: false,
123
+ creds: "none",
124
+ bootMinutes: 6,
125
+ },
126
+ "gitlab": {
127
+ label: "GitLab CE",
128
+ tagline: "Repositories, reviews and CI.",
129
+ recommendedRamGb: 8,
130
+ heavy: true,
131
+ creds: "fixedRootPassword",
132
+ fixedUsername: "root",
133
+ minPasswordLength: 12,
134
+ hostFloorGb: 8,
135
+ bootMinutes: 25,
136
+ loginNote: "GitLab ships with public sign-up disabled.",
137
+ },
138
+ "elasticsearch": {
139
+ label: "Elasticsearch",
140
+ tagline: "Search and analytics over your own documents.",
141
+ recommendedRamGb: 8,
142
+ heavy: true,
143
+ creds: "fixedRootPassword",
144
+ fixedUsername: "elastic",
145
+ minPasswordLength: 8,
146
+ bootMinutes: 10,
147
+ loginNote: "This password is the only lock on the endpoint, so keep it somewhere safe.",
148
+ },
149
+ "elasticsearch-with-kibana": {
150
+ label: "Elasticsearch + Kibana",
151
+ tagline: "Search and analytics with a UI in front of it.",
152
+ recommendedRamGb: 8,
153
+ heavy: true,
154
+ creds: "fixedRootPassword",
155
+ fixedUsername: "elastic",
156
+ minPasswordLength: 8,
157
+ hostFloorGb: 4,
158
+ bootMinutes: 8,
159
+ 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.",
160
+ },
161
+ "immich": {
162
+ label: "Immich",
163
+ tagline: "Photo and video backup for your phone.",
164
+ recommendedRamGb: 8,
165
+ heavy: true,
166
+ creds: "none",
167
+ bootMinutes: 20,
168
+ },
169
+ "nextcloud": {
170
+ label: "Nextcloud",
171
+ tagline: "Files, calendar and contacts you host yourself.",
172
+ recommendedRamGb: 8,
173
+ heavy: true,
174
+ creds: "dashboardLogin",
175
+ usernameShape: "any",
176
+ minPasswordLength: 8,
177
+ bootMinutes: 15,
178
+ },
179
+ "jenkins": {
180
+ label: "Jenkins",
181
+ tagline: "The CI server for your builds.",
182
+ recommendedRamGb: 4,
183
+ heavy: true,
184
+ creds: "dashboardLogin",
185
+ usernameShape: "slug",
186
+ minPasswordLength: 8,
187
+ hostFloorGb: 2,
188
+ bootMinutes: 15,
189
+ loginNote: "No setup wizard and no unlock key to find: the admin is seeded before the first boot.",
190
+ },
191
+ "grafana": {
192
+ label: "Grafana",
193
+ tagline: "Dashboards and alerts over your metrics.",
194
+ recommendedRamGb: 4,
195
+ heavy: false,
196
+ creds: "dashboardLogin",
197
+ usernameShape: "slug",
198
+ minPasswordLength: 8,
199
+ bootMinutes: 5,
200
+ loginNote: "It never boots on the well-known admin/admin default.",
201
+ },
202
+ "grafana-with-postgresql": {
203
+ label: "Grafana (Postgres)",
204
+ tagline: "The same Grafana, with its dashboards and alerts in a bundled Postgres.",
205
+ recommendedRamGb: 4,
206
+ heavy: false,
207
+ creds: "dashboardLogin",
208
+ usernameShape: "slug",
209
+ minPasswordLength: 8,
210
+ bootMinutes: 5,
211
+ loginNote: "It never boots on the well-known admin/admin default.",
212
+ },
213
+ "strapi": {
214
+ label: "Strapi",
215
+ tagline: "A headless CMS with an admin panel and a content API.",
216
+ recommendedRamGb: 4,
217
+ heavy: false,
218
+ creds: "none",
219
+ bootMinutes: 6,
220
+ },
221
+ "metabase": {
222
+ label: "Metabase",
223
+ tagline: "Dashboards and questions over your data.",
224
+ recommendedRamGb: 4,
225
+ heavy: true,
226
+ creds: "none",
227
+ hostFloorGb: 2,
228
+ bootMinutes: 7,
229
+ },
230
+ "anythingllm": {
231
+ label: "AnythingLLM",
232
+ tagline: "Chat over your own documents.",
233
+ recommendedRamGb: 4,
234
+ heavy: false,
235
+ creds: "fixedRootPassword",
236
+ minPasswordLength: 8,
237
+ bootMinutes: 6,
238
+ loginNote: "The unlock screen asks for this password ALONE, with no username. Without it AnythingLLM would boot open to anyone with the link.",
239
+ },
240
+ "stalwart": {
241
+ label: "Stalwart (mail server)",
242
+ tagline: "A full mail server, SMTP through JMAP.",
243
+ recommendedRamGb: 4,
244
+ heavy: false,
245
+ creds: "fixedRootPassword",
246
+ fixedUsername: "admin",
247
+ minPasswordLength: 8,
248
+ singleton: true,
249
+ bootMinutes: 4,
250
+ 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.",
251
+ },
252
+ "chatwoot": {
253
+ label: "Chatwoot",
254
+ tagline: "Live chat and a shared support inbox.",
255
+ recommendedRamGb: 8,
256
+ heavy: true,
257
+ creds: "none",
258
+ bootMinutes: 12,
259
+ },
260
+ "mautic": {
261
+ label: "Mautic",
262
+ tagline: "Marketing automation and campaigns.",
263
+ recommendedRamGb: 8,
264
+ heavy: true,
265
+ creds: "none",
266
+ bootMinutes: 8,
267
+ },
268
+ "minio": {
269
+ label: "MinIO",
270
+ tagline: "S3-compatible object storage.",
271
+ recommendedRamGb: 4,
272
+ heavy: false,
273
+ creds: "dashboardLogin",
274
+ usernameShape: "any",
275
+ minPasswordLength: 8,
276
+ bootMinutes: 3,
277
+ 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.",
278
+ },
279
+ "open-webui-with-ollama": {
280
+ label: "Open WebUI + Ollama",
281
+ tagline: "Chat with local models on your own box.",
282
+ recommendedRamGb: 8,
283
+ heavy: true,
284
+ creds: "none",
285
+ bootMinutes: 11,
286
+ loginNote: "Project servers are CPU ONLY, so stay with small models (1B-3B). Nothing ships with a model - you pull the first one yourself.",
287
+ },
288
+ "litellm": {
289
+ label: "LiteLLM",
290
+ tagline: "One OpenAI-compatible endpoint in front of every model provider.",
291
+ recommendedRamGb: 4,
292
+ heavy: false,
293
+ creds: "fixedRootPassword",
294
+ fixedUsername: "admin",
295
+ minPasswordLength: 8,
296
+ bootMinutes: 6,
297
+ 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.",
298
+ },
299
+ "odoo": {
300
+ label: "Odoo",
301
+ tagline: "The open-source ERP and business suite.",
302
+ recommendedRamGb: 8,
303
+ heavy: true,
304
+ creds: "fixedRootPassword",
305
+ minPasswordLength: 8,
306
+ bootMinutes: 8,
307
+ 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.",
308
+ },
309
+ "paperless": {
310
+ label: "Paperless-ngx",
311
+ tagline: "A searchable, OCR-indexed archive of your documents.",
312
+ recommendedRamGb: 8,
313
+ heavy: true,
314
+ creds: "dashboardLogin",
315
+ usernameShape: "any",
316
+ minPasswordLength: 8,
317
+ bootMinutes: 8,
318
+ },
319
+ "espocrm": {
320
+ label: "EspoCRM",
321
+ tagline: "Contacts, accounts and deals in a CRM you own.",
322
+ recommendedRamGb: 8,
323
+ heavy: true,
324
+ creds: "dashboardLogin",
325
+ usernameShape: "slug",
326
+ minPasswordLength: 8,
327
+ bootMinutes: 12,
328
+ loginNote: "No install wizard: the admin is seeded before the first boot, so it never comes up on the documented admin/password default.",
329
+ },
330
+ "krayin": {
331
+ label: "Krayin (CRM)",
332
+ tagline: "Leads, quotes and deals in an open-source CRM you own.",
333
+ recommendedRamGb: 8,
334
+ heavy: true,
335
+ creds: "dashboardLogin",
336
+ fixedUsername: "",
337
+ usernameShape: "email",
338
+ minPasswordLength: 8,
339
+ unsupportedArchitectures: ["arm"],
340
+ bootMinutes: 15,
341
+ 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.",
342
+ },
343
+ "mysterium": {
344
+ label: "Mysterium (VPN node)",
345
+ tagline: "A VPN node that shares the server's bandwidth on the Mysterium network, and earns for it.",
346
+ recommendedRamGb: 4,
347
+ heavy: false,
348
+ creds: "fixedRootPassword",
349
+ fixedUsername: "myst",
350
+ minPasswordLength: 12,
351
+ maxPasswordBytes: 72,
352
+ passwordForbids: "nul",
353
+ singleton: true,
354
+ noWebComponent: true,
355
+ requiresPublic: "its node panel (HTTP on port 4449) is reachable only through a firewall rule the backend opens for a public service, restricted to `panel_source` - that restriction, not `is_public: false`, is what keeps it private",
356
+ networkTerms: "a Mysterium node shares this server's bandwidth with strangers on the Mysterium network: their traffic leaves from the project's IP address, under Mysterium's terms",
357
+ bootMinutes: 4,
358
+ loginNote: "The node panel is plain HTTP on port 4449 of the project IP (`public_url` from get_project_service), reachable ONLY from `panel_source`. Sign in as `myst` with the password; on first boot the password can take a minute to apply. Then claim the node on mystnodes.com. A running container does not mean the node is claimed or earning. The allowed IPs can be changed later in the console (Settings, Panel access); this MCP has no tool for that yet.",
359
+ },
360
+ "mysterium-gateway": {
361
+ label: "Mysterium VPN Gateway",
362
+ tagline: "An HTTP proxy that routes your apps through the Mysterium VPN network, paid for in MYST.",
363
+ recommendedRamGb: 4,
364
+ heavy: false,
365
+ creds: "noLogin",
366
+ singleton: true,
367
+ noWebComponent: true,
368
+ requiresPublic: "its HTTP proxy (port 3128) is reachable only through a firewall rule the backend opens for a public service, restricted to `proxy_source` - that restriction, not `is_public: false`, is what keeps it from being an open proxy",
369
+ networkTerms: "the gateway consumes the Mysterium network and is paid for in MYST per GB from a funded identity, separately from the project server, under Mysterium's terms",
370
+ bootMinutes: 10,
371
+ loginNote: "No login and no web UI: it is an HTTP proxy on port 3128 (`proxy_endpoint` from get_project_service), reachable ONLY from `proxy_source`. It carries NO traffic until its identity is registered and funded with MYST: request the `connector` container's logs (request_project_container_logs) for the identity address to fund, the registration state and the exit IP. Then set HTTP_PROXY / HTTPS_PROXY in the app to `proxy_endpoint`.",
372
+ },
373
+ "urnetwork": {
374
+ label: "URnetwork (bandwidth node)",
375
+ tagline: "A provider node that shares the server's bandwidth with the URnetwork network.",
376
+ recommendedRamGb: 4,
377
+ heavy: false,
378
+ creds: "authCode",
379
+ noWebComponent: true,
380
+ networkTerms: "a URnetwork provider shares this server's bandwidth with the URnetwork network: other people's traffic leaves from the project's IP address, under URnetwork's terms",
381
+ bootMinutes: 10,
382
+ loginNote: "No UI and no ports: the node dials out to URnetwork. Manage it (earnings, pausing) at ur.io/app. A running container does NOT prove the auth code was accepted: check the account, or the container logs for `init proxy auth failed`. Auth codes expire; a new one can replace it without recreating the node from the console (Settings, URnetwork account); this MCP has no tool for that yet.",
383
+ },
384
+ "nginx": {
385
+ label: "Nginx (static site)",
386
+ tagline: "A static website served over HTTPS, with custom domains.",
387
+ recommendedRamGb: 4,
388
+ heavy: false,
389
+ creds: "noLogin",
390
+ bootMinutes: 10,
391
+ loginNote: "No login: it serves a welcome page until you upload a site. get_project_service returns `credentials.content_path` (for example `~/nginx-def123`); install a project SSH key (add_project_ssh_key), connect as `shell` to the project IP, and put the site's files there. They are served immediately, with no sudo or Docker. The directory can outlive a deleted service, and a recreated service gets a new one.",
392
+ },
393
+ "wireguard": {
394
+ label: "WireGuard (wg-easy)",
395
+ tagline: "A private WireGuard VPN with an HTTPS admin panel.",
396
+ recommendedRamGb: 4,
397
+ heavy: false,
398
+ creds: "dashboardLogin",
399
+ usernameShape: "any",
400
+ defaultUsername: "admin",
401
+ usernameMaxLength: 64,
402
+ minPasswordLength: 12,
403
+ singleton: true,
404
+ requiresPublic: "VPN clients reach it on UDP 51820 only through a firewall rule the backend opens for a public service; the admin panel is password-protected HTTPS",
405
+ bootMinutes: 10,
406
+ loginNote: "Sign in to the admin panel over HTTPS with this login, add VPN peers there, and import their configurations into WireGuard clients. Clients connect to `wireguard_endpoint` (the project IP, UDP 51820). A running container does not prove a working tunnel.",
407
+ },
408
+ "code-server": {
409
+ label: "code-server",
410
+ tagline: "VS Code in the browser, with a terminal and your extensions.",
411
+ recommendedRamGb: 4,
412
+ heavy: false,
413
+ creds: "fixedRootPassword",
414
+ fixedUsername: "",
415
+ minPasswordLength: 12,
416
+ bootMinutes: 10,
417
+ loginNote: "There is no username: the sign-in screen asks for the password alone, and the same password unlocks sudo in the terminal. Settings, extensions and /config/workspace persist across restarts.",
418
+ },
419
+ "openssh-server": {
420
+ label: "OpenSSH server",
421
+ tagline: "A Linux container you reach over SSH, with sudo and a home that persists.",
422
+ recommendedRamGb: 4,
423
+ heavy: false,
424
+ creds: "dashboardLogin",
425
+ usernameShape: "unix",
426
+ defaultUsername: "dev",
427
+ minPasswordLength: 12,
428
+ passwordForbids: "control",
429
+ noWebComponent: true,
430
+ requiresPublic: "its SSH port (`public_port`, default 2222) is reachable only through a firewall rule the backend opens for a public service",
431
+ publicPortNote: "the host port sshd listens on (default 2222); each SSH server in a project needs its own",
432
+ bootMinutes: 10,
433
+ loginNote: "Connect with `credentials.ssh_command` from get_project_service (`ssh -p <public_port> <user>@<project IP>`). The SSH port is open to the whole internet, which is why the password is required even with a key; sudo uses the same password. It is its own SSH server in a container, separate from the project server's `shell` access, and everything under /config persists across restarts.",
434
+ },
435
+ "jupyterlab": {
436
+ label: "JupyterLab",
437
+ tagline: "Python notebooks with numpy, pandas and scikit-learn installed.",
438
+ recommendedRamGb: 8,
439
+ heavy: true,
440
+ creds: "fixedRootPassword",
441
+ fixedUsername: "",
442
+ minPasswordLength: 12,
443
+ bootMinutes: 15,
444
+ loginNote: "There is no username: paste the password into the `Password or token` box on the sign-in page. The whole home folder (notebooks, settings, `pip install --user` packages) persists across restarts.",
445
+ },
446
+ "jupyter-datascience": {
447
+ label: "Jupyter Data Science",
448
+ tagline: "JupyterLab with Python, R and Julia notebooks.",
449
+ recommendedRamGb: 8,
450
+ heavy: true,
451
+ creds: "fixedRootPassword",
452
+ fixedUsername: "",
453
+ minPasswordLength: 12,
454
+ bootMinutes: 20,
455
+ loginNote: "There is no username: paste the password into the `Password or token` box on the sign-in page. It is JupyterLab with the R (tidyverse) and Julia kernels added, a ~2.7 GB image; `jupyterlab` is the lighter Python-only choice. The whole home folder persists across restarts.",
456
+ },
457
+ "laravel": {
458
+ label: "Laravel",
459
+ tagline: "A fresh Laravel app on its own MySQL database.",
460
+ recommendedRamGb: 4,
461
+ heavy: false,
462
+ creds: "noLogin",
463
+ bootMinutes: 15,
464
+ loginNote: "No login to set: the URL serves the Laravel welcome page on its own MySQL database, and the app built on it defines its own sign-in. First boot runs `composer create-project`, so the URL can take several minutes to serve.",
465
+ },
466
+ };
467
+ /** `RESERVED_USERNAMES` on the OpenSSH server's validator: root and the image's own accounts. */
468
+ const UNIX_RESERVED_USERNAMES = new Set(["root", "abc", "daemon", "bin", "sys", "nobody", "sshd"]);
469
+ /** Username validation per shape, mirroring the backend validators. */
470
+ const USERNAME_SHAPE_RULES = {
471
+ any: { test: (v) => v.length > 0, hint: "any non-empty string" },
472
+ slug: { test: (v) => /^[a-zA-Z0-9_-]+$/.test(v), hint: "letters, digits, hyphen and underscore only" },
473
+ email: { test: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v), hint: "an email address - this app signs in by email" },
474
+ unix: {
475
+ test: (v) => /^[a-z_][a-z0-9_-]{0,31}$/.test(v) && !UNIX_RESERVED_USERNAMES.has(v),
476
+ hint: "a lowercase Linux login name (letters, digits, - and _, starting with a letter or _, up to 32) and never root or a system account",
61
477
  },
62
478
  };
479
+ /** The key algorithms the OpenSSH server's `PUBLIC_KEY_FORMAT` accepts. */
480
+ const OPENSSH_KEY_ALGORITHMS = new Set([
481
+ "ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384",
482
+ "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com",
483
+ ]);
484
+ /** Gateway service types (`MysteriumGatewayServiceValidator::SERVICE_TYPES`), default first. */
485
+ const GATEWAY_SERVICE_TYPES = ["wireguard", "scraping", "data_transfer", "quic"];
486
+ /** URnetwork's `AUTH_CODE_FORMAT`, with its 20-character floor. */
487
+ const URNETWORK_AUTH_CODE = /^[A-Za-z0-9_\-=+/.]{20,}$/;
488
+ /**
489
+ * One IP or CIDR the backend's `IPAddr.new` would accept, never an everything-range: the
490
+ * console refuses a world-open panel or proxy at create, and so does this. Returns an error
491
+ * sentence, or null when the value is fine.
492
+ */
493
+ function sourceError(value, field) {
494
+ const v = value.trim();
495
+ const [address, prefix, extra] = v.split("/");
496
+ const family = isIP(address ?? "");
497
+ const bits = family === 4 ? 32 : 128;
498
+ if (!family || extra !== undefined || (prefix !== undefined && !/^\d{1,3}$/.test(prefix))
499
+ || (prefix !== undefined && Number(prefix) > bits)) {
500
+ return `${field} \`${v}\` is not an IP address or CIDR range (for example 203.0.113.7 or 203.0.113.0/24).`;
501
+ }
502
+ if (prefix !== undefined && Number(prefix) === 0) {
503
+ return `${field} \`${v}\` is every address on the internet. Pass the IP (or range) that should reach it; opening it to everyone is a deliberate choice made later in the console, never at create.`;
504
+ }
505
+ return null;
506
+ }
507
+ /** Rendered into the create description so the catalogue cannot drift from the code. */
508
+ const TEMPLATE_CATALOGUE_TEXT = TEMPLATE_TYPES.map((t) => {
509
+ const f = TEMPLATES[t];
510
+ const username = f.defaultUsername
511
+ ? `optional username (default \`${f.defaultUsername}\`; ${USERNAME_SHAPE_RULES[f.usernameShape ?? "any"].hint})`
512
+ : `username (${USERNAME_SHAPE_RULES[f.usernameShape ?? "any"].hint})`;
513
+ const password = `password${f.minPasswordLength && f.minPasswordLength > 8 ? ` (${f.minPasswordLength}+)` : ""}`;
514
+ const creds = f.creds === "dashboardLogin"
515
+ ? `${username} + ${password}`
516
+ : f.creds === "fixedRootPassword"
517
+ ? `${password} only${f.fixedUsername ? `, user is always \`${f.fixedUsername}\`` : ", no username"}`
518
+ : f.creds === "noLogin"
519
+ ? "no login and nothing to claim"
520
+ : f.creds === "authCode"
521
+ ? "an `auth_code` from ur.io/app"
522
+ : "NO admin creds - first visitor owns it";
523
+ const flags = [
524
+ f.heavy ? "heavy" : null,
525
+ f.hostFloorGb ? `needs a ${f.hostFloorGb} GB+ host` : null,
526
+ f.singleton ? "one per project" : null,
527
+ f.unsupportedArchitectures?.length ? `no ${f.unsupportedArchitectures.join("/")} hosts` : null,
528
+ f.noWebComponent ? "no URL, no custom domain" : null,
529
+ f.requiresPublic ? "is_public must be true" : null,
530
+ f.networkTerms ? "needs acknowledge_network_terms" : null,
531
+ ].filter(Boolean).join(", ");
532
+ return `- \`${t}\` (${f.label}) - ${f.tagline} ${creds}. ~${f.bootMinutes} min, ${f.recommendedRamGb} GB recommended${flags ? ` [${flags}]` : ""}`;
533
+ }).join("\n");
63
534
  const NO_ORG = "No active organization. Call `list_organizations` then `select_organization` first.";
64
535
  function hasOrg() {
65
536
  return session.getActiveOrgId() !== null;
66
537
  }
538
+ /**
539
+ * Container inspection covers project SERVICES and project DATABASES with one contract - both
540
+ * are Docker containers on the project's single box, and the backend shares one
541
+ * `ContainerInspectable` concern for them. `resource_type` picks the collection rather than
542
+ * this module carrying two near-identical copies of four tools.
543
+ *
544
+ * REQUEST-THEN-READ, like the AWS instance-logs surface: POST dispatches a fetch to the host
545
+ * agent and returns a `pending` record; GET settles pending fetches against their tasks and
546
+ * returns the recent ones (newest first, up to 20).
547
+ *
548
+ * READ-ONLY BY CONSTRUCTION. The backend emits only allow-listed `docker ps / logs / stats /
549
+ * inspect` commands through the agent - no shell, no Docker socket, and the docker filter is
550
+ * scoped to this resource's containers. There is deliberately NO start / stop / restart /
551
+ * remove / exec surface anywhere on the platform, so do not go looking for one or promise it.
552
+ */
553
+ const INSPECT_LINES_DEFAULT = 200;
554
+ const INSPECT_LINES_MAX = 1000;
555
+ const INSPECT_RESOURCE_TYPES = ["service", "database"];
556
+ 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.";
557
+ function inspectPath(projectPid, resourceType, resourcePid, operation) {
558
+ const collection = resourceType === "service" ? "services" : "databases";
559
+ return `${PROJECTS_BASE}/${projectPid}/${collection}/${resourcePid}/${operation}`;
560
+ }
561
+ const inspectArgs = {
562
+ project_pid: z.string().min(1),
563
+ resource_type: z.enum(INSPECT_RESOURCE_TYPES)
564
+ .describe("`service` for a one-click template, `database` for a project Postgres/Redis/MySQL/MongoDB. Picks which collection the pid is looked up in."),
565
+ resource_pid: z.string().min(1).describe("The service or database pid, as returned by list_project_services / list_project_databases"),
566
+ };
67
567
  export function registerProjectServiceTools(server) {
68
568
  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
569
 
@@ -86,9 +586,19 @@ A \`template_type\` you do not recognise means the backend shipped a template ne
86
586
  });
87
587
  server.tool("get_project_service", `Get one deployed service, INCLUDING ITS CREDENTIALS. This is the only endpoint that returns them - \`list_project_services\` deliberately does not.
88
588
 
89
- Credentials appear once the service reaches \`active\`; before that the block is simply absent and the right answer is "not ready yet", not an error. What you get depends on the template: Supabase returns the dashboard username and password plus \`public_url\` (and the anon key when present); n8n, n8n-with-postgresql and Twenty return \`public_url\` only, because their first admin is created through the app's own first-run screen rather than from env.
589
+ The block can be absent or partial until the service reaches \`active\`; then the right answer is "not ready yet", not an error. What it holds depends on the template:
590
+ - Login templates: the username and password plus \`public_url\`; Supabase adds the anon key.
591
+ - First-visitor templates (n8n, n8n-with-postgresql, Twenty, WordPress, Immich, Strapi, Metabase, Chatwoot, Mautic, Open WebUI + Ollama): \`public_url\` only, because their first admin is created through the app's own first-run screen rather than from env.
592
+ - code-server, JupyterLab and Jupyter Data Science: \`password\` and \`public_url\`, and no username.
593
+ - Laravel: \`public_url\` only. A fresh app with no accounts, so there is nothing to claim and no warning to give.
594
+ - OpenSSH server: \`host\`, \`port\`, \`username\`, \`password\` and a ready-made \`ssh_command\`; no URL.
595
+ - WireGuard: the admin \`username\` and \`password\`, the HTTPS \`public_url\`, and \`wireguard_endpoint\` (the project IP, UDP 51820) for VPN clients.
596
+ - Mysterium: \`public_url\` (its HTTP panel on port 4449), the fixed username \`myst\`, the password, and the \`panel_sources\` allowed to open it.
597
+ - Mysterium VPN Gateway: \`proxy_endpoint\`, \`proxy_sources\`, \`country\` and \`service_type\`; no login.
598
+ - Nginx: \`content_path\`, the directory to upload the site to over SSH.
599
+ - URnetwork: a \`hint\` only; the node is managed at ur.io/app.
90
600
 
91
- HAND THE URL OVER WITH THE WARNING ATTACHED for n8n and Twenty: the account is claimed by whoever opens it first, so an unattended public URL is an open door until the user completes setup.
601
+ HAND THE URL OVER WITH THE WARNING ATTACHED for the first-visitor templates: the account is claimed by whoever opens it first, so an unattended public URL is an open door until the user completes setup.
92
602
 
93
603
  Call this after \`create_project_service\` reports \`active\`, and give the user the credentials - creating a service and not passing on how to log into it leaves the job half done.`, {
94
604
  project_pid: z.string().min(1),
@@ -104,59 +614,192 @@ Call this after \`create_project_service\` reports \`active\`, and give the user
104
614
  }
105
615
  return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
106
616
  });
107
- server.tool("create_project_service", `Deploy a one-click application template into a project. FOUR are available:
617
+ server.tool("create_project_service", `Deploy a one-click application template into a project. THIRTY-SIX are available:
618
+
619
+ ${TEMPLATE_CATALOGUE_TEXT}
620
+
621
+ CREDENTIALS DIFFER PER TEMPLATE AND THE WRONG SHAPE IS REFUSED HERE rather than sent and silently dropped:
622
+ - 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, a Linux login name for the OpenSSH server, anything for the rest. WireGuard and the OpenSSH server default the username (\`admin\`, \`dev\`), so it is optional for those two. The catalogue above says which.
623
+ - Templates taking a password only: pass \`dashboard_password\` alone. The app owns the username (GitLab is \`root\`, Elasticsearch \`elastic\`, Mysterium \`myst\`) or has none (code-server, JupyterLab, Jupyter Data Science). Passing a username is refused.
624
+ - Templates taking nothing: pass neither. "No login and nothing to claim" (Nginx, Laravel, the Mysterium VPN Gateway) is NOT the first-visitor case below.
625
+ - URnetwork takes an \`auth_code\` from ur.io/app instead of a login. It is a secret: never repeat it back.
108
626
 
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.
627
+ TEMPLATE-SPECIFIC FIELDS, each refused on any other template:
628
+ - Mysterium: \`panel_source\`, the IP or CIDR allowed to open its HTTP panel on 4449.
629
+ - Mysterium VPN Gateway: \`proxy_source\`, the IP or CIDR of the apps that will use the proxy on 3128; \`country\` (two-letter code, optional); \`service_type\` (wireguard, the default, or scraping, data_transfer, quic).
630
+ For both, an omitted source makes the backend use the IP this MCP's requests come from, which is the machine running this server and not necessarily where the user or their apps are. Pass it explicitly; \`list_project_ssh_keys\` shows the \`requester_ip\` the backend sees. A world range (\`/0\`) is refused.
631
+ - OpenSSH server: \`public_key\` (one OpenSSH public key line, optional) and \`password_access\` (default true; false only with a key, and the password still gates sudo). \`public_port\` is its SSH port, default 2222, and a second SSH server in the same project needs another one.
632
+ - Mysterium, the Mysterium VPN Gateway and URnetwork share the server's network with other people or spend MYST outside the platform, so each needs \`acknowledge_network_terms: true\`. Explain what they are agreeing to and ask; never set it on the user's behalf.
113
633
 
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.
634
+ 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
635
 
116
- \`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.
636
+ \`is_public\` is REQUIRED with no default: ASK THE USER. Most templates are web apps whose first-run setup happens at the public URL, so \`true\` is usually what they want, but do not assume it. Four are refused with \`false\` because the backend would never open the port they are reached on: Mysterium, the Mysterium VPN Gateway, WireGuard and the OpenSSH server (the first two stay private through their source IP lock, not through \`is_public\`).
637
+
638
+ 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, WireGuard, Mysterium and the Mysterium VPN Gateway are one-per-project (their ports are fixed on the host). 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.
117
639
 
118
640
  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
641
 
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).`, {
642
+ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_port\` already claimed by another database or service (a second OpenSSH server left on 2222 is the common one); the capacity guard refusing the template; a second Stalwart, WireGuard, Mysterium or Mysterium VPN Gateway; and Krayin on an ARM (\`cax*\`) host, whose image has no ARM manifest at any tag.`, {
121
643
  project_pid: z.string().min(1),
122
- template_type: z.enum(TEMPLATE_TYPES).describe("supabase | n8n | n8n-with-postgresql | twenty"),
644
+ template_type: z.enum(TEMPLATE_TYPES).describe("One of the 36 template slugs - see the catalogue in the description"),
123
645
  name: z.string().min(1).max(255).describe("Unique within the project"),
124
646
  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."),
127
- public_port: z.number().int().min(1024).max(65535).optional().describe("Optional. Must not collide with another database or service in this project."),
128
- }, async ({ project_pid, template_type, name, is_public, dashboard_username, dashboard_password, public_port }) => {
647
+ 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 / Linux login name / any) - see the catalogue. Optional for WireGuard (default admin) and the OpenSSH server (default dev). Refused for password-only and no-credential templates."),
648
+ dashboard_password: z.string().min(8).max(255).optional().describe("For templates that take any password. 8+ chars; 12+ for GitLab, Mysterium, WireGuard, code-server, the OpenSSH server, JupyterLab and Jupyter Data Science (see the catalogue). Refused for no-credential templates."),
649
+ public_port: z.number().int().min(1024).max(65535).optional().describe("Optional. Must not collide with another database or service in this project. For the OpenSSH server it is the SSH port (default 2222)."),
650
+ panel_source: z.string().min(1).max(64).optional().describe("Mysterium only: the IP or CIDR allowed to open its HTTP panel on 4449. Omitted, the backend uses the IP this MCP's requests come from."),
651
+ proxy_source: z.string().min(1).max(64).optional().describe("Mysterium VPN Gateway only: the IP or CIDR of the apps that will use the proxy on 3128. Omitted, the backend uses the IP this MCP's requests come from."),
652
+ country: z.string().length(2).optional().describe("Mysterium VPN Gateway only: a two-letter country code to exit from. Optional."),
653
+ service_type: z.enum(GATEWAY_SERVICE_TYPES).optional().describe("Mysterium VPN Gateway only: the Mysterium service to consume. Default wireguard."),
654
+ auth_code: z.string().min(1).max(512).optional().describe("URnetwork only, and required there: the auth code from ur.io/app (20+ characters, letters, digits and _-=+/. only). A secret: never repeat it back."),
655
+ public_key: z.string().min(1).max(16384).optional().describe("OpenSSH server only: one OpenSSH PUBLIC key line (ssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256/384/521 or their sk- variants). Never a private key."),
656
+ password_access: z.boolean().optional().describe("OpenSSH server only: false turns password sign-in off, which needs a public_key. Default true. The password still gates sudo."),
657
+ acknowledge_network_terms: z.boolean().optional().describe("Mysterium, Mysterium VPN Gateway and URnetwork only, and required true there: the user has agreed to what the catalogue says the template does with the server's network or their money. Ask; never set it on their behalf."),
658
+ }, async ({ project_pid, template_type, name, is_public, dashboard_username, dashboard_password, public_port, panel_source, proxy_source, country, service_type, auth_code, public_key, password_access, acknowledge_network_terms }) => {
129
659
  if (!hasOrg())
130
660
  return { content: [{ type: "text", text: NO_ORG }] };
131
661
  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) {
136
- if (!dashboard_username || !dashboard_password) {
137
- return {
138
- content: [{
139
- 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.`,
141
- }],
142
- };
662
+ const minPw = template.minPasswordLength ?? 8;
663
+ const refuse = (text) => ({ content: [{ type: "text", text }] });
664
+ // Trimmed like the console does, and blank means absent. " admin" would otherwise reach
665
+ // a validator that does not trim and fail a shape check the user cannot see.
666
+ const username = dashboard_username?.trim() || undefined;
667
+ // A field that belongs to one template is refused on every other one: the backend would
668
+ // drop it silently, and for `auth_code` that means a secret sent somewhere it is not used.
669
+ const ownFields = [
670
+ ["panel_source", panel_source, "mysterium"],
671
+ ["proxy_source", proxy_source, "mysterium-gateway"],
672
+ ["country", country, "mysterium-gateway"],
673
+ ["service_type", service_type, "mysterium-gateway"],
674
+ ["auth_code", auth_code, "urnetwork"],
675
+ ["public_key", public_key, "openssh-server"],
676
+ ["password_access", password_access, "openssh-server"],
677
+ ];
678
+ for (const [field, value, owner] of ownFields) {
679
+ if (value !== undefined && template_type !== owner) {
680
+ return refuse(`\`${field}\` belongs to \`${owner}\` only, so it was NOT sent for \`${template_type}\`. Re-run without it.`);
143
681
  }
144
682
  }
145
- else if (dashboard_username || dashboard_password) {
146
- return {
147
- content: [{
148
- 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.`,
150
- }],
151
- };
683
+ if (template.requiresPublic && !is_public) {
684
+ return refuse(`${template.label} needs is_public: true, because ${template.requiresPublic}. With false the backend never opens that port and the service cannot be reached. Re-run with is_public true.`);
685
+ }
686
+ if (template.networkTerms && acknowledge_network_terms !== true) {
687
+ return refuse(`${template.label} needs acknowledge_network_terms: true, because ${template.networkTerms}. Explain that to the user and ask; re-run with the flag only once they agree.`);
688
+ }
689
+ // Validate the credential SHAPE before sending. The backend drops fields a template
690
+ // does not accept, so a silently-ignored password leaves the user believing they set
691
+ // one - which for a "first visitor owns it" template is a security problem, not a
692
+ // cosmetic one.
693
+ if (template.creds === "dashboardLogin") {
694
+ const rule = USERNAME_SHAPE_RULES[template.usernameShape ?? "any"];
695
+ const usernameOptional = template.defaultUsername !== undefined;
696
+ if (!dashboard_password || (!username && !usernameOptional)) {
697
+ return refuse(usernameOptional
698
+ ? `\`${template_type}\` (${template.label}) requires dashboard_password (${minPw}+ chars). dashboard_username is optional and defaults to \`${template.defaultUsername}\`; if given it must be ${rule.hint}. Ask the user for the password.`
699
+ : `\`${template_type}\` (${template.label}) requires BOTH dashboard_username and dashboard_password - they become its admin login. Username must be ${rule.hint}; password ${minPw}+ chars. Ask the user for them.`);
700
+ }
701
+ if (username !== undefined && !rule.test(username)) {
702
+ return refuse(`dashboard_username \`${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." : ""}`);
703
+ }
704
+ if (username !== undefined && template.usernameMaxLength && [...username].length > template.usernameMaxLength) {
705
+ return refuse(`dashboard_username is too long for ${template.label}: ${template.usernameMaxLength} characters at most.`);
706
+ }
707
+ }
708
+ else if (template.creds === "fixedRootPassword") {
709
+ if (!dashboard_password) {
710
+ return refuse(`\`${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}` : ""}`);
711
+ }
712
+ if (username) {
713
+ return refuse(`\`${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.`);
714
+ }
715
+ }
716
+ else if (username || dashboard_password) {
717
+ return refuse(template.creds === "none"
718
+ ? `\`${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.`
719
+ : `\`${template_type}\` (${template.label}) has no login to set, so dashboard_username / dashboard_password do not apply and were NOT sent.${template.creds === "authCode" ? " It takes an `auth_code` from ur.io/app instead." : ""} Re-run without them.`);
720
+ }
721
+ if (dashboard_password) {
722
+ if (dashboard_password.length < minPw) {
723
+ return refuse(`${template.label} requires a password of at least ${minPw} characters (got ${dashboard_password.length}).`);
724
+ }
725
+ if (dashboard_password.trim() === "") {
726
+ return refuse(`${template.label} needs a real password: one made only of spaces fails the backend's presence check.`);
727
+ }
728
+ if (template.maxPasswordBytes && Buffer.byteLength(dashboard_password, "utf8") > template.maxPasswordBytes) {
729
+ return refuse(`${template.label} takes a password of at most ${template.maxPasswordBytes} bytes (its image hashes it with bcrypt, which cannot take more). Use a shorter one.`);
730
+ }
731
+ if (template.passwordForbids === "nul" && dashboard_password.includes("\u0000")) {
732
+ return refuse(`${template.label} cannot take a password with a null character in it.`);
733
+ }
734
+ if (template.passwordForbids === "control" && /[\u0000-\u001f\u007f-\u009f]/.test(dashboard_password)) {
735
+ return refuse(`${template.label} cannot take a password with a tab, line break or other control character: the image feeds it to chpasswd as one line, so a line break would inject a second entry.`);
736
+ }
737
+ }
738
+ if (panel_source !== undefined) {
739
+ const error = sourceError(panel_source, "panel_source");
740
+ if (error)
741
+ return refuse(error);
742
+ }
743
+ if (proxy_source !== undefined) {
744
+ const error = sourceError(proxy_source, "proxy_source");
745
+ if (error)
746
+ return refuse(error);
747
+ }
748
+ if (country !== undefined && !/^[A-Za-z]{2}$/.test(country)) {
749
+ return refuse("country must be a two-letter ISO-3166 code, like DE or US.");
750
+ }
751
+ let authCode;
752
+ if (template.creds === "authCode") {
753
+ authCode = auth_code?.trim();
754
+ if (!authCode) {
755
+ return refuse("URnetwork requires auth_code: the token the user creates at ur.io/app. Ask them for it.");
756
+ }
757
+ if (!URNETWORK_AUTH_CODE.test(authCode)) {
758
+ return refuse("That auth_code is not a URnetwork auth code: it must be one token of 20 or more characters using letters, digits and _-=+/. only, copied exactly from ur.io/app. It was NOT sent.");
759
+ }
760
+ }
761
+ let publicKey;
762
+ if (public_key !== undefined) {
763
+ const key = public_key.trim();
764
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(key)) {
765
+ return refuse("That is a PRIVATE key, and it was NOT sent anywhere. Send the PUBLIC half - the `.pub` one-liner (`ssh-ed25519 AAAA... user@host`). Treat the pasted private key as compromised and generate a new keypair.");
766
+ }
767
+ const parts = /^(\S+)\s+(\S+)(?:\s+(.*))?$/.exec(key);
768
+ if (/[\r\n]/.test(key) || !parts || !OPENSSH_KEY_ALGORITHMS.has(parts[1])
769
+ || !/^[A-Za-z0-9+/]+={0,3}$/.test(parts[2]) || parts[2].length < 40) {
770
+ return refuse(`public_key must be ONE OpenSSH public key line: an algorithm (${[...OPENSSH_KEY_ALGORITHMS].join(", ")}), the base64 key, and an optional comment - the contents of a \`.pub\` file.`);
771
+ }
772
+ // Single spaces between the parts, as the backend's PUBLIC_KEY_FORMAT expects.
773
+ publicKey = [parts[1], parts[2], parts[3]?.trim()].filter(Boolean).join(" ");
774
+ }
775
+ if (password_access === false && !publicKey) {
776
+ return refuse("password_access: false needs a public_key: without one nobody could sign in. Pass the user's public key, or leave password sign-in on.");
152
777
  }
153
778
  const body = { name, is_public, instant_deploy: true };
154
- if (template.dashboardCreds) {
155
- body.dashboard_username = dashboard_username;
779
+ if (template.creds === "dashboardLogin") {
780
+ body.dashboard_username = username ?? template.defaultUsername;
781
+ body.dashboard_password = dashboard_password;
782
+ }
783
+ else if (template.creds === "fixedRootPassword") {
156
784
  body.dashboard_password = dashboard_password;
157
785
  }
786
+ else if (template.creds === "authCode") {
787
+ body.auth_code = authCode;
788
+ }
158
789
  if (public_port !== undefined)
159
790
  body.public_port = public_port;
791
+ if (panel_source !== undefined)
792
+ body.panel_source = panel_source.trim();
793
+ if (proxy_source !== undefined)
794
+ body.proxy_source = proxy_source.trim();
795
+ if (country !== undefined)
796
+ body.country = country.toUpperCase();
797
+ if (service_type !== undefined)
798
+ body.service_type = service_type;
799
+ if (publicKey !== undefined)
800
+ body.public_key = publicKey;
801
+ if (password_access !== undefined)
802
+ body.password_access = password_access;
160
803
  const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${template_type}`, {
161
804
  method: "POST",
162
805
  body,
@@ -166,11 +809,14 @@ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_po
166
809
  return { content: [{ type: "text", text: `Failed to create service (${result.statusCode}): ${result.message}` }] };
167
810
  }
168
811
  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}.`,
812
+ `${template.label} service \`${name}\` is provisioning (status: creating). Expect around ${template.bootMinutes} minute${template.bootMinutes === 1 ? "" : "s"} - do not call it stuck before then.`,
813
+ "Poll `list_project_services`, then call `get_project_service` (with include_credentials) once it is `active`. Credentials live on that endpoint only.",
171
814
  ];
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.`);
815
+ if (template.creds === "none") {
816
+ 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.`);
817
+ }
818
+ if (template.loginNote) {
819
+ lines.push(template.loginNote);
174
820
  }
175
821
  return {
176
822
  content: [
@@ -200,5 +846,141 @@ EXPECT THESE 422s: a duplicate service name inside the same project; \`public_po
200
846
  }
201
847
  return { content: [{ type: "text", text: "Service deletion queued." }] };
202
848
  });
849
+ 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.
850
+
851
+ 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.
852
+
853
+ 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.`, {
854
+ ...inspectArgs,
855
+ lines: z.number().int().min(1).max(INSPECT_LINES_MAX).optional()
856
+ .describe(`Tail length (default ${INSPECT_LINES_DEFAULT}, max ${INSPECT_LINES_MAX}; clamped server-side)`),
857
+ }, async ({ project_pid, resource_type, resource_pid, lines }) => {
858
+ if (!hasOrg())
859
+ return { content: [{ type: "text", text: NO_ORG }] };
860
+ const body = {};
861
+ if (lines !== undefined)
862
+ body.lines = lines;
863
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "logs"), {
864
+ method: "POST",
865
+ body,
866
+ toolName: "request_project_container_logs",
867
+ });
868
+ if (!result.success) {
869
+ return { content: [{ type: "text", text: `Failed to request container logs (${result.statusCode}): ${result.message}` }] };
870
+ }
871
+ return {
872
+ content: [
873
+ { 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." },
874
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
875
+ ],
876
+ };
877
+ });
878
+ 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 }) => {
879
+ if (!hasOrg())
880
+ return { content: [{ type: "text", text: NO_ORG }] };
881
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "logs"), {
882
+ toolName: "get_project_container_logs",
883
+ });
884
+ if (!result.success) {
885
+ return { content: [{ type: "text", text: `Failed to read container logs (${result.statusCode}): ${result.message}` }] };
886
+ }
887
+ return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
888
+ });
889
+ 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\`.
890
+
891
+ 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 }) => {
892
+ if (!hasOrg())
893
+ return { content: [{ type: "text", text: NO_ORG }] };
894
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "stats"), {
895
+ method: "POST",
896
+ toolName: "request_project_container_stats",
897
+ });
898
+ if (!result.success) {
899
+ return { content: [{ type: "text", text: `Failed to request container stats (${result.statusCode}): ${result.message}` }] };
900
+ }
901
+ return {
902
+ content: [
903
+ { type: "text", text: "Stats fetch dispatched (`pending`). Call `get_project_container_stats` with the same ids to read it." },
904
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
905
+ ],
906
+ };
907
+ });
908
+ 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 }) => {
909
+ if (!hasOrg())
910
+ return { content: [{ type: "text", text: NO_ORG }] };
911
+ const result = await apiRequest(inspectPath(project_pid, resource_type, resource_pid, "stats"), {
912
+ toolName: "get_project_container_stats",
913
+ });
914
+ if (!result.success) {
915
+ return { content: [{ type: "text", text: `Failed to read container stats (${result.statusCode}): ${result.message}` }] };
916
+ }
917
+ return { content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }] };
918
+ });
919
+ server.tool("set_project_service_custom_domain", `Point a custom domain at a project service's web UI, replacing its platform-generated hostname. Every template exposes a web component except four with no web UI to route: Mysterium, the Mysterium VPN Gateway, URnetwork and the OpenSSH server. The backend refuses a domain for those, so do not offer one.
920
+
921
+ 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:
922
+ 1. \`get_project\` for the project's \`public_ip\`.
923
+ 2. Have the user create a DNS **A record** for the domain pointing at that IP (a CNAME chain resolving to it also passes).
924
+ 3. Wait for propagation.
925
+ 4. Call this. A 422 naming the IP means step 2 or 3 is not done - relay it and retry later, do not loop.
926
+
927
+ 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.
928
+
929
+ 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.`, {
930
+ project_pid: z.string().min(1),
931
+ service_pid: z.string().min(1),
932
+ domain: z.string().min(1).describe("Bare hostname, e.g. crm.example.com - no https://, no trailing path"),
933
+ }, async ({ project_pid, service_pid, domain }) => {
934
+ if (!hasOrg())
935
+ return { content: [{ type: "text", text: NO_ORG }] };
936
+ const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${service_pid}/custom_domain`, {
937
+ method: "POST",
938
+ body: { domain },
939
+ toolName: "set_project_service_custom_domain",
940
+ });
941
+ if (!result.success) {
942
+ const dnsHint = result.statusCode === 422
943
+ ? " 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."
944
+ : "";
945
+ return { content: [{ type: "text", text: `Failed to set custom domain (${result.statusCode}): ${result.message}.${dnsHint}` }] };
946
+ }
947
+ return {
948
+ content: [
949
+ { 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.` },
950
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
951
+ ],
952
+ };
953
+ });
954
+ 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.
955
+
956
+ 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.`, {
957
+ project_pid: z.string().min(1),
958
+ service_pid: z.string().min(1),
959
+ confirm: z.boolean().describe("Must be true. The service redeploys and anything pointing at the custom domain - webhooks, OAuth callbacks - breaks."),
960
+ }, async ({ project_pid, service_pid, confirm }) => {
961
+ if (!hasOrg())
962
+ return { content: [{ type: "text", text: NO_ORG }] };
963
+ if (!confirm) {
964
+ return {
965
+ content: [{
966
+ type: "text",
967
+ 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.`,
968
+ }],
969
+ };
970
+ }
971
+ const result = await apiRequest(`${PROJECTS_BASE}/${project_pid}/services/${service_pid}/custom_domain`, {
972
+ method: "DELETE",
973
+ toolName: "remove_project_service_custom_domain",
974
+ });
975
+ if (!result.success) {
976
+ return { content: [{ type: "text", text: `Failed to remove custom domain (${result.statusCode}): ${result.message}` }] };
977
+ }
978
+ return {
979
+ content: [
980
+ { type: "text", text: "Custom domain removed and the platform hostname restored. Re-read `get_project_service` for the current `public_url`." },
981
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
982
+ ],
983
+ };
984
+ });
203
985
  }
204
986
  //# sourceMappingURL=project-services.js.map