@biznetgio/neo-app 0.4.63 → 0.4.65
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 +51 -8
package/package.json
CHANGED
package/src/mcp/tools.js
CHANGED
|
@@ -68,6 +68,19 @@ const allowCrossEnvProp = { allowCrossEnvironment: { type: 'boolean', descriptio
|
|
|
68
68
|
// invariant in sequence (each must start from a clean, unpinned session).
|
|
69
69
|
export function resetSessionEnv() { sessionEnv = null; }
|
|
70
70
|
|
|
71
|
+
// assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
|
|
72
|
+
// if the session is pinned and the resource lives in a different environment, refuse so an
|
|
73
|
+
// ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
|
|
74
|
+
// resources with no environment (buckets are shared) and an unpinned session are not guarded.
|
|
75
|
+
async function assertResourceEnv(api, getPath, allowCross = false) {
|
|
76
|
+
if (!sessionEnv || allowCross) return;
|
|
77
|
+
let env;
|
|
78
|
+
try { env = (await api.get(getPath)).data?.environment; } catch { return; } // can't resolve → don't block
|
|
79
|
+
if (env && env !== sessionEnv) {
|
|
80
|
+
throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that resource is in "${env}". Staging and production must not be mixed (JNG #136). Pass allowCrossEnvironment:true to act on it anyway, or re-pin with neo_set_context.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
// deleteAndConverge deletes a resource, then polls its endpoint until the inventory
|
|
72
85
|
// actually converges (GET → 404), so a delete isn't reported done while the record
|
|
73
86
|
// still exists. Returns a durable operation reference (`del-<id>`) + convergence
|
|
@@ -443,11 +456,13 @@ export const TOOLS = [
|
|
|
443
456
|
healthTimeoutSeconds: int('Health-check timeout (s)'),
|
|
444
457
|
healthGraceSeconds: int('Health-check grace period (s)'),
|
|
445
458
|
confirm: { type: 'boolean', description: 'Approve a cost change (compute/replicas/autoscale/volume): without it the new cost is returned and nothing is applied.' },
|
|
459
|
+
...allowCrossEnvProp,
|
|
446
460
|
},
|
|
447
461
|
required: ['appId'],
|
|
448
462
|
},
|
|
449
463
|
async call(api, args) {
|
|
450
|
-
const { appId, confirm, orgId, computeTier, ...rest } = args;
|
|
464
|
+
const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
|
|
465
|
+
await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
|
|
451
466
|
const body = { ...rest };
|
|
452
467
|
if (computeTier) { body.billingMode = 'bundled'; body.cpuLimit = computeTier; }
|
|
453
468
|
else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
|
|
@@ -595,8 +610,9 @@ export const TOOLS = [
|
|
|
595
610
|
description:
|
|
596
611
|
'Trigger a deployment of an app (service). Returns a deployment id — pass it to neo_wait_deployment to block until it finishes (don\'t sleep/guess), or a pending_approval result when org policy gates it. Deploy to staging first.',
|
|
597
612
|
mutating: true,
|
|
598
|
-
schema: { type: 'object', properties: { appId: str('App/service id') }, required: ['appId'] },
|
|
613
|
+
schema: { type: 'object', properties: { appId: str('App/service id'), ...allowCrossEnvProp }, required: ['appId'] },
|
|
599
614
|
async call(api, args) {
|
|
615
|
+
await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
600
616
|
const { status, data } = await api.post(`/api/v1/apps/${args.appId}/deploy`);
|
|
601
617
|
return result(status, data);
|
|
602
618
|
},
|
|
@@ -679,8 +695,9 @@ export const TOOLS = [
|
|
|
679
695
|
name: 'neo_restart_service',
|
|
680
696
|
description: 'Rolling-restart a service with its CURRENT image + config — no rebuild (JNG #128). Use after a config-only change (neo_set_service_variable, neo_update_service) that doesn\'t need a new build, instead of a full neo_deploy. Returns immediately; poll neo_list_pods for the new pods.',
|
|
681
697
|
mutating: true,
|
|
682
|
-
schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)') }, required: ['appId'] },
|
|
698
|
+
schema: { type: 'object', properties: { appId: str('Service/app id (from neo_list_services)'), ...allowCrossEnvProp }, required: ['appId'] },
|
|
683
699
|
async call(api, args) {
|
|
700
|
+
await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
684
701
|
return (await api.post(`/api/v1/apps/${args.appId}/restart`, {})).data;
|
|
685
702
|
},
|
|
686
703
|
},
|
|
@@ -695,11 +712,13 @@ export const TOOLS = [
|
|
|
695
712
|
command: str('Shell command to run (executed via /bin/sh -c)'),
|
|
696
713
|
pod: str('Specific pod name (optional; from neo_list_pods) — else the first running pod'),
|
|
697
714
|
timeoutSeconds: int('Max seconds to wait (default 30, max 120)'),
|
|
715
|
+
...allowCrossEnvProp,
|
|
698
716
|
},
|
|
699
717
|
required: ['appId', 'command'],
|
|
700
718
|
},
|
|
701
719
|
async call(api, args) {
|
|
702
|
-
const { appId, ...body } = args;
|
|
720
|
+
const { appId, allowCrossEnvironment, ...body } = args;
|
|
721
|
+
await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
|
|
703
722
|
return (await api.post(`/api/v1/apps/${appId}/exec`, body)).data;
|
|
704
723
|
},
|
|
705
724
|
},
|
|
@@ -722,10 +741,12 @@ export const TOOLS = [
|
|
|
722
741
|
databaseId: str('Database id (postgres, deployed)'),
|
|
723
742
|
sql: str('SQL to run (piped into psql)'),
|
|
724
743
|
readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
|
|
744
|
+
...allowCrossEnvProp,
|
|
725
745
|
},
|
|
726
746
|
required: ['databaseId', 'sql'],
|
|
727
747
|
},
|
|
728
748
|
async call(api, args) {
|
|
749
|
+
await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
729
750
|
return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
|
|
730
751
|
},
|
|
731
752
|
},
|
|
@@ -772,10 +793,12 @@ export const TOOLS = [
|
|
|
772
793
|
key: str('Variable name'),
|
|
773
794
|
value: str('Variable value'),
|
|
774
795
|
secret: { type: 'boolean', description: 'Store as a secret (default false)' },
|
|
796
|
+
...allowCrossEnvProp,
|
|
775
797
|
},
|
|
776
798
|
required: ['appId', 'key', 'value'],
|
|
777
799
|
},
|
|
778
800
|
async call(api, args) {
|
|
801
|
+
await assertResourceEnv(api, `/api/v1/apps/${args.appId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
779
802
|
const { status, data } = await api.post(`/api/v1/apps/${args.appId}/variables`, { key: args.key, value: args.value, secret: !!args.secret });
|
|
780
803
|
return result(status, data); // {status:"set", key, redeployRequired} — no value returned
|
|
781
804
|
},
|
|
@@ -1420,7 +1443,7 @@ export const TOOLS = [
|
|
|
1420
1443
|
},
|
|
1421
1444
|
{
|
|
1422
1445
|
name: 'neo_list_cron_runs',
|
|
1423
|
-
description: 'List the recent runs of a scheduled job (cron)
|
|
1446
|
+
description: 'List the recent runs of a scheduled job (cron): status (running|succeeded|failed), startedAt/finishedAt, and for a FAILED run the terminal diagnostics — reason, message, and the container exitCode — so you can see WHY it failed, not just that it did (JNG #112, handoff 4.6).',
|
|
1424
1447
|
mutating: false,
|
|
1425
1448
|
schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
|
|
1426
1449
|
async call(api, args) {
|
|
@@ -1541,11 +1564,29 @@ export const TOOLS = [
|
|
|
1541
1564
|
targetType: { type: 'string', enum: ['neo_db', 'neo_queue', 'neo_object_storage'], description: 'neo_db | neo_queue | neo_object_storage — the resource to attach' },
|
|
1542
1565
|
targetId: str('The database, queue, or bucket id to bind'),
|
|
1543
1566
|
envKey: str('Env var name / prefix (optional; default DATABASE_URL, NATS_URL, or S3 prefix)'),
|
|
1567
|
+
...allowCrossEnvProp,
|
|
1544
1568
|
},
|
|
1545
1569
|
required: ['appId', 'targetType', 'targetId'],
|
|
1546
1570
|
},
|
|
1547
1571
|
async call(api, args) {
|
|
1548
|
-
const { appId, ...body } = args;
|
|
1572
|
+
const { appId, allowCrossEnvironment, ...body } = args;
|
|
1573
|
+
// Environment isolation (JNG #136 §4): the consumer must match the session pin, and a
|
|
1574
|
+
// consumer must not bind a target from a DIFFERENT environment. Buckets are shared (no env).
|
|
1575
|
+
if (!allowCrossEnvironment) {
|
|
1576
|
+
let cEnv;
|
|
1577
|
+
try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
|
|
1578
|
+
if (sessionEnv && cEnv && cEnv !== sessionEnv) {
|
|
1579
|
+
throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" but that service is in "${cEnv}" (JNG #136). Pass allowCrossEnvironment:true to override, or re-pin with neo_set_context.`);
|
|
1580
|
+
}
|
|
1581
|
+
if (args.targetType !== 'neo_object_storage') {
|
|
1582
|
+
const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
|
|
1583
|
+
let tEnv;
|
|
1584
|
+
try { tEnv = (await api.get(tPath)).data?.environment; } catch { /* ignore */ }
|
|
1585
|
+
if (cEnv && tEnv && cEnv !== tEnv) {
|
|
1586
|
+
throw new Error(`environment_mismatch: cannot bind a "${tEnv}" ${args.targetType} to a "${cEnv}" service — binding across environments is refused (JNG #136). Pass allowCrossEnvironment:true to override.`);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1549
1590
|
const { status, data } = await api.post(`/api/v1/apps/${appId}/bindings`, body);
|
|
1550
1591
|
return redactSecrets(result(status, data));
|
|
1551
1592
|
},
|
|
@@ -1639,6 +1680,7 @@ export const TOOLS = [
|
|
|
1639
1680
|
image: str('app/cron: prebuilt image reference'),
|
|
1640
1681
|
repoUrl: str('app: git repository URL'),
|
|
1641
1682
|
branch: str('app: git branch'),
|
|
1683
|
+
deps: { type: 'array', items: { type: 'string', enum: ['database', 'queue', 'object_storage'] }, description: 'app: managed-resource dependencies this service needs (from neo_scan_repo `deps`). The plan flags any dep not satisfied by a binding as incompleteTopology and apply refuses it unless acknowledged — so a service needing NATS_URL/DATABASE_URL isn\'t deployed into a 503 (JNG #112 §7).' },
|
|
1642
1684
|
port: int('app: container port'),
|
|
1643
1685
|
computeTier: str('app/db/queue: micro | small | medium | large | xlarge'),
|
|
1644
1686
|
engine: str('database: postgres | mysql'),
|
|
@@ -1697,18 +1739,19 @@ export const TOOLS = [
|
|
|
1697
1739
|
{
|
|
1698
1740
|
name: 'neo_apply_stack',
|
|
1699
1741
|
description:
|
|
1700
|
-
'Apply a planned stack. REQUIRES the planId and the exact planHash from neo_plan_stack — the SERVER rejects a missing, changed, expired, or already-applied plan, so a billable stack can never be created without first creating + reviewing its costed plan (handoff 3.4/3.5, server-enforced approval). Creates resources in dependency order (foundations → services → bindings → deploy) and returns an operationId to poll with neo_get_operation.',
|
|
1742
|
+
'Apply a planned stack. REQUIRES the planId and the exact planHash from neo_plan_stack — the SERVER rejects a missing, changed, expired, or already-applied plan, so a billable stack can never be created without first creating + reviewing its costed plan (handoff 3.4/3.5, server-enforced approval). Creates resources in dependency order (foundations → services → bindings → deploy) and returns an operationId to poll with neo_get_operation. If the plan has an incompleteTopology (a service dependency not bound), apply returns error "incomplete_topology" — wire the missing resource + re-plan, or pass acknowledgeIncompleteTopology:true to deploy anyway.',
|
|
1701
1743
|
mutating: true,
|
|
1702
1744
|
schema: {
|
|
1703
1745
|
type: 'object',
|
|
1704
1746
|
properties: {
|
|
1705
1747
|
planId: str('Plan id from neo_plan_stack'),
|
|
1706
1748
|
planHash: str('Plan hash from neo_plan_stack — must match exactly (proves you reviewed this plan)'),
|
|
1749
|
+
acknowledgeIncompleteTopology: { type: 'boolean', description: 'Deploy past a detected unmet dependency (incompleteTopology) — e.g. a service that needs NATS_URL with no queue bound. Default false refuses it (JNG #112 §7).' },
|
|
1707
1750
|
},
|
|
1708
1751
|
required: ['planId', 'planHash'],
|
|
1709
1752
|
},
|
|
1710
1753
|
async call(api, args) {
|
|
1711
|
-
const { status, data } = await api.post(`/api/v1/plans/${args.planId}/apply`, { planHash: args.planHash });
|
|
1754
|
+
const { status, data } = await api.post(`/api/v1/plans/${args.planId}/apply`, { planHash: args.planHash, acknowledgeIncompleteTopology: !!args.acknowledgeIncompleteTopology });
|
|
1712
1755
|
return result(status, data);
|
|
1713
1756
|
},
|
|
1714
1757
|
},
|