@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.
- package/README.md +122 -37
- package/dist/client.d.ts +7 -0
- package/dist/client.js +2 -1
- package/dist/client.js.map +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/alerts.js +12 -2
- package/dist/tools/alerts.js.map +1 -1
- package/dist/tools/backups.js +29 -5
- package/dist/tools/backups.js.map +1 -1
- package/dist/tools/billing.js +233 -39
- package/dist/tools/billing.js.map +1 -1
- package/dist/tools/database-users.js +53 -6
- package/dist/tools/database-users.js.map +1 -1
- package/dist/tools/deployments.js +47 -3
- package/dist/tools/deployments.js.map +1 -1
- package/dist/tools/github.js +21 -4
- package/dist/tools/github.js.map +1 -1
- package/dist/tools/instance-logs.js +2 -2
- package/dist/tools/instance-logs.js.map +1 -1
- package/dist/tools/instances.js +423 -88
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/organizations.js +5 -1
- package/dist/tools/organizations.js.map +1 -1
- package/dist/tools/pitr.d.ts +22 -0
- package/dist/tools/pitr.js +75 -10
- package/dist/tools/pitr.js.map +1 -1
- package/dist/tools/postgres-extensions.js +176 -57
- package/dist/tools/postgres-extensions.js.map +1 -1
- package/dist/tools/project-backups.js +130 -0
- package/dist/tools/project-backups.js.map +1 -1
- package/dist/tools/project-databases.js +16 -23
- package/dist/tools/project-databases.js.map +1 -1
- package/dist/tools/project-services.js +853 -71
- package/dist/tools/project-services.js.map +1 -1
- package/dist/tools/project-ssh.d.ts +2 -0
- package/dist/tools/project-ssh.js +338 -0
- package/dist/tools/project-ssh.js.map +1 -0
- package/dist/tools/projects.js +21 -3
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/scaling.js +1 -1
- package/dist/tools/scaling.js.map +1 -1
- package/dist/types/tiers.js +27 -4
- package/dist/types/tiers.js.map +1 -1
- 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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 = [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
110
|
-
- \`
|
|
111
|
-
-
|
|
112
|
-
|
|
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
|
|
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.
|
|
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
|
|
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("
|
|
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("
|
|
126
|
-
dashboard_password: z.string().min(8).max(255).optional().describe("
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
return {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
155
|
-
body.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.
|
|
170
|
-
|
|
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 (
|
|
173
|
-
lines.push(`SECURITY
|
|
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
|