voroa-cli 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +247 -2
- package/package.json +41 -4
- package/src/agent.js +95 -0
- package/src/api.js +552 -0
- package/src/commands/db.js +99 -0
- package/src/commands/deploy.js +130 -0
- package/src/commands/domains.js +122 -0
- package/src/commands/env.js +143 -0
- package/src/commands/lifecycle.js +40 -0
- package/src/commands/link.js +92 -0
- package/src/commands/login.js +104 -0
- package/src/commands/logs.js +304 -0
- package/src/commands/services.js +72 -0
- package/src/commands/ssh.js +46 -0
- package/src/commands/status.js +50 -0
- package/src/commands/tokens.js +81 -0
- package/src/commands/workspace.js +63 -0
- package/src/config.js +108 -0
- package/src/errors.js +53 -0
- package/src/flags.js +32 -0
- package/src/index.js +212 -0
- package/src/interactive.js +34 -0
- package/src/link.js +72 -0
- package/src/mcp/build.js +72 -0
- package/src/mcp/kernel/index.js +59 -0
- package/src/mcp/kernel/logger.js +28 -0
- package/src/mcp/lib/config.js +72 -0
- package/src/mcp/lib/credentials.js +51 -0
- package/src/mcp/lib/http.js +136 -0
- package/src/mcp/lib/response.js +14 -0
- package/src/mcp/lib/reversibility.js +33 -0
- package/src/mcp/reversibilityMap.json +77 -0
- package/src/mcp/tools/_action.js +49 -0
- package/src/mcp/tools/_ids.js +42 -0
- package/src/mcp/tools/_shape.js +318 -0
- package/src/mcp/tools/actions.js +94 -0
- package/src/mcp/tools/getBuildLog.js +39 -0
- package/src/mcp/tools/getDeployHistory.js +42 -0
- package/src/mcp/tools/getRuntimeLogs.js +44 -0
- package/src/mcp/tools/getServiceStatus.js +41 -0
- package/src/mcp/tools/getUsage.js +34 -0
- package/src/mcp/tools/listDatabases.js +25 -0
- package/src/mcp/tools/listDomains.js +33 -0
- package/src/mcp/tools/listServices.js +20 -0
- package/src/mcp/tools/propose.js +124 -0
- package/src/mcp/tools/reads.js +756 -0
- package/src/mcp/tools/writes.js +632 -0
- package/src/mcpTools.js +292 -0
- package/src/output.js +145 -0
- package/src/prompt.js +229 -0
- package/src/region.js +5 -0
- package/src/resolve.js +64 -0
- package/src/session.js +270 -0
- package/src/status.js +199 -0
- package/src/stepup.js +69 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Response shaping for the customer token API (backend/src/routes/customerMcpApi.js re-mounts the SPA's
|
|
2
|
+
// own routers, so these envelopes are the SPA's - verified against the handlers, not guessed; bug #26's
|
|
3
|
+
// rule about inventing a shape applies here too).
|
|
4
|
+
//
|
|
5
|
+
// Every shaper is a WHITELIST pick. A new upstream field is DROPPED by default until somebody deliberately
|
|
6
|
+
// adds it here. That default matters more on this surface than on the SPA, for two reasons:
|
|
7
|
+
//
|
|
8
|
+
// 1. GOLDEN RULE 6. Whatever a tool returns lands in the customer's AI client transcript verbatim. The
|
|
9
|
+
// SPA renders a subset of what it receives; an MCP tool would print all of it. So fields that are
|
|
10
|
+
// merely unused in the UI (a raw container state token, a build-server flag, a platform IP) become
|
|
11
|
+
// LEAKS here. They are dropped below, each with the reason.
|
|
12
|
+
// 2. GOLDEN RULE 13. A transcript is stored, like a git commit. "Only what the decision needs" is the
|
|
13
|
+
// same discipline the dev MCP's env_var_catalog follows (names, never values).
|
|
14
|
+
|
|
15
|
+
export function pick(obj, fields) {
|
|
16
|
+
if (!obj || typeof obj !== 'object') return {};
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const f of fields) if (obj[f] !== undefined) out[f] = obj[f];
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// placementOf - WHERE a service/database lives and its plan, from the API's `placement` (plan 2026-10-31: Provider ->
|
|
23
|
+
// Region -> Plan; the server is never sent). Only the customer-meaningful fields - never a plan key.
|
|
24
|
+
function placementOf(p) {
|
|
25
|
+
if (!p || typeof p !== 'object') return undefined;
|
|
26
|
+
const out = { provider: p.providerLabel || p.provider, region: p.label || p.region };
|
|
27
|
+
if (p.plan && typeof p.plan === 'object') out.plan = { name: p.plan.label || null, priceInrMonthly: p.plan.priceInr };
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- Services list (GET /apps -> { apps: [...] }) -----------------------------------------------------
|
|
32
|
+
// DROPPED: installationId (a GitHub App installation id - our integration's internal key, useless to the
|
|
33
|
+
// customer and meaningless outside our plumbing), projectId / environmentId / computeSizeKey (internal
|
|
34
|
+
// ids; the NAMES are surfaced instead), sleepable / healthCheckPath / buildFilter* / preDeployCommand
|
|
35
|
+
// (configuration an agent cannot change on this surface, so printing it is noise that costs transcript).
|
|
36
|
+
//
|
|
37
|
+
// DROPPED, and this one is a RULE 6 fix rather than a tidiness one: `buildPack`. Its values name the
|
|
38
|
+
// build tooling underneath the platform. The dashboard BRANCHES on that value and never prints it, so no
|
|
39
|
+
// customer has ever seen those words - surfacing them here would put them into a stored AI transcript,
|
|
40
|
+
// which is precisely the leak rule 6 exists to stop. `serviceType` already answers the only question an
|
|
41
|
+
// agent has ("what kind of service is this"), in Voroa's own vocabulary. There is no customer-safe mapping
|
|
42
|
+
// worth inventing for a field nothing on this surface acts on. (The words themselves are deliberately not
|
|
43
|
+
// written in this comment: backend/tests/staticScan.test.js scans this package INCLUDING comments, because
|
|
44
|
+
// it ships to customers and there is no build step between this source and whoever installs it.)
|
|
45
|
+
export function service(row) {
|
|
46
|
+
if (!row || typeof row !== 'object') return {};
|
|
47
|
+
const out = pick(row, ['uuid', 'name', 'subdomain', 'serviceType', 'rootDirectory']);
|
|
48
|
+
// Postgres lower-cases unquoted aliases (known bug #21), so these arrive snake_cased. Renamed here so a
|
|
49
|
+
// tool result reads like an API and not like a table.
|
|
50
|
+
if (row.git_repository !== undefined) out.repo = row.git_repository;
|
|
51
|
+
if (row.git_branch !== undefined) out.branch = row.git_branch;
|
|
52
|
+
if (row.created_at !== undefined) out.createdAt = row.created_at;
|
|
53
|
+
if (row.projectName !== undefined) out.project = row.projectName;
|
|
54
|
+
if (row.environmentName !== undefined) out.environment = row.environmentName;
|
|
55
|
+
if (row.placement !== undefined) out.placement = placementOf(row.placement);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- Service status (GET /apps/:uuid/status) ----------------------------------------------------------
|
|
60
|
+
// The upstream payload carries `status`, the RAW `state:health` token (e.g. "running:unknown",
|
|
61
|
+
// "exited:unhealthy"). It is NEVER surfaced. Two reasons, both already written down in this codebase:
|
|
62
|
+
// known bug #3 (an `unknown` health suffix is not "unhealthy", and showing the raw string produced a
|
|
63
|
+
// scary badge on a healthy app) and golden rule 6 (it is a container-runtime token, i.e. substrate).
|
|
64
|
+
//
|
|
65
|
+
// `runningState` / `health` / `crashLooping` are NOT decoded here. They are computed ONCE on the platform
|
|
66
|
+
// by `backend/src/lib/runtimeState.js` and travel as real fields. This file previously derived its own
|
|
67
|
+
// four-value bucket and got it wrong in the way that matters: it merged `restarting:unhealthy` (a crash
|
|
68
|
+
// loop - the whole reason bug #30 added a container health check) into the same value as
|
|
69
|
+
// `restarting:unknown` (one ordinary post-deploy restart), so a crash-looping service read as "coming back
|
|
70
|
+
// up". A whitelist pick cannot make that mistake; a second decoder can, and did.
|
|
71
|
+
export function serviceStatus(data) {
|
|
72
|
+
const out = pick(data, ['uuid', 'verdict', 'runningState', 'health', 'crashLooping']);
|
|
73
|
+
// A platform that has not shipped the fields yet (or a read that degraded) is the COULD-NOT-READ case,
|
|
74
|
+
// not the unmodeled-token case - so the fallback is 'unavailable', matching the server decoder's own
|
|
75
|
+
// vocabulary (review E7: both of this comment's examples ARE the unavailable case, and asserting a
|
|
76
|
+
// successful read that never happened is the same conflation one layer up). Never fall back to decoding
|
|
77
|
+
// the raw string - that is how a second decoder returns.
|
|
78
|
+
if (out.runningState === undefined) out.runningState = 'unavailable';
|
|
79
|
+
if (out.health === undefined) out.health = 'unknown';
|
|
80
|
+
if (out.crashLooping === undefined) out.crashLooping = false;
|
|
81
|
+
// deploymentStatus is upstream's in-flight-build axis; surface it as a plain boolean rather than the
|
|
82
|
+
// engine's queue vocabulary.
|
|
83
|
+
out.deploying = !!(data && data.deploymentStatus);
|
|
84
|
+
// fqdn is a comma-separated "https://host" list built from OUR OWN domains (the handler strips the raw
|
|
85
|
+
// upstream host precisely because a partial setup yields a box-IP host - rule 6). Safe, and it is the
|
|
86
|
+
// one field a human actually wants.
|
|
87
|
+
if (data && data.fqdn) out.urls = String(data.fqdn).split(',').map((s) => s.trim()).filter(Boolean);
|
|
88
|
+
if (data && data.projectName !== undefined) out.project = data.projectName;
|
|
89
|
+
if (data && data.environmentName !== undefined) out.environment = data.environmentName;
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Deploy history (GET /apps/:uuid/deployments -> { deployments: [...] }) ---------------------------
|
|
94
|
+
// `status` is the RAW BUILD status (building|live|failed|cancelled|skipped) and `verdict` is the honest
|
|
95
|
+
// state -
|
|
96
|
+
// the contract bug #24 locked. Both are customer-facing by design, so both are surfaced.
|
|
97
|
+
// DROPPED: `source` (null on most paths and an engine-classification concept when it is not).
|
|
98
|
+
const DEPLOY_FIELDS = [
|
|
99
|
+
'deploymentUuid', 'status', 'verdict', 'commit', 'commitMessage',
|
|
100
|
+
'createdAt', 'finishedAt', 'authorName', 'authorLogin',
|
|
101
|
+
];
|
|
102
|
+
export function deployment(row) {
|
|
103
|
+
return pick(row, DEPLOY_FIELDS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- Build log (GET /apps/:uuid/deployments/:d/log) ---------------------------------------------------
|
|
107
|
+
// `lines` are ALREADY redacted server-side per engine (surfaceBodyForEngine + assembleDeployLog); this
|
|
108
|
+
// client does not re-redact and must not pretend to.
|
|
109
|
+
// DROPPED: `isolated` - it says the build ran on a separate build machine, which is pure infrastructure
|
|
110
|
+
// (rule 6) and means nothing to the customer.
|
|
111
|
+
const BUILD_LOG_FIELDS = ['available', 'status', 'verdict', 'lines', 'phases', 'buildMs', 'reason', 'retentionDays'];
|
|
112
|
+
export function buildLog(data) {
|
|
113
|
+
return pick(data, BUILD_LOG_FIELDS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Domains (GET /apps/:uuid/domains) ----------------------------------------------------------------
|
|
117
|
+
// DROPPED per custom-domain row: `dns` (the registrar instructions) and `aRecordIp` (the PLATFORM's IP
|
|
118
|
+
// address). Both are legitimately shown in the dashboard, where the human is about to type them into a
|
|
119
|
+
// registrar. Neither is something an agent acts on, and an infrastructure IP address in a stored
|
|
120
|
+
// transcript is exactly what rule 6 exists to prevent - so the tool says "set this up in your dashboard"
|
|
121
|
+
// instead.
|
|
122
|
+
// `wildcard` / `ownership` (ADR-0268) ride along so an agent can tell a pending wildcard - which serves nothing
|
|
123
|
+
// until its owner verifies it in the dashboard - from a broken domain. Absent on exact rows.
|
|
124
|
+
// edgeProtection (ADR-0264): { state, reason } of the domain's own edge state - present only once the edge
|
|
125
|
+
// network is in play for this service, so the shape is unchanged until then.
|
|
126
|
+
const DOMAIN_FIELDS = ['id', 'domain', 'status', 'verifiedStatus', 'certificateStatus', 'wildcard', 'ownership', 'edgeProtection'];
|
|
127
|
+
export function domains(data) {
|
|
128
|
+
const out = {};
|
|
129
|
+
if (data && data.defaultDomain) out.defaultDomain = data.defaultDomain;
|
|
130
|
+
out.custom = Array.isArray(data && data.custom) ? data.custom.map((d) => pick(d, DOMAIN_FIELDS)) : [];
|
|
131
|
+
const q = data && data.quota;
|
|
132
|
+
if (q && typeof q === 'object') out.quota = pick(q, ['used', 'limit', 'unlimited']);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- Databases (GET /databases -> { databases: [...] }) ----------------------------------------------
|
|
137
|
+
// Connection credentials are NOT on this surface at all (the /connection route is absent from the
|
|
138
|
+
// capability allow-list), so there is nothing secret to drop here - only a rename.
|
|
139
|
+
export function database(row) {
|
|
140
|
+
if (!row || typeof row !== 'object') return {};
|
|
141
|
+
const out = pick(row, ['uuid', 'name', 'type', 'status']);
|
|
142
|
+
if (row.created_at !== undefined) out.createdAt = row.created_at;
|
|
143
|
+
if (row.placement !== undefined) out.placement = placementOf(row.placement);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Usage (GET /usage) -------------------------------------------------------------------------------
|
|
148
|
+
// GRAIN, stated because the fields mix two (review M5): buildMinutes/egressGb are ACCOUNT-wide totals
|
|
149
|
+
// (account_usage has no workspace grain), workspaceEgressGb is THIS workspace's own figure. The tool
|
|
150
|
+
// description says the same; here the account fields are grouped under `account` so the result itself
|
|
151
|
+
// cannot be misread as all-workspace.
|
|
152
|
+
const USAGE_ACCOUNT_FIELDS = ['buildMinutes', 'buildMinutesIncluded', 'egressGb'];
|
|
153
|
+
const USAGE_WORKSPACE_FIELDS = ['workspaceEgressGb', 'bandwidthGbIncluded'];
|
|
154
|
+
export function usage(data) {
|
|
155
|
+
if (!data || typeof data !== 'object') return {};
|
|
156
|
+
const out = { ...pick(data, ['period']), account: pick(data, USAGE_ACCOUNT_FIELDS) };
|
|
157
|
+
const ws = pick(data, USAGE_WORKSPACE_FIELDS);
|
|
158
|
+
// A NULL workspace figure means "the platform does not measure this yet" - the backend sets it null on
|
|
159
|
+
// purpose so a consumer can tell that apart from "you used 0 GB", and pick() passing the null through
|
|
160
|
+
// made the agent report a fake zero on a billable quota (review M1: the exact misreading the SPA path
|
|
161
|
+
// was built to prevent). Say it explicitly instead of handing over a null. `== null` on purpose
|
|
162
|
+
// (review M-L1): an ABSENT key - a version-skewed or degraded response - must read as not-measured
|
|
163
|
+
// too, never as a quota with no usage figure and no flag (serviceStatus handles absence the same way).
|
|
164
|
+
if (ws.workspaceEgressGb == null) {
|
|
165
|
+
delete ws.workspaceEgressGb;
|
|
166
|
+
ws.bandwidthMeasured = false;
|
|
167
|
+
} else {
|
|
168
|
+
ws.bandwidthMeasured = true;
|
|
169
|
+
}
|
|
170
|
+
out.workspace = ws;
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Billing reads (ADR-0164 D7) ----------------------------------------------------------------------
|
|
175
|
+
// The four billing shapers are the tightest whitelists in this file, and golden rule 13 is the governing
|
|
176
|
+
// rule rather than golden rule 4 - nothing on these paths is encrypted at rest, so the question is not
|
|
177
|
+
// "may this be decrypted" but "does the decision need it in a stored transcript".
|
|
178
|
+
//
|
|
179
|
+
// DROPPED, and this is the largest exposure on the surface: the GST identity block that GET /billing/me
|
|
180
|
+
// returns to anyone holding billing.manage - `gstin`, `billingName`, `billingState`, `billingAddress`,
|
|
181
|
+
// `contactPhone`, plus `canEditBillingIdentity`. A registered business address and a phone number are
|
|
182
|
+
// DPDP-weight fields and they buy an agent nothing. DROPPED for the same reason on the summary:
|
|
183
|
+
// `mandateInstrument` (the card last four / the payment handle) and `placeOfSupply` (the registered State).
|
|
184
|
+
//
|
|
185
|
+
// DROPPED as ROLLOUT STATE, not as secrets: `billingEnabled`, `paidCheckoutLive`, `multiMethodBilling`,
|
|
186
|
+
// `multiPaymentMethods`, `horizontalScalingEnabled`, `paidAtCreate` and `workspacePlan.autoscalingAvailable`
|
|
187
|
+
// on /billing/me, and `methods` on the quote. They say what Voroa has switched on this week, which is our
|
|
188
|
+
// business and not the customer's decision.
|
|
189
|
+
//
|
|
190
|
+
// DROPPED as internal ids: `workspace.id`, `items[].id`, `items[].appUuid`, `items[].sizeKey` and the
|
|
191
|
+
// invoice `id`. The size-key rule, stated once because it decides four fields: a size key is dropped where
|
|
192
|
+
// a LABEL already says the same thing (`items[].sizeKey` has `sizeLabel` beside it) and KEPT where nothing
|
|
193
|
+
// else does (`pendingSizeKey`, `scheduledChange.sizeKey` and `workspacePlan.planKey` name the TARGET of an
|
|
194
|
+
// in-flight, booked or active change, for which no label field exists - dropping them would leave an agent
|
|
195
|
+
// able to say a change is coming and not what to).
|
|
196
|
+
//
|
|
197
|
+
// NESTED PICKS, not top-level names. `pick` is SHALLOW, so naming `workspacePlan` or `scheduledChange` at
|
|
198
|
+
// the top level would pass those objects through whole and a field added upstream next month would enter
|
|
199
|
+
// this surface silently - which is the one property the whitelist exists to have.
|
|
200
|
+
const BILLING_ME_WORKSPACE = ['name'];
|
|
201
|
+
const BILLING_ME_BILLING = ['mandateStatus', 'mandateMethod', 'mandateUsable', 'mandateNeedsAction', 'anchorDay'];
|
|
202
|
+
// `billing.paymentDue` (PR #1086) is the ONE honest money field on this endpoint: null unless a charge has
|
|
203
|
+
// actually failed or stuck, and an amount with a date when it has. It is named here because dropping it
|
|
204
|
+
// would leave this surface able to say "next charge Rs X on the 8th, payment method working" and unable to
|
|
205
|
+
// say "you owe Rs Y since the 15th" - the two halves of the same customer's truth, with only the flattering
|
|
206
|
+
// one reaching the agent.
|
|
207
|
+
// `payUrl` and `expiresAt` are NOT named: a live payment door does not belong in a stored AI transcript
|
|
208
|
+
// (golden rule 13), and the rest of this surface already sends the customer to their dashboard to act -
|
|
209
|
+
// the same reason invoice PDFs are not here.
|
|
210
|
+
const BILLING_ME_PAYMENT_DUE = ['amountInr', 'since'];
|
|
211
|
+
const BILLING_ME_ITEM = [
|
|
212
|
+
'serviceName', 'kind', 'sizeLabel', 'priceInrMonthly', 'status', 'provisionedAt', 'isComp',
|
|
213
|
+
'pendingAction', 'pendingSizeKey', 'inFlightChange', 'quantity', 'pendingQuantity',
|
|
214
|
+
'storageGb', 'pendingStorageGb',
|
|
215
|
+
];
|
|
216
|
+
const BILLING_ME_SCHEDULED_CHANGE = ['action', 'sizeKey', 'quantity', 'effectiveAt'];
|
|
217
|
+
const BILLING_ME_PLAN = ['active', 'planKey', 'isComp', 'priceInrMonthly', 'maxInstancesPerService'];
|
|
218
|
+
const BILLING_ME_NEXT_DEBIT = ['estimateInrTotal', 'date'];
|
|
219
|
+
|
|
220
|
+
export function billingMe(data) {
|
|
221
|
+
if (!data || typeof data !== 'object') return {};
|
|
222
|
+
return {
|
|
223
|
+
workspace: pick(data.workspace, BILLING_ME_WORKSPACE),
|
|
224
|
+
billing: (() => {
|
|
225
|
+
const b = pick(data.billing, BILLING_ME_BILLING);
|
|
226
|
+
// Always present, explicitly null when nothing is owed - the same distinction scheduledChange draws.
|
|
227
|
+
// An absent key would read as "not checked"; null reads as "checked, nothing due".
|
|
228
|
+
b.paymentDue = data.billing && data.billing.paymentDue
|
|
229
|
+
? pick(data.billing.paymentDue, BILLING_ME_PAYMENT_DUE)
|
|
230
|
+
: null;
|
|
231
|
+
return b;
|
|
232
|
+
})(),
|
|
233
|
+
items: (Array.isArray(data.items) ? data.items : []).map((r) => {
|
|
234
|
+
const out = pick(r, BILLING_ME_ITEM);
|
|
235
|
+
// Always present, explicitly null when there is no booked change - {} would read as "a change, with
|
|
236
|
+
// no fields", the same distinction pickOrNull draws for an absent deploy.
|
|
237
|
+
out.scheduledChange = r && r.scheduledChange ? pick(r.scheduledChange, BILLING_ME_SCHEDULED_CHANGE) : null;
|
|
238
|
+
return out;
|
|
239
|
+
}),
|
|
240
|
+
workspacePlan: pick(data.workspacePlan, BILLING_ME_PLAN),
|
|
241
|
+
nextDebit: data.nextDebit ? pick(data.nextDebit, BILLING_ME_NEXT_DEBIT) : null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- Invoices (GET /billing/invoices -> a bare array) -------------------------------------------------
|
|
246
|
+
// `status` is the DERIVED paid/unpaid state (captured -> paid, refunded -> refunded, else issued), not the
|
|
247
|
+
// raw payment alias. `id` is dropped: it addresses the PDF route, which is not on this surface.
|
|
248
|
+
const BILLING_INVOICE_FIELDS = ['invoiceNumber', 'totalInr', 'issuedAt', 'status'];
|
|
249
|
+
export function billingInvoices(rows) {
|
|
250
|
+
return pickAll(rows, BILLING_INVOICE_FIELDS);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Billing summary (GET /billing/summary) -----------------------------------------------------------
|
|
254
|
+
const BILLING_SUMMARY_FIELDS = [
|
|
255
|
+
'currency', 'subtotalExGst', 'intraState', 'total', 'debitDate',
|
|
256
|
+
'mandateStatus', 'mandateMethod', 'mandateUsable', 'mandateNeedsAction',
|
|
257
|
+
'mandateMaxPaise', 'mandateExpiresAt', 'itemCount',
|
|
258
|
+
];
|
|
259
|
+
const BILLING_SUMMARY_GST = ['cgst', 'sgst', 'igst', 'total'];
|
|
260
|
+
const BILLING_SUMMARY_LINE = ['service', 'size', 'priceInrMonthly'];
|
|
261
|
+
export function billingSummary(data) {
|
|
262
|
+
if (!data || typeof data !== 'object') return {};
|
|
263
|
+
const out = pick(data, BILLING_SUMMARY_FIELDS);
|
|
264
|
+
out.gst = pick(data.gst, BILLING_SUMMARY_GST);
|
|
265
|
+
out.lines = pickAll(data.lines, BILLING_SUMMARY_LINE);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- Quote (GET /billing/quote) -----------------------------------------------------------------------
|
|
270
|
+
// Three shapes, one per arm the route answers with. `intent` is a ROUTING declaration nobody acts on, so it
|
|
271
|
+
// is kept ONLY on the two refusal arms, where it IS the answer, and dropped from the money arm.
|
|
272
|
+
const BILLING_QUOTE_FIELDS = [
|
|
273
|
+
'currency', 'dueNowInr', 'dueNowExGstInr', 'gstInr', 'gstPercent', 'monthlyInr', 'monthlyExGstInr',
|
|
274
|
+
'storageGb', 'storageMonthlyInr', 'storageRatePerGbInr', 'prorationDays', 'cycleDays', 'nextAnchorDate',
|
|
275
|
+
];
|
|
276
|
+
const BILLING_QUOTE_REFUSAL_FIELDS = ['capExceeded', 'chargeable', 'downgrade', 'intent'];
|
|
277
|
+
export function billingQuote(data) {
|
|
278
|
+
if (!data || typeof data !== 'object') return {};
|
|
279
|
+
if (data.capExceeded === true || data.chargeable === false) return pick(data, BILLING_QUOTE_REFUSAL_FIELDS);
|
|
280
|
+
return pick(data, BILLING_QUOTE_FIELDS);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- Action results -----------------------------------------------------------------------------------
|
|
284
|
+
// The lifecycle routes answer `{ ok: true }`. Redeploy/rollback answer `{ ok, building?, deployment }`
|
|
285
|
+
// where `deployment` is the ENGINE's own envelope - which on one path is the upstream provider's raw
|
|
286
|
+
// response and can carry an upstream message string. That envelope is therefore NEVER passed through:
|
|
287
|
+
// only the deployment id is extracted, and only if it is a plain scalar (rule 6, rule 3).
|
|
288
|
+
export function actionResult(data, { message } = {}) {
|
|
289
|
+
const out = { ok: !!(data && data.ok === true) };
|
|
290
|
+
if (data && data.building === true) out.queued = true;
|
|
291
|
+
const dep = data && data.deployment;
|
|
292
|
+
const first = dep && Array.isArray(dep.deployments) ? dep.deployments[0] : null;
|
|
293
|
+
const id = first && first.deployment_uuid;
|
|
294
|
+
if (typeof id === 'string' && id) out.deploymentId = id;
|
|
295
|
+
if (message) out.message = message;
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// pick / pickAll - the projection primitive the Phase B and C tools are built from. `pick` is the one
|
|
300
|
+
// that has been here since v1; it is EXPORTED rather than copied, because a second implementation of the
|
|
301
|
+
// projection rule is the drift this file exists to prevent.
|
|
302
|
+
//
|
|
303
|
+
// They live HERE rather than in tools/reads.js because of what tests/staticScan asserts: every tool module
|
|
304
|
+
// must project through this file, so that the value-level scan sweeps every shaper in one place. A tool
|
|
305
|
+
// with an inline projection is invisible to that scan, which is the shape of bugs #26 and #34 - the guard
|
|
306
|
+
// stays green while the thing it guards has moved out from under it.
|
|
307
|
+
//
|
|
308
|
+
// The rule they enforce is the conservative one golden rule 6 needs: a tool forwards a NAMED subset of a
|
|
309
|
+
// response, never the raw envelope, so a payload that grows an infra field upstream does not start leaking
|
|
310
|
+
// it here by default.
|
|
311
|
+
export const pickAll = (rows, keys) => (Array.isArray(rows) ? rows.map((r) => pick(r, keys)) : []);
|
|
312
|
+
|
|
313
|
+
// Every shaper, by name, so a test can sweep ALL of them against realistic payloads in one place. A
|
|
314
|
+
// whitelist that a new shaper forgets to join is a whitelist with a hole in it.
|
|
315
|
+
export const ALL_SHAPERS = {
|
|
316
|
+
service, serviceStatus, deployment, buildLog, domains, database, usage, actionResult,
|
|
317
|
+
billingMe, billingInvoices, billingSummary, billingQuote,
|
|
318
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The six REVERSIBLE action tools (scope services:act). Each row here must have a matching entry in
|
|
2
|
+
// ../reversibilityMap.json AND a matching services:act capability in the backend allow-list - both
|
|
3
|
+
// directions are CI-asserted by backend/tests/customerMcpCapabilities.test.js, so a tool cannot exist here
|
|
4
|
+
// without a server capability, and a server capability cannot exist without a declared reversibility.
|
|
5
|
+
//
|
|
6
|
+
// Descriptions are written for a MODEL to reason about, not just for a human to read: each one states
|
|
7
|
+
// plainly what happens and, where it matters, what the customer will notice. `stop` says the service goes
|
|
8
|
+
// down, because a description that undersells the effect is how an agent takes a site offline on a vague
|
|
9
|
+
// instruction.
|
|
10
|
+
|
|
11
|
+
import { makeServiceAction } from './_action.js';
|
|
12
|
+
|
|
13
|
+
export const redeploy = makeServiceAction({
|
|
14
|
+
name: 'redeploy',
|
|
15
|
+
path: 'redeploy',
|
|
16
|
+
successMessage: 'Deploy started. Use get_deploy_history and get_build_log to follow it.',
|
|
17
|
+
description:
|
|
18
|
+
'Build and deploy the current code on the service\'s configured branch. The service keeps serving the '
|
|
19
|
+
+ 'existing version while the build runs, and a failed build changes nothing. Reversible: the previous '
|
|
20
|
+
+ 'version stays available through rollback. Rate limited - a repeated call within a short window is '
|
|
21
|
+
+ 'refused.',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const restart = makeServiceAction({
|
|
25
|
+
name: 'restart',
|
|
26
|
+
path: 'restart',
|
|
27
|
+
successMessage: 'Restart requested.',
|
|
28
|
+
description:
|
|
29
|
+
'Restart the running service without rebuilding it. Brief interruption while the container comes back. '
|
|
30
|
+
+ 'No code, configuration or data changes. Reversible.',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const stop = makeServiceAction({
|
|
34
|
+
name: 'stop',
|
|
35
|
+
path: 'stop',
|
|
36
|
+
successMessage: 'Service stopped. It will not serve traffic until you start it again.',
|
|
37
|
+
description:
|
|
38
|
+
'Stop the service. IT WILL STOP SERVING TRAFFIC and will stay down until someone starts it again - '
|
|
39
|
+
+ 'confirm with the person before using this on anything live. Nothing is deleted and no data is lost; '
|
|
40
|
+
+ 'start reverses it.',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const start = makeServiceAction({
|
|
44
|
+
name: 'start',
|
|
45
|
+
path: 'start',
|
|
46
|
+
successMessage: 'Service started.',
|
|
47
|
+
description:
|
|
48
|
+
'Start a stopped service so it serves traffic again. Reversible: stop reverses it.',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const cancelDeploy = makeServiceAction({
|
|
52
|
+
name: 'cancel_deploy',
|
|
53
|
+
path: 'deploy/cancel',
|
|
54
|
+
successMessage: 'Cancel requested. Use get_deploy_history to confirm what stopped.',
|
|
55
|
+
description:
|
|
56
|
+
'Stop a deploy that is still running or waiting. A queued deploy is dropped and a running build is '
|
|
57
|
+
+ 'killed; whatever is serving right now keeps serving. If the new version has already gone live it is '
|
|
58
|
+
+ 'too late and nothing is undone - use rollback for that. Cancelling nothing is not an error. '
|
|
59
|
+
+ 'Reversible: deploy again to re-run it. A cancelled deploy still counts toward the plan\'s build '
|
|
60
|
+
+ 'minutes (time rounds up to whole minutes), so avoid deploy/cancel loops.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Wave 2 (plan 2026-10-27). Both reversible in the plain sense: nothing is destroyed and nothing changes
|
|
64
|
+
// that does not refill or re-read itself.
|
|
65
|
+
export const purgeEdgeCache = makeServiceAction({
|
|
66
|
+
name: 'purge_edge_cache',
|
|
67
|
+
path: 'edge-cache/purge',
|
|
68
|
+
successMessage: 'Cache cleared. Visitors fetch fresh copies on their next request.',
|
|
69
|
+
description:
|
|
70
|
+
'Clear the edge cache of a web service, so every visitor gets the current files on their next request. '
|
|
71
|
+
+ 'Nothing is deleted from the service; the cache simply refills. Does nothing when edge caching is off. '
|
|
72
|
+
+ 'Reversible - there is nothing to reverse.',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const scanDisk = makeServiceAction({
|
|
76
|
+
name: 'scan_disk',
|
|
77
|
+
path: 'disk/scan',
|
|
78
|
+
successMessage: 'Scan complete.',
|
|
79
|
+
description:
|
|
80
|
+
'Measure what is using the service\'s disk right now and return the largest paths. Read-only: it changes '
|
|
81
|
+
+ 'nothing on the service. Rate limited - call it once, not in a loop.',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const rollback = makeServiceAction({
|
|
85
|
+
name: 'rollback',
|
|
86
|
+
path: 'rollback',
|
|
87
|
+
successMessage: 'Rollback started. Use get_service_status to confirm the previous version is serving.',
|
|
88
|
+
description:
|
|
89
|
+
'Serve the previous version of the service again, using the copy already on the host - no rebuild, so '
|
|
90
|
+
+ 'it is fast. If there is no previous version, or the previous version fails to come up, the current '
|
|
91
|
+
+ 'version keeps serving and nothing changes. Reversible: redeploy moves forward again. Not available '
|
|
92
|
+
+ 'for every service; the tool says so plainly when it is not. Each rollback attempt still counts '
|
|
93
|
+
+ 'toward the plan\'s build minutes (rounded up to whole minutes), even when nothing was restored.',
|
|
94
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// get_build_log - READ (scope services:read). The build output for one deploy.
|
|
2
|
+
// Maps to GET /apps/:uuid/deployments/:deploymentId/log.
|
|
3
|
+
//
|
|
4
|
+
// The log body is redacted SERVER-side (per engine) before it leaves Voroa, so this tool does not - and
|
|
5
|
+
// must not claim to - redact anything itself. Old deploys report available:false with reason 'retention'
|
|
6
|
+
// rather than an empty log, so "nothing to show" and "we no longer keep this" stay distinguishable.
|
|
7
|
+
|
|
8
|
+
import { jsonResponse, badInput } from '../lib/response.js';
|
|
9
|
+
import { serviceId, deploymentId } from './_ids.js';
|
|
10
|
+
import { buildLog } from './_shape.js';
|
|
11
|
+
|
|
12
|
+
export const getBuildLog = {
|
|
13
|
+
name: 'get_build_log',
|
|
14
|
+
kind: 'read',
|
|
15
|
+
scope: 'services:read',
|
|
16
|
+
description:
|
|
17
|
+
'Read the build log for one deploy: the phase timeline (clone, install, build, deploy) plus the build '
|
|
18
|
+
+ 'output. This is the tool for "why did my deploy fail". Logs are kept for a limited window; an older '
|
|
19
|
+
+ 'deploy returns available:false with reason "retention". Read-only.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
serviceId: { type: 'string', description: 'The service id, from list_services.' },
|
|
24
|
+
deployId: { type: 'string', description: 'The deploy id, from get_deploy_history.' },
|
|
25
|
+
},
|
|
26
|
+
required: ['serviceId', 'deployId'],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
async handler(args, ctx) {
|
|
30
|
+
const id = serviceId(args.serviceId);
|
|
31
|
+
if (!id) throw badInput('serviceId is required and must be a valid service id from list_services.');
|
|
32
|
+
const dep = deploymentId(args.deployId);
|
|
33
|
+
if (!dep) throw badInput('deployId is required and must be a deploy id from get_deploy_history.');
|
|
34
|
+
const data = await ctx.http.get(
|
|
35
|
+
`/apps/${encodeURIComponent(id)}/deployments/${encodeURIComponent(dep)}/log`
|
|
36
|
+
);
|
|
37
|
+
return jsonResponse(buildLog(data));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// get_deploy_history - READ (scope services:read). Recent deploys for a service, newest first.
|
|
2
|
+
// Maps to GET /apps/:uuid/deployments.
|
|
3
|
+
|
|
4
|
+
import { jsonResponse, badInput } from '../lib/response.js';
|
|
5
|
+
import { serviceId } from './_ids.js';
|
|
6
|
+
import { deployment } from './_shape.js';
|
|
7
|
+
|
|
8
|
+
// Upper bound on what a single tool call prints into the transcript. The API returns its own page; this
|
|
9
|
+
// clamps what we surface so one call cannot flood a conversation window.
|
|
10
|
+
const MAX_ROWS = 30;
|
|
11
|
+
|
|
12
|
+
export const getDeployHistory = {
|
|
13
|
+
name: 'get_deploy_history',
|
|
14
|
+
kind: 'read',
|
|
15
|
+
scope: 'services:read',
|
|
16
|
+
description:
|
|
17
|
+
'List recent deploys for a service, newest first: deploy id, build outcome, honest verdict, commit, '
|
|
18
|
+
+ 'commit message, and start/finish times. Use the deploy id with get_build_log to see why one '
|
|
19
|
+
+ 'failed. Read-only.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
serviceId: { type: 'string', description: 'The service id, from list_services.' },
|
|
24
|
+
limit: { type: 'integer', description: `How many deploys to return (1-${MAX_ROWS}, default 10).` },
|
|
25
|
+
},
|
|
26
|
+
required: ['serviceId'],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
async handler(args, ctx) {
|
|
30
|
+
const id = serviceId(args.serviceId);
|
|
31
|
+
if (!id) throw badInput('serviceId is required and must be a valid service id from list_services.');
|
|
32
|
+
const want = Number.isFinite(Number(args.limit)) ? Number(args.limit) : 10;
|
|
33
|
+
const limit = Math.min(Math.max(Math.trunc(want), 1), MAX_ROWS);
|
|
34
|
+
const data = await ctx.http.get(`/apps/${encodeURIComponent(id)}/deployments`);
|
|
35
|
+
const rows = Array.isArray(data && data.deployments) ? data.deployments : [];
|
|
36
|
+
return jsonResponse({
|
|
37
|
+
deploys: rows.slice(0, limit).map(deployment),
|
|
38
|
+
returned: Math.min(rows.length, limit),
|
|
39
|
+
total: rows.length,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// get_runtime_logs - READ (scope services:read). What the running service has printed.
|
|
2
|
+
// Maps to GET /apps/:uuid/logs?lines=N[&q=TEXT]. `search` (ADR-0370) is the dashboard's search: lines containing the
|
|
3
|
+
// text, case-insensitive, matched by the server over the last 24 hours - so an agent can find one error line
|
|
4
|
+
// without pulling a thousand. Checked here too (<= 200 chars, one line, no backtick) so a bad value is BAD_INPUT
|
|
5
|
+
// before any request, the same rule the backend enforces.
|
|
6
|
+
//
|
|
7
|
+
// `lines` is ALWAYS sent (known bug #8: an unbounded log read is a huge payload). The backend clamps to
|
|
8
|
+
// 1-1000 as well - this clamp is the client half, so a model asking for 100000 gets a sensible answer
|
|
9
|
+
// rather than a rejection.
|
|
10
|
+
|
|
11
|
+
import { jsonResponse, badInput } from '../lib/response.js';
|
|
12
|
+
import { serviceId } from './_ids.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LINES = 200;
|
|
15
|
+
const MAX_LINES = 1000;
|
|
16
|
+
|
|
17
|
+
export const getRuntimeLogs = {
|
|
18
|
+
name: 'get_runtime_logs',
|
|
19
|
+
kind: 'read',
|
|
20
|
+
scope: 'services:read',
|
|
21
|
+
description:
|
|
22
|
+
'Read the most recent output from a running service (its own application logs). Use this for "my app '
|
|
23
|
+
+ 'is up but behaving wrong"; use get_build_log instead for "my deploy failed". Read-only.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
serviceId: { type: 'string', description: 'The service id, from list_services.' },
|
|
28
|
+
lines: { type: 'integer', description: `How many recent lines (1-${MAX_LINES}, default ${DEFAULT_LINES}).` },
|
|
29
|
+
search: { type: 'string', description: 'Optional. Only lines containing this text (case-insensitive), up to 200 characters.' },
|
|
30
|
+
},
|
|
31
|
+
required: ['serviceId'],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
async handler(args, ctx) {
|
|
35
|
+
const id = serviceId(args.serviceId);
|
|
36
|
+
if (!id) throw badInput('serviceId is required and must be a valid service id from list_services.');
|
|
37
|
+
const want = Number.isFinite(Number(args.lines)) ? Number(args.lines) : DEFAULT_LINES;
|
|
38
|
+
const lines = Math.min(Math.max(Math.trunc(want), 1), MAX_LINES);
|
|
39
|
+
const search = typeof args.search === 'string' ? args.search.trim() : '';
|
|
40
|
+
if (search.length > 200 || /[`\r\n]/.test(search)) throw badInput('search must be one line of up to 200 characters, without backticks.');
|
|
41
|
+
const data = await ctx.http.get(`/apps/${encodeURIComponent(id)}/logs`, search ? { lines, q: search } : { lines });
|
|
42
|
+
return jsonResponse({ logs: typeof (data && data.logs) === 'string' ? data.logs : '', lines, ...(search && { search }) });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// get_service_status - READ (scope services:read). The honest current state of one service.
|
|
2
|
+
// Maps to GET /apps/:uuid/status.
|
|
3
|
+
|
|
4
|
+
import { jsonResponse, badInput } from '../lib/response.js';
|
|
5
|
+
import { serviceId } from './_ids.js';
|
|
6
|
+
import { serviceStatus } from './_shape.js';
|
|
7
|
+
|
|
8
|
+
export const getServiceStatus = {
|
|
9
|
+
name: 'get_service_status',
|
|
10
|
+
kind: 'read',
|
|
11
|
+
scope: 'services:read',
|
|
12
|
+
description:
|
|
13
|
+
'Check one service: whether it is running, whether a deploy is in progress, the outcome of the latest '
|
|
14
|
+
+ 'deploy, and its live addresses. Read-only. Fields: `verdict` is the honest outcome of the LATEST '
|
|
15
|
+
+ 'deploy (building, starting, live, failed_to_start, failed, sleeping, suspended - or '
|
|
16
|
+
+ 'skipped/cancelled when the '
|
|
17
|
+
+ 'latest request did not run, e.g. a rollback with nothing to restore). "suspended" means Voroa '
|
|
18
|
+
+ 'stopped the service because it kept restarting without ever staying up; nothing was deleted, and '
|
|
19
|
+
+ 'starting or redeploying it is all it takes to bring it back. `runningState` is what the '
|
|
20
|
+
+ 'service is doing RIGHT NOW (running, starting, restarting, deploying, degraded, failed, stopped, '
|
|
21
|
+
+ 'unknown - or unavailable, meaning the platform could not read the state at that moment: retry '
|
|
22
|
+
+ 'before concluding anything). `health` is its own health check (healthy, unhealthy, unknown - '
|
|
23
|
+
+ '"unknown" usually means no '
|
|
24
|
+
+ 'health check is configured, NOT that anything is wrong). `crashLooping` is true when the service is '
|
|
25
|
+
+ 'repeatedly restarting AND failing its health check: that means it starts, dies, and starts again, so '
|
|
26
|
+
+ 'read the recent output with get_runtime_logs rather than deploying again.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
serviceId: { type: 'string', description: 'The service id, from list_services.' },
|
|
31
|
+
},
|
|
32
|
+
required: ['serviceId'],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
async handler(args, ctx) {
|
|
36
|
+
const id = serviceId(args.serviceId);
|
|
37
|
+
if (!id) throw badInput('serviceId is required and must be a valid service id from list_services.');
|
|
38
|
+
const data = await ctx.http.get(`/apps/${encodeURIComponent(id)}/status`);
|
|
39
|
+
return jsonResponse(serviceStatus(data));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// get_usage - READ (scope services:read). Metered usage for a billing period against the plan's pools.
|
|
2
|
+
// Maps to GET /usage?period=YYYY-MM.
|
|
3
|
+
|
|
4
|
+
import { jsonResponse, badInput } from '../lib/response.js';
|
|
5
|
+
import { period as parsePeriod } from './_ids.js';
|
|
6
|
+
import { usage } from './_shape.js';
|
|
7
|
+
|
|
8
|
+
export const getUsage = {
|
|
9
|
+
name: 'get_usage',
|
|
10
|
+
kind: 'read',
|
|
11
|
+
scope: 'services:read',
|
|
12
|
+
description:
|
|
13
|
+
'Read usage for a billing period, against what the plan includes. The result is grouped by grain: '
|
|
14
|
+
+ '`account` fields (build minutes, total bandwidth) cover the WHOLE account across every workspace; '
|
|
15
|
+
+ '`workspace` fields cover only the workspace this token is bound to. `bandwidthMeasured: false` '
|
|
16
|
+
+ 'means the platform does not measure workspace bandwidth yet - it does NOT mean zero was used. '
|
|
17
|
+
+ 'Read-only - this shows usage, not charges, and nothing here can change a plan or a bill.',
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
period: { type: 'string', description: 'Billing period as YYYY-MM. Omit for the current period.' },
|
|
22
|
+
},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
async handler(args, ctx) {
|
|
26
|
+
let period;
|
|
27
|
+
if (args.period != null && args.period !== '') {
|
|
28
|
+
period = parsePeriod(args.period);
|
|
29
|
+
if (!period) throw badInput('period must be in YYYY-MM form, for example 2026-08.');
|
|
30
|
+
}
|
|
31
|
+
const data = await ctx.http.get('/usage', period ? { period } : undefined);
|
|
32
|
+
return jsonResponse(usage(data));
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// list_databases - READ (scope services:read). The managed databases in this workspace.
|
|
2
|
+
// Maps to GET /databases.
|
|
3
|
+
//
|
|
4
|
+
// Connection credentials are NOT reachable from this surface at all: GET /databases/:uuid/connection is
|
|
5
|
+
// absent from the backend capability allow-list, so there is no argument by which this tool could return
|
|
6
|
+
// a password (golden rule 4 - a stored secret is write-only, and it is certainly not going in a
|
|
7
|
+
// transcript).
|
|
8
|
+
|
|
9
|
+
import { jsonResponse } from '../lib/response.js';
|
|
10
|
+
import { database } from './_shape.js';
|
|
11
|
+
|
|
12
|
+
export const listDatabases = {
|
|
13
|
+
name: 'list_databases',
|
|
14
|
+
kind: 'read',
|
|
15
|
+
scope: 'services:read',
|
|
16
|
+
description:
|
|
17
|
+
'List the managed databases in your workspace with their name, type and state. Connection details and '
|
|
18
|
+
+ 'passwords are NOT available here - get them from your Voroa dashboard. Read-only.',
|
|
19
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
20
|
+
async handler(_args, ctx) {
|
|
21
|
+
const data = await ctx.http.get('/databases');
|
|
22
|
+
const rows = Array.isArray(data && data.databases) ? data.databases : [];
|
|
23
|
+
return jsonResponse({ databases: rows.map(database), count: rows.length });
|
|
24
|
+
},
|
|
25
|
+
};
|