@biznetgio/neo-app 0.4.35 → 0.4.37
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/package.json +1 -1
- package/src/mcp/tools.js +103 -45
package/package.json
CHANGED
package/src/mcp/tools.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { resolve, basename } from 'node:path';
|
|
6
6
|
import { statSync, readFileSync } from 'node:fs';
|
|
7
|
-
import { createHash } from 'node:crypto';
|
|
8
7
|
import { deploySource } from '../deploySource.js';
|
|
9
8
|
import { pkgVersion } from '../util.js';
|
|
10
9
|
import { estimateCost } from '../pricing.js';
|
|
@@ -44,8 +43,8 @@ function requireEnv(env) {
|
|
|
44
43
|
// actually converges (GET → 404), so a delete isn't reported done while the record
|
|
45
44
|
// still exists. Returns a durable operation reference (`del-<id>`) + convergence
|
|
46
45
|
// state (handoff 3.6; 0.4.34 retest §9 "every delete returns a durable operation ID").
|
|
47
|
-
async function deleteAndConverge(api, path, resourceId, kind) {
|
|
48
|
-
const { status, data } = await api.del(path);
|
|
46
|
+
async function deleteAndConverge(api, path, resourceId, kind, delQuery = '') {
|
|
47
|
+
const { status, data } = await api.del(path + delQuery);
|
|
49
48
|
let converged = false, checks = 0;
|
|
50
49
|
for (let i = 0; i < 12; i++) {
|
|
51
50
|
checks++;
|
|
@@ -67,15 +66,6 @@ function gbFromSize(v) {
|
|
|
67
66
|
return m ? parseInt(m[1], 10) : undefined;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
// deletionHashOf is an immutable approval token for a project deletion: a short hash
|
|
71
|
-
// of the exact inventory being deleted. The preview returns it; a cascade delete must
|
|
72
|
-
// echo it back, and the tool recomputes the live inventory and refuses a mismatch — so
|
|
73
|
-
// a project can't be deleted against a stale/partial inventory (0.4.31 retest §7).
|
|
74
|
-
function deletionHashOf(pid, inv, total) {
|
|
75
|
-
const names = Object.entries(inv).flatMap(([k, a]) => a.map((n) => k + ':' + n)).sort();
|
|
76
|
-
return createHash('sha256').update(JSON.stringify({ pid, total, names })).digest('hex').slice(0, 16);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
69
|
// confirmCost is the Approve & Create gate for billable one-shot creates: unless
|
|
80
70
|
// args.confirm===true it returns the live-priced cost and creates NOTHING — the
|
|
81
71
|
// agent shows the user, then re-calls with confirm:true. plan/apply remains the
|
|
@@ -214,6 +204,33 @@ export const TOOLS = [
|
|
|
214
204
|
return (await api.get(`/api/v1/projects/${args.projectId}`)).data;
|
|
215
205
|
},
|
|
216
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: 'neo_get_confirmation',
|
|
209
|
+
description: 'Read a confirmation record (the server-side approval for a high-risk action) by id — its action, status, redacted summary, whether an exact-name phrase is required, and expiry. Read-only; never contains secrets.',
|
|
210
|
+
mutating: false,
|
|
211
|
+
schema: { type: 'object', properties: { confirmationId: str('Confirmation id (from a destructive preview or a plan)') }, required: ['confirmationId'] },
|
|
212
|
+
async call(api, args) {
|
|
213
|
+
return (await api.get(`/api/v1/confirmations/${args.confirmationId}`)).data;
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'neo_confirm_action',
|
|
218
|
+
description: 'Record a human DECISION on a confirmation (NEVER creates or deletes anything itself) — the server-enforced approval step for a high-risk action (project/service deletion, billable create). decision is approve | decline | cancel. Approving a DELETION requires confirmationPhrase to exactly equal the target name (the human must type it). After approve, call the action tool again with this confirmationId to actually perform it (single-use). Show the human the summary from the preview and ask once; never infer consent.',
|
|
219
|
+
mutating: true,
|
|
220
|
+
schema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
confirmationId: str('Confirmation id (from a destructive preview or a plan)'),
|
|
224
|
+
decision: { type: 'string', enum: ['approve', 'decline', 'cancel'], description: 'approve | decline | cancel' },
|
|
225
|
+
confirmationPhrase: str('For a deletion: the EXACT target name, typed by the human, required to approve'),
|
|
226
|
+
},
|
|
227
|
+
required: ['confirmationId', 'decision'],
|
|
228
|
+
},
|
|
229
|
+
async call(api, args) {
|
|
230
|
+
const { status, data } = await api.post(`/api/v1/confirmations/${args.confirmationId}/decide`, { decision: args.decision, confirmationPhrase: args.confirmationPhrase });
|
|
231
|
+
return result(status, data);
|
|
232
|
+
},
|
|
233
|
+
},
|
|
217
234
|
{
|
|
218
235
|
name: 'neo_list_services',
|
|
219
236
|
description: 'List services/apps in a project.',
|
|
@@ -333,7 +350,7 @@ export const TOOLS = [
|
|
|
333
350
|
},
|
|
334
351
|
{
|
|
335
352
|
name: 'neo_scale_service',
|
|
336
|
-
description: 'Scale a service to N replicas. May return pending_approval under org policy.',
|
|
353
|
+
description: 'Scale a service to N replicas. Scaling changes cost, so without confirm:true it returns the new estimated cost and scales nothing (re-call with confirm:true). May return pending_approval under org policy.',
|
|
337
354
|
mutating: true,
|
|
338
355
|
schema: {
|
|
339
356
|
type: 'object',
|
|
@@ -341,10 +358,22 @@ export const TOOLS = [
|
|
|
341
358
|
projectId: str('Project id'),
|
|
342
359
|
service: str('Service/app name'),
|
|
343
360
|
replicas: int('Desired replica count (>=0)'),
|
|
361
|
+
confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true the tool returns the new cost and scales nothing.' },
|
|
344
362
|
},
|
|
345
363
|
required: ['projectId', 'service', 'replicas'],
|
|
346
364
|
},
|
|
347
365
|
async call(api, args) {
|
|
366
|
+
if (args.confirm !== true) {
|
|
367
|
+
let computeTier;
|
|
368
|
+
try {
|
|
369
|
+
const apps = (await api.get(`/api/v1/projects/${args.projectId}/apps`)).data;
|
|
370
|
+
const list = Array.isArray(apps) ? apps : (apps.applications || []);
|
|
371
|
+
const svc = list.find((a) => a.name === args.service || a.id === args.service);
|
|
372
|
+
computeTier = svc && (svc.cpuLimit || svc.computeTier);
|
|
373
|
+
} catch { /* best effort — gate regardless */ }
|
|
374
|
+
const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas });
|
|
375
|
+
if (gate) return { ...gate, change: `scale "${args.service}" to ${args.replicas} replica(s)`, message: 'Scaling changes cost — show the user and re-call with confirm:true to apply.' };
|
|
376
|
+
}
|
|
348
377
|
const { status, data } = await api.post(
|
|
349
378
|
`/api/v1/projects/${args.projectId}/services/${args.service}/scale`,
|
|
350
379
|
{ replicas: args.replicas }
|
|
@@ -641,10 +670,16 @@ export const TOOLS = [
|
|
|
641
670
|
databaseId: str('Database id'),
|
|
642
671
|
volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
|
|
643
672
|
replicas: int('Read/failover replicas (CNPG/postgres); total instances = 1 + replicas'),
|
|
673
|
+
confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/replica resize returns the new cost and applies nothing.' },
|
|
644
674
|
},
|
|
645
675
|
required: ['databaseId'],
|
|
646
676
|
},
|
|
647
677
|
async call(api, args) {
|
|
678
|
+
const costChanging = args.volumeSize || args.replicas !== undefined;
|
|
679
|
+
if (costChanging && args.confirm !== true) {
|
|
680
|
+
const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
|
|
681
|
+
if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — re-call with confirm:true to apply.' };
|
|
682
|
+
}
|
|
648
683
|
const body = {};
|
|
649
684
|
if (args.volumeSize) body.volumeSize = args.volumeSize;
|
|
650
685
|
if (args.replicas !== undefined) body.replicas = args.replicas;
|
|
@@ -807,11 +842,17 @@ export const TOOLS = [
|
|
|
807
842
|
dlqEnabled: { type: 'boolean', description: 'Enable/disable the dead-letter queue' },
|
|
808
843
|
volumeSize: str('New storage size, e.g. "20Gi" — must be >= current (grow-only)'),
|
|
809
844
|
retention: { type: 'object', description: 'Retention policy {maxAgeSeconds, maxBytes, maxMsgs}' },
|
|
845
|
+
confirm: { type: 'boolean', description: 'Approve the cost change: without confirm:true a storage/plan change returns the new cost and applies nothing. DLQ/retention-only edits apply directly.' },
|
|
810
846
|
},
|
|
811
847
|
required: ['queueId'],
|
|
812
848
|
},
|
|
813
849
|
async call(api, args) {
|
|
814
|
-
const
|
|
850
|
+
const costChanging = args.volumeSize || args.planClass;
|
|
851
|
+
if (costChanging && args.confirm !== true) {
|
|
852
|
+
const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
|
|
853
|
+
if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'This changes cost — re-call with confirm:true to apply.' };
|
|
854
|
+
}
|
|
855
|
+
const { queueId, confirm, orgId, ...body } = args;
|
|
815
856
|
const { status, data } = await api.put(`/api/v1/queues/${queueId}`, body);
|
|
816
857
|
return redactSecrets(result(status, data));
|
|
817
858
|
},
|
|
@@ -1262,12 +1303,34 @@ export const TOOLS = [
|
|
|
1262
1303
|
// the server hard-deletes the record and tears down the running workload. ---
|
|
1263
1304
|
{
|
|
1264
1305
|
name: 'neo_delete_service',
|
|
1265
|
-
description: '
|
|
1306
|
+
description: 'Delete a service (web / worker / static_site) and tear down its workload. SAFE BY DEFAULT: with no confirmationId it returns the service details + a server-side CONFIRMATION, deleting NOTHING. Then: show the user, call neo_confirm_action(confirmationId, "approve", confirmationPhrase=<EXACT service name>), then re-call with that confirmationId. Server-enforced, single-use, exact-name. Destructive and irreversible.',
|
|
1266
1307
|
mutating: true,
|
|
1267
1308
|
destructive: true,
|
|
1268
|
-
schema: {
|
|
1309
|
+
schema: {
|
|
1310
|
+
type: 'object',
|
|
1311
|
+
properties: {
|
|
1312
|
+
appId: str('Service/app id (from neo_list_services)'),
|
|
1313
|
+
confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the confirmation.'),
|
|
1314
|
+
},
|
|
1315
|
+
required: ['appId'],
|
|
1316
|
+
},
|
|
1269
1317
|
async call(api, args) {
|
|
1270
|
-
|
|
1318
|
+
if (args.confirmationId) {
|
|
1319
|
+
return deleteAndConverge(api, `/api/v1/apps/${args.appId}`, args.appId, 'service', `?confirmationId=${encodeURIComponent(args.confirmationId)}`);
|
|
1320
|
+
}
|
|
1321
|
+
let svc = null;
|
|
1322
|
+
try { svc = (await api.get(`/api/v1/apps/${args.appId}`)).data; } catch { /* preview best-effort */ }
|
|
1323
|
+
let c;
|
|
1324
|
+
try { c = (await api.post('/api/v1/confirmations', { action: 'service_delete', targetId: args.appId })).data; }
|
|
1325
|
+
catch (e) { return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) }; }
|
|
1326
|
+
const phrase = (c.summary && c.summary.phraseRequired) || (svc && svc.name);
|
|
1327
|
+
return {
|
|
1328
|
+
deleted: false, requiresConfirmation: true,
|
|
1329
|
+
service: svc ? { id: svc.id, name: svc.name, type: svc.serviceType, environment: svc.environment } : { id: args.appId },
|
|
1330
|
+
warning: 'IRREVERSIBLE: deletes the service and tears down its running workload.',
|
|
1331
|
+
confirmationId: c.confirmationId, phraseRequired: phrase,
|
|
1332
|
+
message: `To delete: call neo_confirm_action("${c.confirmationId}", "approve", confirmationPhrase="${phrase}") — the human must type the exact service name — then re-call neo_delete_service with confirmationId="${c.confirmationId}".`,
|
|
1333
|
+
};
|
|
1271
1334
|
},
|
|
1272
1335
|
},
|
|
1273
1336
|
{
|
|
@@ -1366,21 +1429,24 @@ export const TOOLS = [
|
|
|
1366
1429
|
},
|
|
1367
1430
|
{
|
|
1368
1431
|
name: 'neo_delete_project',
|
|
1369
|
-
description: 'Delete a project and ALL its resources (apps, databases, queues, crons, buckets, namespace + PVCs/CNPG). SAFE BY DEFAULT:
|
|
1432
|
+
description: 'Delete a project and ALL its resources (apps, databases, queues, crons, buckets, namespace + PVCs/CNPG). SAFE BY DEFAULT: with no confirmationId it returns a PREVIEW (name, org, full inventory, metering, no-refund) AND a server-side CONFIRMATION, deleting NOTHING. Then: show the user, call neo_confirm_action(confirmationId, "approve", confirmationPhrase=<EXACT project name>), then re-call this tool with that confirmationId. Server-enforced, single-use, exact-name. Destructive and irreversible. Only the creator or an org owner/admin may delete.',
|
|
1370
1433
|
mutating: true,
|
|
1371
1434
|
destructive: true,
|
|
1372
1435
|
schema: {
|
|
1373
1436
|
type: 'object',
|
|
1374
1437
|
properties: {
|
|
1375
1438
|
projectId: str('Project id'),
|
|
1376
|
-
|
|
1377
|
-
cascade: { type: 'boolean', description: 'Required true to delete a NON-EMPTY project — acknowledges every resource in the inventory is destroyed.' },
|
|
1378
|
-
deletionHash: str('For a non-empty project: the deletionHash from the preview. The tool refuses if the live inventory no longer matches it (immutable approval).'),
|
|
1439
|
+
confirmationId: str('An APPROVED confirmation id (from neo_confirm_action). Omit to get the preview + a fresh confirmation.'),
|
|
1379
1440
|
},
|
|
1380
1441
|
required: ['projectId'],
|
|
1381
1442
|
},
|
|
1382
1443
|
async call(api, args) {
|
|
1383
1444
|
const pid = args.projectId;
|
|
1445
|
+
// With an approved confirmation: the server consumes it atomically and deletes.
|
|
1446
|
+
if (args.confirmationId) {
|
|
1447
|
+
const { status, data } = await api.del(`/api/v1/projects/${pid}?confirmationId=${encodeURIComponent(args.confirmationId)}`);
|
|
1448
|
+
return { deleted: true, project: { id: pid }, result: result(status, data) };
|
|
1449
|
+
}
|
|
1384
1450
|
// Gather inventory + usage BEFORE any delete (this is also the metering snapshot).
|
|
1385
1451
|
const safeGet = async (p) => { try { return (await api.get(p)).data; } catch { return null; } };
|
|
1386
1452
|
const [proj, apps, dbs, queues, crons, buckets, usage] = await Promise.all([
|
|
@@ -1405,36 +1471,28 @@ export const TOOLS = [
|
|
|
1405
1471
|
// project is empty — treat it as non-empty so the empty-path can't silently
|
|
1406
1472
|
// delete hidden resources (the preview must be trustworthy).
|
|
1407
1473
|
const inventoryIncomplete = [apps, dbs, queues, crons, buckets].some((x) => x === null);
|
|
1408
|
-
|
|
1474
|
+
// Create a server-side, single-use, exact-name confirmation for this deletion.
|
|
1475
|
+
let confirmationId, phraseRequired = proj && proj.name;
|
|
1476
|
+
try {
|
|
1477
|
+
const c = (await api.post('/api/v1/confirmations', { action: 'project_delete', projectId: pid })).data;
|
|
1478
|
+
confirmationId = c.confirmationId;
|
|
1479
|
+
phraseRequired = (c.summary && c.summary.phraseRequired) || phraseRequired;
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
return { deleted: false, error: 'confirmation_failed', message: 'Could not create a deletion confirmation: ' + (e.message || e) };
|
|
1482
|
+
}
|
|
1483
|
+
return {
|
|
1484
|
+
deleted: false,
|
|
1485
|
+
requiresConfirmation: true,
|
|
1409
1486
|
project: proj ? { id: proj.id, name: proj.name, organizationId: proj.organizationId } : { id: pid },
|
|
1410
1487
|
resourceInventory: inv,
|
|
1411
1488
|
resourceCount: total,
|
|
1412
1489
|
meteringSnapshot: usage ? { period: usage.period, total: usage.total ?? usage.totalCost, currency: usage.currency } : null,
|
|
1413
1490
|
inventoryIncomplete: inventoryIncomplete || undefined,
|
|
1414
|
-
|
|
1415
|
-
|
|
1491
|
+
warning: 'IRREVERSIBLE: deletes the project, every resource above, and their data (database/queue volumes included). No refund for prepaid or accrued usage. The organization itself remains.',
|
|
1492
|
+
confirmationId,
|
|
1493
|
+
phraseRequired,
|
|
1494
|
+
message: `Show the user this inventory. To delete: call neo_confirm_action("${confirmationId}", "approve", confirmationPhrase="${phraseRequired}") — the human must type the exact project name — then re-call neo_delete_project with confirmationId="${confirmationId}".`,
|
|
1416
1495
|
};
|
|
1417
|
-
// Treat an unverifiable inventory as non-empty — never allow the no-cascade path
|
|
1418
|
-
// when we could not confirm the project is actually empty.
|
|
1419
|
-
const requireCascade = total > 0 || inventoryIncomplete;
|
|
1420
|
-
if (args.confirm !== true) {
|
|
1421
|
-
return { ...preview, deleted: false, requiresConfirmation: true, message: requireCascade ? 'Show this inventory to the user. To delete, re-call with confirm:true, cascade:true, and deletionHash.' : 'Empty project. To delete, re-call with confirm:true.' };
|
|
1422
|
-
}
|
|
1423
|
-
if (requireCascade && args.cascade !== true) {
|
|
1424
|
-
return { ...preview, deleted: false, error: 'cascade_required', message: inventoryIncomplete ? 'Could not fully verify the project inventory — re-call with confirm:true AND cascade:true to delete anyway.' : `This project has ${total} resource(s). Re-call with confirm:true AND cascade:true to delete them all.` };
|
|
1425
|
-
}
|
|
1426
|
-
// Immutable approval: a non-empty cascade delete must echo the preview's
|
|
1427
|
-
// deletionHash, and it must still match the live inventory.
|
|
1428
|
-
if (total > 0) {
|
|
1429
|
-
if (!args.deletionHash) {
|
|
1430
|
-
return { ...preview, deleted: false, error: 'deletion_hash_required', message: 'Pass deletionHash (from the preview) to confirm you are deleting exactly this inventory.' };
|
|
1431
|
-
}
|
|
1432
|
-
if (args.deletionHash !== preview.deletionHash) {
|
|
1433
|
-
return { ...preview, deleted: false, error: 'inventory_changed', message: 'The project inventory changed since your preview — re-preview and pass the new deletionHash.' };
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
const { status, data } = await api.del(`/api/v1/projects/${pid}`);
|
|
1437
|
-
return { deleted: true, project: preview.project, removed: inv, resourceCount: total, meteringSnapshot: preview.meteringSnapshot, result: result(status, data) };
|
|
1438
1496
|
},
|
|
1439
1497
|
},
|
|
1440
1498
|
];
|