voroa-cli 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -2
  3. package/package.json +41 -4
  4. package/src/api.js +552 -0
  5. package/src/commands/db.js +99 -0
  6. package/src/commands/deploy.js +104 -0
  7. package/src/commands/domains.js +122 -0
  8. package/src/commands/env.js +143 -0
  9. package/src/commands/lifecycle.js +40 -0
  10. package/src/commands/link.js +92 -0
  11. package/src/commands/login.js +104 -0
  12. package/src/commands/logs.js +285 -0
  13. package/src/commands/services.js +72 -0
  14. package/src/commands/ssh.js +46 -0
  15. package/src/commands/status.js +36 -0
  16. package/src/commands/tokens.js +81 -0
  17. package/src/commands/workspace.js +63 -0
  18. package/src/config.js +108 -0
  19. package/src/errors.js +10 -0
  20. package/src/flags.js +32 -0
  21. package/src/index.js +190 -0
  22. package/src/interactive.js +28 -0
  23. package/src/link.js +72 -0
  24. package/src/mcp/build.js +72 -0
  25. package/src/mcp/kernel/index.js +59 -0
  26. package/src/mcp/kernel/logger.js +28 -0
  27. package/src/mcp/lib/config.js +72 -0
  28. package/src/mcp/lib/credentials.js +51 -0
  29. package/src/mcp/lib/http.js +136 -0
  30. package/src/mcp/lib/response.js +14 -0
  31. package/src/mcp/lib/reversibility.js +33 -0
  32. package/src/mcp/reversibilityMap.json +77 -0
  33. package/src/mcp/tools/_action.js +49 -0
  34. package/src/mcp/tools/_ids.js +42 -0
  35. package/src/mcp/tools/_shape.js +318 -0
  36. package/src/mcp/tools/actions.js +94 -0
  37. package/src/mcp/tools/getBuildLog.js +39 -0
  38. package/src/mcp/tools/getDeployHistory.js +42 -0
  39. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  40. package/src/mcp/tools/getServiceStatus.js +41 -0
  41. package/src/mcp/tools/getUsage.js +34 -0
  42. package/src/mcp/tools/listDatabases.js +25 -0
  43. package/src/mcp/tools/listDomains.js +33 -0
  44. package/src/mcp/tools/listServices.js +20 -0
  45. package/src/mcp/tools/propose.js +124 -0
  46. package/src/mcp/tools/reads.js +756 -0
  47. package/src/mcp/tools/writes.js +632 -0
  48. package/src/mcpTools.js +265 -0
  49. package/src/output.js +56 -0
  50. package/src/prompt.js +127 -0
  51. package/src/region.js +5 -0
  52. package/src/resolve.js +64 -0
  53. package/src/status.js +178 -0
  54. package/src/stepup.js +69 -0
@@ -0,0 +1,756 @@
1
+ // Phase B read tools - read parity with what a customer can SEE in the dashboard.
2
+ //
3
+ // Grouped in ONE file the way actions.js groups the six action tools, rather than 23 near-identical files
4
+ // (CLAUDE.md "simplicity first"). Each tool is still a full, separately-registered entry with its own
5
+ // description and schema - the model reads those, and they are the real UX.
6
+ //
7
+ // PROJECTION IS DELIBERATE AND CONSERVATIVE (golden rule 6). Every tool forwards a NAMED subset of the
8
+ // response, never the raw envelope: a payload that grows an infra field upstream must not start leaking it
9
+ // here by default. Where a row's full shape was not enumerated when this was written, the subset is the
10
+ // small obviously-safe part (id, name, state, timestamps). Under-forwarding is a visible gap somebody can
11
+ // widen deliberately; over-forwarding is a rule-6 breach nobody sees. Widen a pick list only after reading
12
+ // the handler that produces it.
13
+ //
14
+ // NOTHING HERE RETURNS A SECRET VALUE. list_env_vars and list_secret_files return NAMES only, because that
15
+ // is all their endpoints return (routes/apps/env.js getEnvKeysForApp; routes/apps/secrets.js maps to
16
+ // filename + createdAt). Database connection credentials are not on this surface at all.
17
+
18
+ import { jsonResponse, badInput } from '../lib/response.js';
19
+ import { serviceId } from './_ids.js';
20
+ // The projection primitives live in _shape.js so every tool's shaping is swept by one scan - see its
21
+ // header, and tests/staticScan, which refuses a tool module that projects inline.
22
+ import { pick, pickAll, billingMe, billingInvoices, billingSummary, billingQuote } from './_shape.js';
23
+
24
+ // `pick` returns {} for an absent source, which is right for a shaper that always has a subject. Some
25
+ // reads here answer "there is nothing", and {} would read as "something, with no fields" - a deploy that
26
+ // is not running must not look like a deploy with no id. This says nothing, explicitly.
27
+ const pickOrNull = (obj, keys) => (obj && typeof obj === 'object' ? pick(obj, keys) : null);
28
+
29
+ // A read tool over a path built from at most two validated ids. `project` receives the parsed body.
30
+ function readTool({ name, description, params = [], path, project }) {
31
+ const properties = {};
32
+ for (const p of params) properties[p.name] = { type: 'string', description: p.description };
33
+ return {
34
+ name,
35
+ kind: 'read',
36
+ scope: 'services:read',
37
+ description,
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties,
41
+ required: params.filter((p) => !p.optional).map((p) => p.name),
42
+ additionalProperties: false,
43
+ },
44
+ async handler(args, ctx) {
45
+ const values = {};
46
+ for (const p of params) {
47
+ const raw = args[p.name];
48
+ if (raw == null || raw === '') {
49
+ if (!p.optional) throw badInput(`${p.name} is required.`);
50
+ continue;
51
+ }
52
+ const ok = serviceId(raw);
53
+ if (!ok) throw badInput(`${p.name} is not a valid id.`);
54
+ values[p.name] = ok;
55
+ }
56
+ const url = path(values);
57
+ return jsonResponse(project(await ctx.http.get(url)));
58
+ },
59
+ };
60
+ }
61
+
62
+ const SERVICE_ARG = { name: 'serviceId', description: 'The service id, from list_services.' };
63
+ const svc = (v) => encodeURIComponent(v.serviceId);
64
+
65
+ export const getCurrentDeployment = readTool({
66
+ name: 'get_current_deployment',
67
+ description:
68
+ 'The deploy currently running for a service, or nothing if none is in progress. Use get_deploy_history '
69
+ + 'for finished deploys.',
70
+ params: [SERVICE_ARG],
71
+ path: (v) => `/apps/${svc(v)}/deployment`,
72
+ // The handler returns { deployment: <row|null> }.
73
+ project: (d) => ({
74
+ deployment: pickOrNull(d && d.deployment, ['id', 'status', 'verdict', 'commit', 'commitMessage', 'createdAt', 'finishedAt']),
75
+ }),
76
+ });
77
+
78
+ export const listBranches = readTool({
79
+ name: 'list_branches',
80
+ description:
81
+ 'The branches available on the connected repository for a service, and whether a repository is '
82
+ + 'connected at all.',
83
+ params: [SERVICE_ARG],
84
+ path: (v) => `/apps/${svc(v)}/branches`,
85
+ project: (d) => ({ branches: Array.isArray(d && d.branches) ? d.branches : [], connected: !!(d && d.connected) }),
86
+ });
87
+
88
+ export const listEnvVars = readTool({
89
+ name: 'list_env_vars',
90
+ description:
91
+ 'The NAMES of a service\'s environment variables. Values are never returned by Voroa to anyone, '
92
+ + 'including you - they are stored encrypted and can be replaced but not read back. Say that plainly '
93
+ + 'if asked for a value.',
94
+ params: [SERVICE_ARG],
95
+ path: (v) => `/apps/${svc(v)}/env`,
96
+ project: (d) => ({ env: pickAll(d && d.env, ['key', 'hasValue', 'isBuildTime']) }),
97
+ });
98
+
99
+ export const listSecretFiles = readTool({
100
+ name: 'list_secret_files',
101
+ description:
102
+ 'The FILENAMES of a service\'s secret files. Contents are never returned - they are stored encrypted '
103
+ + 'and can be replaced but not read back.',
104
+ params: [SERVICE_ARG],
105
+ path: (v) => `/apps/${svc(v)}/secret-files`,
106
+ project: (d) => ({ secretFiles: pickAll(d && d.secretFiles, ['filename', 'createdAt']) }),
107
+ });
108
+
109
+ export const listPreviewVariables = readTool({
110
+ name: 'list_preview_variables',
111
+ description:
112
+ 'The NAMES of the environment variables applied to a service\'s preview environments. Values are never '
113
+ + 'returned.',
114
+ params: [SERVICE_ARG],
115
+ path: (v) => `/apps/${svc(v)}/preview-variables`,
116
+ project: (d) => ({ variables: pickAll(d && d.variables, ['key', 'hasValue']) }),
117
+ });
118
+
119
+ export const listPreviews = readTool({
120
+ name: 'list_previews',
121
+ description: 'The preview environments open for a service, and whether previews are switched on for it.',
122
+ params: [SERVICE_ARG],
123
+ path: (v) => `/apps/${svc(v)}/previews`,
124
+ project: (d) => ({
125
+ previewEnabled: !!(d && d.previewEnabled),
126
+ previews: pickAll(d && d.previews, ['id', 'number', 'title', 'state', 'url', 'createdAt']),
127
+ }),
128
+ });
129
+
130
+ export const getMaintenanceMode = readTool({
131
+ name: 'get_maintenance_mode',
132
+ description:
133
+ 'Whether a service is in maintenance mode - visitors see a maintenance page instead of the service '
134
+ + 'while it is on.',
135
+ params: [SERVICE_ARG],
136
+ path: (v) => `/apps/${svc(v)}/maintenance`,
137
+ project: (d) => pickOrNull(d, ['enabled', 'since', 'message']),
138
+ });
139
+
140
+ // ADR-0263. Whether the service's own name is served through the edge network, and if not, why not.
141
+ export const getEdgeProtection = readTool({
142
+ name: 'get_edge_protection',
143
+ description:
144
+ 'Whether edge protection is on for a service - its address is served through an edge network that '
145
+ + 'absorbs floods before they reach it. `available` false means the platform does not offer it yet; '
146
+ + '`effective` false with a `reason` of custom_domain or ip_rules means it cannot apply to this service.',
147
+ params: [SERVICE_ARG],
148
+ path: (v) => `/apps/${svc(v)}/edge-protection`,
149
+ project: (d) => pickOrNull(d, ['available', 'enabled', 'effective', 'reason', 'settling', 'changedAt']),
150
+ });
151
+
152
+ export const listInboundRules = readTool({
153
+ name: 'list_inbound_rules',
154
+ description:
155
+ 'The inbound IP rules for a service - which addresses may reach it. An empty list means no restriction.',
156
+ params: [SERVICE_ARG],
157
+ path: (v) => `/apps/${svc(v)}/inbound-rules`,
158
+ project: (d) => ({ rules: pickAll(d && d.rules, ['id', 'cidr', 'action', 'note', 'createdAt']) }),
159
+ });
160
+
161
+ export const listScheduledTasks = readTool({
162
+ name: 'list_scheduled_tasks',
163
+ description: 'The scheduled tasks (cron jobs) attached to a service, with their commands and frequency.',
164
+ params: [SERVICE_ARG],
165
+ path: (v) => `/apps/${svc(v)}/scheduled-tasks`,
166
+ project: (d) => ({ tasks: pickAll(d && d.tasks, ['taskUuid', 'name', 'command', 'frequency', 'enabled', 'createdAt']) }),
167
+ });
168
+
169
+ export const getScheduledTaskRuns = readTool({
170
+ name: 'get_scheduled_task_runs',
171
+ description:
172
+ 'Recent runs of one scheduled task. Voroa records when each run started, ended and whether it '
173
+ + 'succeeded; it does not keep the task\'s own output.',
174
+ params: [SERVICE_ARG, { name: 'taskId', description: 'The task id, from list_scheduled_tasks.' }],
175
+ path: (v) => `/apps/${svc(v)}/scheduled-tasks/${encodeURIComponent(v.taskId)}/executions`,
176
+ project: (d) => ({ executions: pickAll(d && d.executions, ['id', 'status', 'startedAt', 'finishedAt']) }),
177
+ });
178
+
179
+ export const listServiceEnvGroups = readTool({
180
+ name: 'list_service_env_groups',
181
+ description: 'The environment groups linked to a service, in the order they are applied.',
182
+ params: [SERVICE_ARG],
183
+ path: (v) => `/apps/${svc(v)}/env-groups`,
184
+ project: (d) => ({ groups: pickAll(d && d.groups, ['id', 'name', 'position']) }),
185
+ });
186
+
187
+ export const getServiceUsage = readTool({
188
+ name: 'get_service_usage',
189
+ description:
190
+ 'Recent resource usage for one service. Returns nothing when per-service usage is not being recorded '
191
+ + 'for this account.',
192
+ params: [SERVICE_ARG],
193
+ path: (v) => `/apps/${svc(v)}/service-usage`,
194
+ project: (d) => pickOrNull(d, ['cpuPercent', 'memoryMb', 'memoryLimitMb', 'measuredAt']),
195
+ });
196
+
197
+ export const getDatabaseCapabilities = readTool({
198
+ name: 'get_database_capabilities',
199
+ description: 'Which database features are available on this account - backups, imports, external access.',
200
+ params: [],
201
+ path: () => '/databases/capabilities',
202
+ project: (d) => d || null,
203
+ });
204
+
205
+ const DB_ARG = { name: 'databaseId', description: 'The database id, from list_databases.' };
206
+ const db = (v) => encodeURIComponent(v.databaseId);
207
+
208
+ export const getDatabase = readTool({
209
+ name: 'get_database',
210
+ description:
211
+ 'One database: its name, engine, size, state and storage. Connection credentials are NOT available '
212
+ + 'from here - get them from your Voroa dashboard.',
213
+ params: [DB_ARG],
214
+ path: (v) => `/databases/${db(v)}`,
215
+ project: (d) => pickOrNull(d && (d.database || d), [
216
+ 'uuid', 'name', 'engine', 'version', 'status', 'state', 'createdAt',
217
+ 'computeSizeKey', 'storageQuotaMb', 'storageUsedMb',
218
+ // What the detail route actually sends: `type` (postgres | mysql | redis) and the engine's major -
219
+ // `pgVersion` on Postgres, `engineVersion` on MySQL (ADR-0343). `engine`/`version` stay for older servers.
220
+ 'type', 'pgVersion', 'engineVersion',
221
+ ]),
222
+ });
223
+
224
+ export const listDatabaseUsers = readTool({
225
+ name: 'list_database_users',
226
+ description: 'The database users defined on a database. Passwords are never returned.',
227
+ params: [DB_ARG],
228
+ path: (v) => `/databases/${db(v)}/users`,
229
+ project: (d) => ({ users: pickAll(d && d.users, ['name', 'isDefault', 'createdAt']) }),
230
+ });
231
+
232
+ export const listImports = readTool({
233
+ name: 'list_imports',
234
+ description: 'Recent data imports for a database, with the state of each.',
235
+ params: [DB_ARG],
236
+ path: (v) => `/databases/${db(v)}/import`,
237
+ project: (d) => ({ imports: pickAll(d && (d.imports || d.jobs), ['id', 'status', 'createdAt', 'finishedAt']) }),
238
+ });
239
+
240
+ export const getImportStatus = readTool({
241
+ name: 'get_import_status',
242
+ description: 'The state of one data import, including why it failed if it did.',
243
+ params: [DB_ARG, { name: 'jobId', description: 'The import id, from list_imports.' }],
244
+ path: (v) => `/databases/${db(v)}/import/${encodeURIComponent(v.jobId)}`,
245
+ project: (d) => pickOrNull(d && (d.job || d), ['id', 'status', 'createdAt', 'finishedAt', 'error', 'message']),
246
+ });
247
+
248
+ export const listProjects = readTool({
249
+ name: 'list_projects',
250
+ description: 'The projects in this workspace. A project groups services and their environments.',
251
+ params: [],
252
+ path: () => '/projects',
253
+ project: (d) => ({ projects: pickAll(d && d.projects, ['id', 'name', 'environmentCount', 'serviceCount', 'createdAt']) }),
254
+ });
255
+
256
+ export const getProject = readTool({
257
+ name: 'get_project',
258
+ description:
259
+ 'One project: its name, the environments inside it and the services that belong to them. Use '
260
+ + 'list_projects first to find the id.',
261
+ params: [{ name: 'projectId', description: 'The project id, from list_projects.' }],
262
+ path: (v) => `/projects/${encodeURIComponent(v.projectId)}`,
263
+ project: (d) => pickOrNull(d, ['id', 'name', 'environments', 'services', 'createdAt']),
264
+ });
265
+
266
+ export const listEnvironments = readTool({
267
+ name: 'list_environments',
268
+ description: 'The environments inside a project, and which of them are protected.',
269
+ params: [{ name: 'projectId', description: 'The project id, from list_projects.' }],
270
+ path: (v) => `/projects/${encodeURIComponent(v.projectId)}/environments`,
271
+ project: (d) => ({ environments: pickAll(d && d.environments, ['id', 'name', 'protected', 'serviceCount']) }),
272
+ });
273
+
274
+ export const listEnvGroups = readTool({
275
+ name: 'list_env_groups',
276
+ description:
277
+ 'The environment groups in this workspace. A group holds environment variables shared by several '
278
+ + 'services; values are never returned.',
279
+ params: [],
280
+ path: () => '/env-groups',
281
+ project: (d) => ({ groups: pickAll(d && d.groups, ['id', 'name', 'variableCount', 'serviceCount', 'updatedAt']) }),
282
+ });
283
+
284
+ export const getEnvGroup = readTool({
285
+ name: 'get_env_group',
286
+ description:
287
+ 'One environment group: its name, the NAMES of the variables in it, and the services it is linked to. '
288
+ + 'Values are never returned.',
289
+ params: [{ name: 'groupId', description: 'The group id, from list_env_groups.' }],
290
+ path: (v) => `/env-groups/${encodeURIComponent(v.groupId)}`,
291
+ project: (d) => {
292
+ const g = (d && d.group) || d || {};
293
+ return {
294
+ id: g.id,
295
+ name: g.name,
296
+ variables: pickAll(g.variables, ['key', 'hasValue', 'isBuildTime']),
297
+ services: pickAll(g.services, ['uuid', 'name']),
298
+ };
299
+ },
300
+ });
301
+
302
+ export const listServiceTypes = readTool({
303
+ name: 'list_service_types',
304
+ description: 'The kinds of service that can be created on this account today.',
305
+ params: [],
306
+ path: () => '/service-types',
307
+ project: (d) => ({ types: (d && d.types) || null }),
308
+ });
309
+
310
+ // ---- Billing reads (ADR-0164 D7) ----------------------------------------------------------------------
311
+ // READING a bill is not MOVING money, and that is the whole of what these four open. Nothing that moves
312
+ // money is on this surface: changing a plan, cancelling a subscription, choosing which payment method is
313
+ // debited and every other billing write are absent from the capability allow-list and answer 404 here.
314
+ //
315
+ // THREE CAVEATS LIVE IN THE DESCRIPTIONS BELOW, because the description is what the model reads. Each is
316
+ // true in EVERY state of the platform, not only during an incident:
317
+ // A. a next-charge date is when a charge is SCHEDULED, not evidence the last one succeeded. It is
318
+ // computed from the billing anchor day and today's date - it reads no payment at all - so it is in
319
+ // the future for a subscription that has been failing for months.
320
+ // B. `mandateUsable` says the payment method is registered and unexpired. It is NOT evidence that
321
+ // charges are going through. It is on the two tools that return the field, and on no other.
322
+ // C. the next-debit ESTIMATE on billing_me is the subscription total and excludes usage overage, so the
323
+ // real charge can be higher. The exact figure is on the dashboard.
324
+ // The one honest cross-check an agent has is the invoice list: a failed cycle produces no invoice, so a
325
+ // gap between the newest issuedAt and the next-charge date is visible there and nowhere else.
326
+
327
+ export const billingMeTool = readTool({
328
+ name: 'billing_me',
329
+ description:
330
+ 'What this workspace is paying for: each billable line with its price and any change already booked, '
331
+ + 'the active plan, whether a payment method is set up, and the next scheduled charge. '
332
+ + '`paymentDue` is the field that says whether anything has actually gone wrong: null when nothing is '
333
+ + 'owed, and an amount with a date when a charge has failed or stuck. Read it BEFORE the next-charge '
334
+ + 'date - a workspace can owe money and still show a future scheduled charge. '
335
+ + 'The next-charge DATE is when a charge is scheduled - not evidence that the last one succeeded; '
336
+ + 'check billing_invoices for what has actually been issued. `mandateUsable` means the payment method '
337
+ + 'is registered and unexpired, NOT that charges are going through. The next-charge AMOUNT is the '
338
+ + 'subscription estimate and excludes usage overage, so the real charge can be higher - point the '
339
+ + 'customer at their Voroa dashboard for the exact number. Billing identity (GSTIN, registered name, '
340
+ + 'address, phone) and payment-instrument details are never returned.',
341
+ params: [],
342
+ path: () => '/billing/me',
343
+ project: billingMe,
344
+ });
345
+
346
+ export const billingInvoicesTool = readTool({
347
+ name: 'billing_invoices',
348
+ description:
349
+ 'The GST invoices issued for this workspace, newest first, each with its number, total, date and '
350
+ + 'whether it is paid. This is the one billing read that reflects what actually happened: a cycle that '
351
+ + 'failed to charge produces no invoice, so a gap here is real. Invoice PDFs are downloaded from the '
352
+ + 'Voroa dashboard, not from here.',
353
+ params: [],
354
+ path: () => '/billing/invoices',
355
+ project: billingInvoices,
356
+ });
357
+
358
+ export const billingSummaryTool = readTool({
359
+ name: 'billing_summary',
360
+ description:
361
+ 'The full breakdown of the next charge for this workspace: every line with its price, the taxable '
362
+ + 'subtotal, the CGST/SGST or IGST split, the GST-inclusive total, and the approved ceiling and expiry '
363
+ + 'of the saved payment method. Use this for "why did my bill go up" - it is the same computation the '
364
+ + 'charge itself runs, including any usage overage. The DATE is when the charge is scheduled, not '
365
+ + 'evidence that the last one succeeded; check billing_invoices. `mandateUsable` means the payment '
366
+ + 'method is registered and unexpired, NOT that charges are going through. Rate-limited more tightly '
367
+ + 'than other reads because it recomputes the bill - call it once per question, not in a loop.',
368
+ params: [],
369
+ path: () => '/billing/summary',
370
+ project: billingSummary,
371
+ });
372
+
373
+ // The only billing tool that takes arguments, and the only one that is not a plain GET of a fixed path.
374
+ // Its ids are catalog KEYS (acct_growth, starter_512), not resource ids, so they do not go through
375
+ // serviceId() - the backend validates them against its own catalog and answers 400 on anything else.
376
+ export const billingQuoteTool = {
377
+ name: 'billing_quote',
378
+ kind: 'read',
379
+ scope: 'services:read',
380
+ description:
381
+ 'What an upgrade would cost, before anyone commits to it: the prorated amount due today, the monthly '
382
+ + 'amount after that, the GST on each, and the next billing date. Give EITHER planKey (to price a '
383
+ + 'workspace plan) OR serviceId with sizeKey (to price a service resize). Answers '
384
+ + '{chargeable: false, downgrade: true} when the target costs less than the current one - a downgrade '
385
+ + 'takes effect at the end of the period and is never charged for. This tool only PRICES; it cannot '
386
+ + 'buy anything, and the customer completes an upgrade in their Voroa dashboard. The next billing DATE '
387
+ + 'is when a charge is scheduled, not evidence that the last one succeeded - check billing_invoices. '
388
+ + 'Answers 503 while billing is unavailable.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ planKey: { type: 'string', description: 'A workspace plan key, to price a plan change.' },
393
+ serviceId: { type: 'string', description: 'The service id, from list_services. Use with sizeKey.' },
394
+ sizeKey: { type: 'string', description: 'The target size key for a service, from billing_me.' },
395
+ storageGb: { type: 'string', description: 'Optional. Extra storage in GB to include in the quote.' },
396
+ },
397
+ required: [],
398
+ additionalProperties: false,
399
+ },
400
+ async handler(args, ctx) {
401
+ const q = new URLSearchParams();
402
+ const planKey = typeof args.planKey === 'string' ? args.planKey.trim() : '';
403
+ const sizeKey = typeof args.sizeKey === 'string' ? args.sizeKey.trim() : '';
404
+ const storageGb = typeof args.storageGb === 'string' ? args.storageGb.trim() : '';
405
+ if (planKey) {
406
+ // The backend refuses a mixed target with its own 400; this says the same thing earlier, in words
407
+ // the model can act on rather than as a code it has to look up.
408
+ if (args.serviceId || sizeKey) throw badInput('Quote a workspace plan or a service, not both.');
409
+ q.set('planKey', planKey);
410
+ } else {
411
+ const id = serviceId(args.serviceId);
412
+ if (!id) throw badInput('Give either planKey, or serviceId with sizeKey.');
413
+ if (!sizeKey) throw badInput('sizeKey is required when quoting a service.');
414
+ q.set('appUuid', id);
415
+ q.set('sizeKey', sizeKey);
416
+ if (storageGb) q.set('storageGb', storageGb);
417
+ }
418
+ return jsonResponse(billingQuote(await ctx.http.get(`/billing/quote?${q.toString()}`)));
419
+ },
420
+ };
421
+
422
+ // ---- Object Storage (buckets). Read-only views of what the Buckets pages show. NOT here, on purpose: the
423
+ // access-key secret and key regeneration (credentials - the same line /databases/:uuid/connection stays
424
+ // behind), file download links (a presigned link is a bearer credential to the file), and create/delete
425
+ // (create needs a payment; delete is permanent with no undo). The customer does those in the dashboard.
426
+ const BUCKET_ARG = { name: 'bucketId', description: 'The bucket id, from list_buckets.' };
427
+ const bkt = (v) => encodeURIComponent(v.bucketId);
428
+ const BUCKET_FIELDS = ['id', 'name', 'state', 'freeze', 'plan', 'billingHold', 'createdAt'];
429
+
430
+ export const listBuckets = readTool({
431
+ name: 'list_buckets',
432
+ description:
433
+ 'The Object Storage buckets in this workspace: name, plan, state, and the S3 endpoint to use. The '
434
+ + 'access key secret is never returned - it is shown once in the dashboard when the key is created.',
435
+ path: () => '/buckets',
436
+ project: (d) => ({ endpoint: (d && d.endpoint) || null, buckets: pickAll(d && d.buckets, BUCKET_FIELDS) }),
437
+ });
438
+
439
+ export const listBucketPlans = readTool({
440
+ name: 'list_bucket_plans',
441
+ description: 'The Object Storage plans on sale: storage and storage transfer included, and the monthly price.',
442
+ path: () => '/buckets/plans',
443
+ project: (d) => ({ plans: pickAll(d && d.plans, ['code', 'sizeKey', 'storageGb', 'transferGb', 'priceInrMonthly']) }),
444
+ });
445
+
446
+ export const getBucket = readTool({
447
+ name: 'get_bucket',
448
+ description: 'One bucket: its name, plan, state (active, pending_payment, frozen) and the S3 endpoint.',
449
+ params: [BUCKET_ARG],
450
+ path: (v) => `/buckets/${bkt(v)}`,
451
+ project: (d) => ({ endpoint: (d && d.endpoint) || null, bucket: pickOrNull(d && d.bucket, BUCKET_FIELDS) }),
452
+ });
453
+
454
+ export const getBucketUsage = readTool({
455
+ name: 'get_bucket_usage',
456
+ description:
457
+ 'This IST month for one bucket against its plan: bytes stored, storage transfer, upload and download '
458
+ + 'requests, whether uploads or downloads are paused, and the estimated charge above the plan so far.',
459
+ params: [BUCKET_ARG],
460
+ path: (v) => `/buckets/${bkt(v)}/usage`,
461
+ project: (d) => pick(d || {}, [
462
+ 'period', 'plan', 'storage', 'storageTransfer', 'uploadRequests', 'downloadRequests', 'paused',
463
+ 'estimatedExtra', 'complete',
464
+ ]),
465
+ });
466
+
467
+ export const listBucketFiles = {
468
+ name: 'list_bucket_files',
469
+ kind: 'read',
470
+ scope: 'services:read',
471
+ description:
472
+ 'Files and folders in a bucket, one folder level at a time (200 per page). Give prefix to open a folder '
473
+ + '(for example "images/"), and nextToken from the previous answer for the next page. Names, sizes and '
474
+ + 'dates only - file contents and download links are not available here.',
475
+ inputSchema: {
476
+ type: 'object',
477
+ properties: {
478
+ bucketId: { type: 'string', description: BUCKET_ARG.description },
479
+ prefix: { type: 'string', description: 'Optional. A folder path ending in "/".' },
480
+ nextToken: { type: 'string', description: 'Optional. The nextToken from the previous page.' },
481
+ },
482
+ required: ['bucketId'],
483
+ additionalProperties: false,
484
+ },
485
+ async handler(args, ctx) {
486
+ const id = serviceId(args.bucketId);
487
+ if (!id) throw badInput('bucketId is not a valid id.');
488
+ const q = new URLSearchParams();
489
+ if (typeof args.prefix === 'string' && args.prefix) q.set('prefix', args.prefix);
490
+ if (typeof args.nextToken === 'string' && args.nextToken) q.set('token', args.nextToken);
491
+ const qs = q.toString();
492
+ const d = await ctx.http.get(`/buckets/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`);
493
+ return jsonResponse({
494
+ prefix: (d && d.prefix) || '',
495
+ folders: Array.isArray(d && d.folders) ? d.folders.filter((f) => typeof f === 'string') : [],
496
+ files: pickAll(d && d.files, ['key', 'size', 'lastModified']),
497
+ nextToken: (d && d.nextToken) || null,
498
+ });
499
+ },
500
+ };
501
+
502
+ // ---- Wave 2 reads (plan 2026-10-27) - the features that shipped after Phase B --------------------------
503
+ const DB_ARG2 = { name: 'databaseId', description: 'The database id, from list_databases.' };
504
+ const SVC_ARG2 = { name: 'serviceId', description: 'The service id, from list_services.' };
505
+ const db2 = (v) => encodeURIComponent(v.databaseId);
506
+ const svc2 = (v) => encodeURIComponent(v.serviceId);
507
+
508
+ export const listRepos = readTool({
509
+ name: 'list_repos',
510
+ description:
511
+ 'Repositories the connected GitHub account(s) can deploy from - full_name and default branch, plus the '
512
+ + 'installation id a create_service proposal needs. connected:false means GitHub is not connected yet; '
513
+ + 'that is done in the dashboard. degraded:true means one account could not be listed just now.',
514
+ path: () => '/github/repos',
515
+ project: (d) => ({
516
+ connected: !!(d && d.connected),
517
+ repos: pickAll(d && d.repos, ['full_name', 'default_branch', 'installation_id', 'private']),
518
+ degraded: !!(d && d.degraded),
519
+ needsReconnect: !!(d && d.needsReconnect),
520
+ }),
521
+ });
522
+
523
+ export const listGitConnections = readTool({
524
+ name: 'list_git_connections',
525
+ description:
526
+ 'The GitHub accounts connected to this workspace: installation id, account name, and whether the '
527
+ + 'connection is still working. Connecting or disconnecting is done in the dashboard.',
528
+ path: () => '/github/installations',
529
+ project: (d) => ({
530
+ installations: pickAll(d && d.installations, ['installationId', 'accountLogin', 'disconnected', 'removedOnGitHub']),
531
+ }),
532
+ });
533
+
534
+ export const listGitlabRepos = readTool({
535
+ name: 'list_gitlab_repos',
536
+ description:
537
+ 'Repositories the connected GitLab account(s) can deploy from - full_name, default branch and project '
538
+ + 'id. connected:false means GitLab is not connected (or not offered); connecting is done in the dashboard.',
539
+ path: () => '/gitlab/repos',
540
+ project: (d) => ({
541
+ connected: !!(d && d.connected),
542
+ repos: pickAll(d && d.repos, ['full_name', 'default_branch', 'project_id', 'connection_id']),
543
+ needsReconnect: !!(d && d.needsReconnect),
544
+ }),
545
+ });
546
+
547
+ export const listDatabaseBackups = readTool({
548
+ name: 'list_database_backups',
549
+ description:
550
+ 'Backups of a database that are still within retention: id, status, size, when taken and when it '
551
+ + 'expires, plus whether nightly backups are on. Paid databases only. Downloading a backup is not '
552
+ + 'available here; restoring one is proposed with propose(restore_database_backup).',
553
+ params: [DB_ARG2],
554
+ path: (v) => `/databases/${db2(v)}/backups`,
555
+ project: (d) => ({
556
+ backups: pickAll(d && d.backups, ['id', 'status', 'trigger', 'sizeBytes', 'createdAt', 'finishedAt', 'expiresAt']),
557
+ retentionDays: d && d.retentionDays,
558
+ backupsEnabled: !!(d && d.backupsEnabled),
559
+ paid: !!(d && d.paid),
560
+ }),
561
+ });
562
+
563
+ export const getBackupRestoreStatus = readTool({
564
+ name: 'get_backup_restore_status',
565
+ description:
566
+ 'Progress of a backup restore: status, the id of the NEW database, and timestamps. The restore id is '
567
+ + 'shown to the person who approved propose(restore_database_backup) - ask them for it.',
568
+ params: [DB_ARG2, { name: 'backupId', description: 'The backup id, from list_database_backups.' },
569
+ { name: 'restoreId', description: 'The restore id returned when the restore was approved.' }],
570
+ path: (v) => `/databases/${db2(v)}/backups/${encodeURIComponent(v.backupId)}/restore/${encodeURIComponent(v.restoreId)}`,
571
+ project: (d) => pick(d || {}, ['id', 'status', 'newDbUuid', 'createdAt', 'finishedAt']),
572
+ });
573
+
574
+ export const getDatabaseMetrics = readTool({
575
+ name: 'get_database_metrics',
576
+ description:
577
+ 'Latest CPU, memory and disk readings of a database, with connections (PostgreSQL) or client/ops/hit-rate '
578
+ + 'figures (key-value store). available:false with a reason means nothing has been collected yet.',
579
+ params: [DB_ARG2],
580
+ path: (v) => `/databases/${db2(v)}/metrics`,
581
+ project: (d) => pick(d || {}, ['available', 'reason', 'collectedAt', 'freshness', 'cpu', 'ram', 'disk', 'connections', 'redis']),
582
+ });
583
+
584
+ export const getDatabaseLogs = readTool({
585
+ name: 'get_database_logs',
586
+ description:
587
+ 'The slowest queries of a PostgreSQL database (first 60 characters of each, with call count and mean '
588
+ + 'time) or the INFO stats of a key-value store. No row data. available:false carries a reason.',
589
+ params: [DB_ARG2],
590
+ path: (v) => `/databases/${db2(v)}/logs`,
591
+ project: (d) => ({
592
+ ...pick(d || {}, ['available', 'reason', 'type', 'freshness', 'collectedAt', 'connectedClients', 'opsPerSec', 'hitRate', 'usedMemoryMb']),
593
+ queries: pickAll(d && d.queries, ['rank', 'calls', 'meanTimeMs', 'totalTimeMs', 'rowsAffected', 'query']),
594
+ }),
595
+ });
596
+
597
+ export const listDatabaseExtensions = readTool({
598
+ name: 'list_database_extensions',
599
+ description:
600
+ 'PostgreSQL extensions available for a database and their state (available, pending, enabled, failed). '
601
+ + 'Enabling one is proposed with propose(enable_database_extension) because it cannot be turned off again.',
602
+ params: [DB_ARG2],
603
+ path: (v) => `/databases/${db2(v)}/extensions`,
604
+ project: (d) => ({
605
+ extensions: pickAll(d && d.extensions, ['extension', 'displayName', 'description', 'status', 'enabledAt', 'failureReason', 'canEnable']),
606
+ }),
607
+ });
608
+
609
+ export const getAutoscale = readTool({
610
+ name: 'get_autoscale',
611
+ description:
612
+ 'The autoscaling policy of a service - min/max instances and the CPU or memory target - or null when '
613
+ + 'autoscaling is off. Changing it is proposed (every extra instance is billed).',
614
+ params: [SVC_ARG2],
615
+ path: (v) => `/apps/${svc2(v)}/autoscale`,
616
+ project: (d) => pickOrNull(d, ['minInstances', 'maxInstances', 'cpuTargetPct', 'memTargetPct', 'currentInstances', 'updatedAt']),
617
+ });
618
+
619
+ export const getDiskLimit = readTool({
620
+ name: 'get_disk_limit',
621
+ description: 'The disk allowance of a service: included MB, extra MB bought, and the resulting limit.',
622
+ params: [SVC_ARG2],
623
+ path: (v) => `/apps/${svc2(v)}/disk-limit`,
624
+ project: (d) => pick(d || {}, ['includedMb', 'extraMb', 'limitMb']),
625
+ });
626
+
627
+ export const getPersistentDisk = readTool({
628
+ name: 'get_persistent_disk',
629
+ description: 'The persistent disk of a web service or worker: size in GB, mount path, how much is used, and '
630
+ + 'whether this service may add one (and why not). Null size = no disk yet.',
631
+ params: [SVC_ARG2],
632
+ path: (v) => `/apps/${svc2(v)}/persistent-disk`,
633
+ project: (d) => pick(d || {}, ['gb', 'mountPath', 'mounted', 'usedMb', 'totalMb', 'eligible', 'ratePerGbInr']),
634
+ });
635
+
636
+ export const getEdgeCache = readTool({
637
+ name: 'get_edge_cache',
638
+ description:
639
+ 'Edge caching state of a web service: mode (none, static or all), whether the plan allows it, which '
640
+ + 'domains are cached, and when the cache was last cleared. Changing the mode is proposed; clearing it '
641
+ + 'is purge_edge_cache.',
642
+ params: [SVC_ARG2],
643
+ path: (v) => `/apps/${svc2(v)}/edge-cache`,
644
+ project: (d) => ({
645
+ ...pick(d || {}, ['available', 'mode', 'paid', 'extensions', 'lastPurgedAt', 'purgePending', 'atCapacity']),
646
+ domains: pickAll(d && d.domains, ['host', 'state', 'atCapacity']),
647
+ }),
648
+ });
649
+
650
+ export const getServiceUsageHistory = readTool({
651
+ name: 'get_service_usage_history',
652
+ description:
653
+ 'CPU and memory readings of a service over the last hours (default 24), as time points. available:false '
654
+ + 'with a reason when the service is not reporting yet.',
655
+ params: [SVC_ARG2, { name: 'hours', description: 'Optional. How many hours back, as digits (1-168).', optional: true }],
656
+ path: (v) => `/apps/${svc2(v)}/service-usage/history${v.hours ? `?hours=${encodeURIComponent(v.hours)}` : ''}`,
657
+ project: (d) => ({
658
+ ...pick(d || {}, ['available', 'reason', 'hours', 'since', 'until']),
659
+ points: pickAll(d && d.points, ['t', 'cpuUsedPct', 'cpuCapCores', 'ramUsedMb', 'ramCapMb']),
660
+ }),
661
+ });
662
+
663
+ export const getDeployHookStatus = readTool({
664
+ name: 'get_deploy_hook_status',
665
+ description:
666
+ 'Whether a service has a deploy hook URL. The URL itself is never readable - it is shown once in the '
667
+ + 'dashboard when propose(create_deploy_hook) is approved.',
668
+ params: [SVC_ARG2],
669
+ path: (v) => `/apps/${svc2(v)}/deploy-hook`,
670
+ project: (d) => pick(d || {}, ['enabled']),
671
+ });
672
+
673
+ export const getMyPermissions = readTool({
674
+ name: 'get_my_permissions',
675
+ description:
676
+ 'The role this token acts as in the workspace and the permission keys it holds - check before an '
677
+ + 'action that may be refused with 403.',
678
+ path: () => '/members/me',
679
+ project: (d) => ({ role: pickOrNull(d && d.role, ['key', 'label']), permissions: Array.isArray(d && d.permissions) ? d.permissions.filter((p) => typeof p === 'string') : [] }),
680
+ });
681
+
682
+ export const getWorkspace = readTool({
683
+ name: 'get_workspace',
684
+ description:
685
+ 'The workspace this token belongs to - its name and plan - and a one-line count of its services. '
686
+ + 'The billing contact is not available here.',
687
+ path: () => '/workspace',
688
+ project: (d) => ({
689
+ workspace: pickOrNull(d && d.workspace, ['id', 'name', 'accountPlanKey']),
690
+ serviceCount: d && typeof d.serviceCount === 'number' ? d.serviceCount : undefined,
691
+ }),
692
+ });
693
+
694
+ export const getNotificationSettings = readTool({
695
+ name: 'get_notification_settings',
696
+ description: 'Which workspace email alerts are on: deploy (failed deploys) and usage (approaching limits).',
697
+ path: () => '/workspace/notifications',
698
+ project: (d) => pick(d || {}, ['deploy', 'usage']),
699
+ });
700
+
701
+ // CTO decision 2026-10-27: who has access is readable (never an email); access can only be proposed AWAY.
702
+ export const listMembers = readTool({
703
+ name: 'list_members',
704
+ description:
705
+ 'People in this workspace: member id, name (when they set one) and role. Email addresses are never '
706
+ + 'returned, and the whole list is unavailable when team members are not enabled for the account. A member can '
707
+ + 'be removed or moved to a LOWER role with propose(remove_member / change_member_role); inviting '
708
+ + 'someone or raising a role is done in the dashboard.',
709
+ path: () => '/members',
710
+ project: (d) => ({ members: pickAll(d && d.members, ['customerId', 'fullName', 'role', 'isOwner', 'joinedAt']) }),
711
+ });
712
+
713
+ export const listSshKeys = readTool({
714
+ name: 'list_ssh_keys',
715
+ description:
716
+ 'Your SSH public keys for shell access to services: id, label, fingerprint and when added. Removing one '
717
+ + 'is propose(delete_ssh_key); adding a key is done in the dashboard only.',
718
+ path: () => '/ssh-keys',
719
+ project: (d) => ({
720
+ keys: pickAll(d && d.keys, ['id', 'label', 'fingerprint', 'createdAt', 'lastUsedAt']),
721
+ maxKeys: d && d.maxKeys,
722
+ }),
723
+ });
724
+
725
+ export const listTickets = readTool({
726
+ name: 'list_tickets',
727
+ description: 'Support tickets of this workspace: id, subject, status, priority and dates.',
728
+ path: () => '/support/tickets',
729
+ project: (d) => ({
730
+ tickets: pickAll(d && d.tickets, ['id', 'subject', 'status', 'priority', 'category', 'appUuid', 'createdAt', 'updatedAt']),
731
+ }),
732
+ });
733
+
734
+ export const getTicket = readTool({
735
+ name: 'get_ticket',
736
+ description: 'One support ticket with its conversation (customer and support replies, in order).',
737
+ params: [{ name: 'ticketId', description: 'The ticket id, from list_tickets.' }],
738
+ path: (v) => `/support/tickets/${encodeURIComponent(v.ticketId)}`,
739
+ project: (d) => ({
740
+ ticket: pickOrNull(d && d.ticket, ['id', 'subject', 'body', 'status', 'priority', 'category', 'appUuid', 'createdAt', 'updatedAt']),
741
+ thread: pickAll(d && d.thread, ['kind', 'body', 'createdAt']),
742
+ }),
743
+ });
744
+
745
+ export const READ_TOOLS = [
746
+ listMembers, listSshKeys, listTickets, getTicket,
747
+ listRepos, listGitConnections, listGitlabRepos, listDatabaseBackups, getBackupRestoreStatus, getDatabaseMetrics,
748
+ getDatabaseLogs, listDatabaseExtensions, getAutoscale, getDiskLimit, getPersistentDisk, getEdgeCache, getServiceUsageHistory,
749
+ getDeployHookStatus, getMyPermissions, getWorkspace, getNotificationSettings,
750
+ getCurrentDeployment, listBranches, listEnvVars, listSecretFiles, listPreviewVariables, listPreviews,
751
+ getMaintenanceMode, getEdgeProtection, listInboundRules, listScheduledTasks, getScheduledTaskRuns, listServiceEnvGroups,
752
+ getServiceUsage, getDatabaseCapabilities, getDatabase, listDatabaseUsers, listImports, getImportStatus,
753
+ listProjects, getProject, listEnvironments, listEnvGroups, getEnvGroup, listServiceTypes,
754
+ billingMeTool, billingInvoicesTool, billingSummaryTool, billingQuoteTool,
755
+ listBuckets, listBucketPlans, getBucket, getBucketUsage, listBucketFiles,
756
+ ];