@biznetgio/neo-app 0.4.70 → 0.4.74
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/prompt.md +4 -3
- package/src/commands/deploy.js +13 -0
- package/src/commands/estimate.js +82 -0
- package/src/cost.js +7 -0
- package/src/index.js +30 -3
- package/src/mcp/server.js +117 -16
- package/src/mcp/tools.js +132 -72
package/package.json
CHANGED
package/prompt.md
CHANGED
|
@@ -6,7 +6,7 @@ Keep this in sync with cli/prompt.md (the npm package's bundled copy).
|
|
|
6
6
|
-->
|
|
7
7
|
# Set this repo up on NEO App
|
|
8
8
|
|
|
9
|
-
**Version:** 0.4.
|
|
9
|
+
**Version:** 0.4.74 · **Last updated:** 2026-11-05
|
|
10
10
|
|
|
11
11
|
These are the official setup instructions from NEO App (Biznet Gio's managed-app
|
|
12
12
|
platform). If you are an AI coding agent, complete the steps below yourself by running
|
|
@@ -17,7 +17,7 @@ into chat, repository files, the skill, or MCP configuration.
|
|
|
17
17
|
## What NEO App runs
|
|
18
18
|
|
|
19
19
|
Web services, background workers, scheduled jobs, static sites, managed databases
|
|
20
|
-
(PostgreSQL/MySQL
|
|
20
|
+
(PostgreSQL/MySQL/Redis), queues (NATS), and object storage — on managed Kubernetes, served
|
|
21
21
|
over HTTPS with automatic TLS. It is **not** for GPU training or for apps that need
|
|
22
22
|
several containers running side by side at runtime (a single app container alongside
|
|
23
23
|
managed databases is fine). If the repo has no server to put behind a URL (a library or
|
|
@@ -118,7 +118,8 @@ tools — never the portal UI. The lifecycle is the same for every resource type
|
|
|
118
118
|
components in a monorepo. Ask the human which to deploy; never collapse a monorepo
|
|
119
119
|
into one service.
|
|
120
120
|
2. **Plan and show the cost**: `neo_plan_stack` returns an immutable, priced plan
|
|
121
|
-
(`planId`, `planHash`, cost in IDR, expiry). Show
|
|
121
|
+
(`planId`, `planHash`, cost in IDR with monthly and hourly totals, expiry). Show
|
|
122
|
+
the human the cost and get an
|
|
122
123
|
explicit **Approve & Create**. Individual creates (`neo_create_database`,
|
|
123
124
|
`neo_create_queue`) instead return the cost and create nothing until you re-call
|
|
124
125
|
with `confirm: true`.
|
package/src/commands/deploy.js
CHANGED
|
@@ -35,6 +35,9 @@ function showCost(r) {
|
|
|
35
35
|
const e = r.estimatedCost;
|
|
36
36
|
const note = (e.assumptions || []).join('; ');
|
|
37
37
|
log(c.bold(`Estimated cost: ${formatCost(e)}`) + (note ? c.dim(` — ${note}`) : ''));
|
|
38
|
+
// Identify the exact price-book snapshot the quote (and any approval) is pinned to (#170 r18a D1).
|
|
39
|
+
const ver = e.priceBookVersion || r.priceBookVersion;
|
|
40
|
+
if (ver) log(c.dim(` price book: ${ver}`));
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -95,6 +98,16 @@ export async function deploy(source, opts = {}) {
|
|
|
95
98
|
log: step,
|
|
96
99
|
};
|
|
97
100
|
|
|
101
|
+
// --json: machine-readable + non-interactive. A real deploy still requires --yes; otherwise
|
|
102
|
+
// deploySource returns the priced gate (requiresConfirmation + estimatedCost + confirmToken)
|
|
103
|
+
// and deploys nothing. One shape, mirroring the MCP cost schema (#170).
|
|
104
|
+
if (opts.json) {
|
|
105
|
+
const r = await deploySource(api, { ...base, log: () => {}, dryRun, confirm: !!opts.yes });
|
|
106
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
107
|
+
if (r.result && r.result !== 'success' && !r.dryRun && !r.requiresConfirmation && !r.pending) process.exitCode = 1;
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
// Cost gate: a real deploy is billable, so it needs explicit approval. `--yes` approves
|
|
99
112
|
// up front (headless/CI); otherwise preview the cost first, then confirm (interactive)
|
|
100
113
|
// or require `--yes` (non-interactive). A spec-bound confirmToken links the approval to
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `neo-app estimate` — a read-only cost quote from the server's authoritative price book
|
|
2
|
+
// (#170 r18a D2). Works WITHOUT a project (org-scoped), unlike `deploy --dry-run` which
|
|
3
|
+
// needs one, so a vibe coder can price a resource before creating anything. Zero-write.
|
|
4
|
+
|
|
5
|
+
import { resolveConfig } from '../config.js';
|
|
6
|
+
import { getCredential } from '../creds.js';
|
|
7
|
+
import { NeoApi } from '../api.js';
|
|
8
|
+
import { readLink } from '../link.js';
|
|
9
|
+
import { specToResources, formatCost } from '../cost.js';
|
|
10
|
+
import { log, ok, fail, c } from '../util.js';
|
|
11
|
+
|
|
12
|
+
// buildSpec turns the CLI flags into the flat cost spec specToResources understands.
|
|
13
|
+
function buildSpec(opts) {
|
|
14
|
+
const spec = {};
|
|
15
|
+
if (opts.tier) spec.computeTier = opts.tier;
|
|
16
|
+
if (opts.vcpu) spec.vcpu = Number(opts.vcpu);
|
|
17
|
+
if (opts.mem) spec.memoryMB = Number(opts.mem);
|
|
18
|
+
if (opts.storage) spec.storageGB = Number(opts.storage);
|
|
19
|
+
if (opts.object) spec.objectStorageGB = Number(opts.object);
|
|
20
|
+
if (opts.replicas) spec.replicas = Number(opts.replicas);
|
|
21
|
+
if (opts.queue) spec.queue = true;
|
|
22
|
+
if (opts.cron) spec.cron = true;
|
|
23
|
+
return spec;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function estimate(opts = {}) {
|
|
27
|
+
const cfg = resolveConfig(opts);
|
|
28
|
+
const cred = getCredential(cfg.baseUrl);
|
|
29
|
+
if (!cred?.token) {
|
|
30
|
+
fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
|
|
34
|
+
|
|
35
|
+
const spec = buildSpec(opts);
|
|
36
|
+
const resources = specToResources(spec);
|
|
37
|
+
if (!resources.length) {
|
|
38
|
+
fail('nothing to estimate — pass --tier <micro|small|…> (or --vcpu N --mem MB), --storage GB, --object GB, --queue, or --cron');
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scope: an explicit/linked project (exact org context) else the account's org — the
|
|
43
|
+
// server authority is the same, so a project-less quote still works.
|
|
44
|
+
const projectId = opts.project || readLink()?.projectId;
|
|
45
|
+
let scope;
|
|
46
|
+
if (projectId) scope = { projectId };
|
|
47
|
+
else {
|
|
48
|
+
let orgId = opts.org;
|
|
49
|
+
if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
|
|
50
|
+
if (!orgId) { fail('no project or organization — run \'neo-app login\', or pass --project <id> / --org <id>'); process.exit(1); }
|
|
51
|
+
scope = { orgId };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cost;
|
|
55
|
+
try {
|
|
56
|
+
cost = (await api.estimate(scope, { resources })).cost;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
fail(`estimate failed: ${e.message || e}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fail closed when the server can't price a metric (#170: never quote at a guessed rate).
|
|
63
|
+
if (cost?.pricingUnavailable?.length) {
|
|
64
|
+
if (opts.json) process.stdout.write(JSON.stringify({ cost }, null, 2) + '\n');
|
|
65
|
+
fail(`pricing unavailable for: ${cost.pricingUnavailable.join(', ')} — cannot quote`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
process.stdout.write(JSON.stringify({ cost }, null, 2) + '\n');
|
|
71
|
+
return cost;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log(c.bold(`Estimated cost: ${formatCost(cost)}`));
|
|
75
|
+
for (const l of cost.breakdown || []) log(` · ${l.item}: ${l.monthly} ${cost.currency}/mo (${l.hourly}/h)`);
|
|
76
|
+
for (const u of cost.upfront || []) log(` · ${u.item}: ${u.monthly} ${cost.currency} upfront (hourly: n/a)`);
|
|
77
|
+
for (const v of cost.variable || []) log(c.dim(` usage: ${v.rate} ${cost.currency}/${v.unit || v.metric}`));
|
|
78
|
+
if (cost.priceBookVersion) log(c.dim(` price book: ${cost.priceBookVersion} · billing quantum: ${cost.billingQuantum || 'hour'}`));
|
|
79
|
+
for (const a of cost.assumptions || []) log(c.dim(` assumption: ${a}`));
|
|
80
|
+
ok('estimate complete — nothing was created');
|
|
81
|
+
return cost;
|
|
82
|
+
}
|
package/src/cost.js
CHANGED
|
@@ -26,6 +26,13 @@ export function specToResources(spec = {}) {
|
|
|
26
26
|
}
|
|
27
27
|
if (spec.storageGB) resources.push({ kind: 'database', name: 'storage', volumeSize: `${spec.storageGB}Gi` });
|
|
28
28
|
if (spec.objectStorageGB) resources.push({ kind: 'bucket', name: 'object', quotaGB: spec.objectStorageGB });
|
|
29
|
+
// A cron has no fixed cost — it's billed per run. The server returns it as a 0 fixed
|
|
30
|
+
// line plus the `cron_run` rate in `variable[]`, which is what the gate surfaces (#170).
|
|
31
|
+
if (spec.cron) resources.push({ kind: 'cron', name: 'cron' });
|
|
32
|
+
// A queue bills ONLY per message (queue_message) — never compute/storage (JNG #52). It is
|
|
33
|
+
// its OWN kind, not a database: mapping queue storage to a `database` resource priced it as
|
|
34
|
+
// DB compute+storage and mislabeled it "database storage" (#170 r18a D5).
|
|
35
|
+
if (spec.queue) resources.push({ kind: 'queue', name: 'queue' });
|
|
29
36
|
return resources;
|
|
30
37
|
}
|
|
31
38
|
|
package/src/index.js
CHANGED
|
@@ -16,11 +16,13 @@ ${c.bold('Commands')}
|
|
|
16
16
|
login Authenticate with a NEO App API key (kap_...)
|
|
17
17
|
link <project-id> Link this repo to a project (writes .neo-app.json)
|
|
18
18
|
deploy [path|.zip|git-url] Deploy a directory (default: cwd), a .zip, a Git repo, or --image <ref>
|
|
19
|
+
estimate Price a resource from the server price book (read-only; no project needed)
|
|
19
20
|
(or pass the same via --source <path|.zip|git-url>)
|
|
20
21
|
project create [name] Create a project (defaults to the dir name) and link it
|
|
21
22
|
project list List projects in your organization (find ids to link)
|
|
22
23
|
status | whoami Show connected account, org, environment, linked project
|
|
23
|
-
mcp Run the local stdio
|
|
24
|
+
mcp Run the MCP server: local stdio (default), or hosted
|
|
25
|
+
Streamable HTTP + SSE with --http [--port <n>] [--host <h>]
|
|
24
26
|
|
|
25
27
|
${c.bold('Options')}
|
|
26
28
|
--project <id> Target/link a project (agent setup, deploy)
|
|
@@ -35,6 +37,13 @@ ${c.bold('Options')}
|
|
|
35
37
|
--token <kap_...> API key (also NEO_APP_TOKEN; prompted if omitted)
|
|
36
38
|
--yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
|
|
37
39
|
--dry-run Print intended changes without writing
|
|
40
|
+
--json Machine-readable output incl. the normalized cost (deploy, estimate)
|
|
41
|
+
--tier <key> estimate: compute tier (micro|small|medium|large|xlarge)
|
|
42
|
+
--vcpu <n> --mem <MB> estimate: custom compute (instead of --tier)
|
|
43
|
+
--storage <GB> estimate: database/persistent storage
|
|
44
|
+
--object <GB> estimate: object-storage quota (billed monthly-upfront)
|
|
45
|
+
--queue / --cron estimate: a queue (per-message) / cron (per-run) resource
|
|
46
|
+
--replicas <n> estimate: replica count
|
|
38
47
|
-v, --version Print version
|
|
39
48
|
-h, --help Show this help`;
|
|
40
49
|
|
|
@@ -63,6 +72,18 @@ const options = {
|
|
|
63
72
|
prod: { type: 'boolean' },
|
|
64
73
|
yes: { type: 'boolean' },
|
|
65
74
|
'dry-run': { type: 'boolean' },
|
|
75
|
+
json: { type: 'boolean' },
|
|
76
|
+
tier: { type: 'string' },
|
|
77
|
+
vcpu: { type: 'string' },
|
|
78
|
+
mem: { type: 'string' },
|
|
79
|
+
storage: { type: 'string' },
|
|
80
|
+
object: { type: 'string' },
|
|
81
|
+
replicas: { type: 'string' },
|
|
82
|
+
queue: { type: 'boolean' },
|
|
83
|
+
cron: { type: 'boolean' },
|
|
84
|
+
http: { type: 'boolean' },
|
|
85
|
+
sse: { type: 'boolean' },
|
|
86
|
+
host: { type: 'string' },
|
|
66
87
|
version: { type: 'boolean', short: 'v' },
|
|
67
88
|
help: { type: 'boolean', short: 'h' },
|
|
68
89
|
};
|
|
@@ -85,7 +106,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
85
106
|
// Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
|
|
86
107
|
// still error (nonzero exit) even with `--help`, so a bad command isn't silently
|
|
87
108
|
// accepted (JNG #97). Per-subcommand help is tracked in #89.
|
|
88
|
-
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
109
|
+
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
89
110
|
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
|
|
90
111
|
|
|
91
112
|
try {
|
|
@@ -96,12 +117,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
96
117
|
if (cmd === 'config' && sub === 'install-mcp') return void (await (await import('./commands/install-mcp.js')).installMcp(opts));
|
|
97
118
|
if (cmd === 'login') return void (await (await import('./commands/login.js')).login(opts));
|
|
98
119
|
if (cmd === 'deploy') return void (await (await import('./commands/deploy.js')).deploy(sub, opts));
|
|
120
|
+
if (cmd === 'estimate') return void (await (await import('./commands/estimate.js')).estimate(opts));
|
|
99
121
|
if (cmd === 'link') return void (await (await import('./commands/link.js')).link(sub, opts));
|
|
100
122
|
if (cmd === 'project' && sub === 'link') return void (await (await import('./commands/link.js')).link(positionals[2], opts));
|
|
101
123
|
if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
|
|
102
124
|
if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
|
|
103
125
|
if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
|
|
104
|
-
if (cmd === 'mcp')
|
|
126
|
+
if (cmd === 'mcp') {
|
|
127
|
+
const mcp = await import('./mcp/server.js');
|
|
128
|
+
// Hosted mode: Streamable HTTP + SSE over one listener (auth per request). Default: stdio.
|
|
129
|
+
if (opts.http || opts.sse || opts.port) return void (await mcp.runMcpHttpServer({ port: opts.port, host: opts.host }));
|
|
130
|
+
return void (await mcp.runMcpServer());
|
|
131
|
+
}
|
|
105
132
|
if (cmd === 'version') return void console.log(version());
|
|
106
133
|
if (cmd === 'help') return void console.log(HELP);
|
|
107
134
|
|
package/src/mcp/server.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
// NEO App
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// NEO App MCP server. ONE tool registry (./tools.js, the canonical neo_* contract) served
|
|
2
|
+
// over three transports so every surface is capability- and version-identical (#175):
|
|
3
|
+
// • stdio (`neo-app mcp`) — local agent, reuses the authenticated CLI credential.
|
|
4
|
+
// • Streamable HTTP (`mcp --http`) — hosted, STATELESS (fresh server+transport per POST,
|
|
5
|
+
// JSON responses), auth from the request's Bearer token.
|
|
6
|
+
// Stateless keeps it multi-replica-safe and lets the
|
|
7
|
+
// control plane front it per-request (it owns sessions).
|
|
8
|
+
// • SSE (same `--http` listener) — hosted, session per connection (for direct clients).
|
|
9
|
+
// serverInfo.version is the packed npm version for every transport (never "docker").
|
|
10
|
+
// All logging goes to stderr; stdout is the stdio JSON-RPC channel.
|
|
5
11
|
|
|
12
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
6
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
16
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
8
17
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
18
|
import { resolveConfig } from '../config.js';
|
|
10
19
|
import { getCredential } from '../creds.js';
|
|
@@ -12,16 +21,10 @@ import { NeoApi } from '../api.js';
|
|
|
12
21
|
import { pkgVersion } from '../util.js';
|
|
13
22
|
import { TOOLS } from './tools.js';
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!cred?.token) {
|
|
20
|
-
console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// serverInfo.version MUST equal the packed npm artifact version (handoff 3.1):
|
|
24
|
-
// derive it from package.json, never hard-code it.
|
|
24
|
+
// buildServer wires the shared tool registry onto a fresh MCP Server bound to ONE NeoApi
|
|
25
|
+
// (one session's auth token + env pin). Every transport reuses it, so stdio and hosted
|
|
26
|
+
// HTTP/SSE serve the identical neo_* contract, annotations, and version (#175 parity).
|
|
27
|
+
function buildServer(api) {
|
|
25
28
|
const server = new Server({ name: 'neo-app', version: pkgVersion() }, { capabilities: { tools: {} } });
|
|
26
29
|
|
|
27
30
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -40,7 +43,7 @@ export async function runMcpServer() {
|
|
|
40
43
|
}
|
|
41
44
|
if (!api.token) {
|
|
42
45
|
return {
|
|
43
|
-
content: [{ type: 'text', text: 'Not authenticated.
|
|
46
|
+
content: [{ type: 'text', text: 'Not authenticated. Send an Authorization: Bearer <kap_ token> header (hosted), run `neo-app login`, or set NEO_APP_TOKEN (stdio).' }],
|
|
44
47
|
isError: true,
|
|
45
48
|
};
|
|
46
49
|
}
|
|
@@ -54,6 +57,104 @@ export async function runMcpServer() {
|
|
|
54
57
|
}
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
return server;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// runMcpServer: local stdio. One process = one local credential (unchanged behavior).
|
|
64
|
+
export async function runMcpServer() {
|
|
65
|
+
const cfg = resolveConfig({});
|
|
66
|
+
const cred = getCredential(cfg.baseUrl);
|
|
67
|
+
const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
|
|
68
|
+
if (!cred?.token) {
|
|
69
|
+
console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
|
|
70
|
+
}
|
|
71
|
+
// serverInfo.version MUST equal the packed npm artifact version (handoff 3.1): buildServer
|
|
72
|
+
// derives it from package.json.
|
|
73
|
+
const server = buildServer(api);
|
|
57
74
|
await server.connect(new StdioServerTransport());
|
|
58
|
-
console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl}`);
|
|
75
|
+
console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl} — ${TOOLS.length} tools v${pkgVersion()}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bearer = (req) => {
|
|
79
|
+
const m = /^Bearer\s+(.+)$/i.exec(req.headers['authorization'] || '');
|
|
80
|
+
return m ? m[1].trim() : null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// runMcpHttpServer: hosted Streamable HTTP + SSE. The agent's per-request Bearer token
|
|
84
|
+
// becomes that session's NeoApi token — NEVER a local credential. baseUrl is the control
|
|
85
|
+
// plane this MCP fronts (config / NEO_APP_URL). Returns the http.Server (for tests).
|
|
86
|
+
export async function runMcpHttpServer({ port, host } = {}) {
|
|
87
|
+
const cfg = resolveConfig({});
|
|
88
|
+
// Honor an explicit port, including 0 (ephemeral, used by tests); else PORT, else 8750.
|
|
89
|
+
const listenPort = (port != null && port !== '') ? Number(port) : Number(process.env.PORT || 8750);
|
|
90
|
+
const listenHost = host || process.env.HOST || '0.0.0.0';
|
|
91
|
+
// Mount point for the MCP routes. Default '/mcp'; set NEO_APP_MCP_BASE_PATH=/api/v1/mcp
|
|
92
|
+
// when the control plane reverse-proxies that public path straight through to this sidecar,
|
|
93
|
+
// so the SSE "endpoint" event the client is told to POST back to resolves correctly.
|
|
94
|
+
const base = (process.env.NEO_APP_MCP_BASE_PATH || '/mcp').replace(/\/+$/, '');
|
|
95
|
+
const apiFor = (req) => new NeoApi({ baseUrl: cfg.baseUrl, token: bearer(req) });
|
|
96
|
+
|
|
97
|
+
// SSE keeps per-connection state in-process (sessionId -> transport); the agent's Bearer
|
|
98
|
+
// token is bound to the session's NeoApi when the stream opens. Streamable HTTP is stateless
|
|
99
|
+
// (no map). ponytail: the SSE map → run as a SINGLE replica; add sticky routing or a shared
|
|
100
|
+
// store before scaling SSE horizontally. Streamable HTTP has no such limit.
|
|
101
|
+
const sseSessions = new Map(); // sessionId -> SSEServerTransport
|
|
102
|
+
|
|
103
|
+
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
|
104
|
+
|
|
105
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
108
|
+
|
|
109
|
+
// Unauthenticated liveness/capability probe so infra can confirm the hosted MCP is up
|
|
110
|
+
// and advertises the full contract (version + tool count) — the opposite of the #175
|
|
111
|
+
// "connected but silently reduced" failure.
|
|
112
|
+
if (req.method === 'GET' && (url.pathname === '/healthz' || url.pathname === '/version')) {
|
|
113
|
+
return json(res, 200, { component: 'neo-app-mcp', version: pkgVersion(), tools: TOOLS.length, transports: ['streamable-http', 'sse'] });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Streamable HTTP (stateless): a fresh server + transport per POST, JSON responses.
|
|
117
|
+
// Each request is self-contained (the SDK answers tools/list without a prior initialize),
|
|
118
|
+
// so no session id is minted here — the control plane fronting this owns session state. ---
|
|
119
|
+
if (url.pathname === base) {
|
|
120
|
+
if (req.method !== 'POST') {
|
|
121
|
+
res.setHeader('allow', 'POST');
|
|
122
|
+
return json(res, 405, { error: 'method not allowed; stateless Streamable HTTP MCP uses POST' });
|
|
123
|
+
}
|
|
124
|
+
const server = buildServer(apiFor(req));
|
|
125
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
126
|
+
res.on('close', () => { transport.close(); server.close(); });
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
await transport.handleRequest(req, res); // SDK reads the JSON-RPC body from the stream
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- SSE (stateful): GET <base>/sse opens the stream, POST <base>/messages carries messages ---
|
|
133
|
+
if (url.pathname === `${base}/sse` && req.method === 'GET') {
|
|
134
|
+
const server = buildServer(apiFor(req));
|
|
135
|
+
const transport = new SSEServerTransport(`${base}/messages`, res);
|
|
136
|
+
sseSessions.set(transport.sessionId, transport);
|
|
137
|
+
res.on('close', () => { sseSessions.delete(transport.sessionId); server.close(); });
|
|
138
|
+
await server.connect(transport); // calls transport.start() → sends the endpoint event
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (url.pathname === `${base}/messages` && req.method === 'POST') {
|
|
142
|
+
const transport = sseSessions.get(url.searchParams.get('sessionId'));
|
|
143
|
+
if (!transport) return json(res, 404, { error: 'no such SSE session' });
|
|
144
|
+
await transport.handlePostMessage(req, res);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return json(res, 404, { error: 'not found' });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error('[neo-app mcp] request error', e?.message || e);
|
|
151
|
+
if (!res.headersSent) json(res, 500, { error: String(e?.message || e) });
|
|
152
|
+
else try { res.end(); } catch { /* already closed */ }
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await new Promise((resolve) => httpServer.listen(listenPort, listenHost, resolve));
|
|
157
|
+
const boundPort = httpServer.address()?.port ?? listenPort;
|
|
158
|
+
console.error(`[neo-app mcp] hosted ready: http://${listenHost}:${boundPort}${base} (streamable) + ${base}/sse — fronting ${cfg.baseUrl}, ${TOOLS.length} tools v${pkgVersion()}`);
|
|
159
|
+
return httpServer;
|
|
59
160
|
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -31,10 +31,14 @@ function result(status, data) {
|
|
|
31
31
|
// §2 safety: a create/deploy mutation must name its environment explicitly —
|
|
32
32
|
// NEVER default to production. A silent prod mutation is the exact risk the
|
|
33
33
|
// handoff calls out ("Never default a missing environment to production").
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// The session env pin (JNG #136) is PER-API-SESSION state, not a module global, so one
|
|
35
|
+
// hosted MCP process can serve many tenants concurrently without their env pins bleeding
|
|
36
|
+
// across sessions — each MCP session has its own NeoApi, and the pin lives on it. Stdio
|
|
37
|
+
// has a single api for the whole process, so the behavior is identical to a module global.
|
|
38
|
+
// Seeded once per api from NEO_APP_TARGET_ENV; null = not chosen yet.
|
|
39
|
+
function seedSessionEnv() { const e = String(process.env.NEO_APP_TARGET_ENV || '').toLowerCase(); return (e === 'staging' || e === 'production') ? e : null; }
|
|
40
|
+
function getSessionEnv(api) { if (api._sessionEnv === undefined) api._sessionEnv = seedSessionEnv(); return api._sessionEnv; }
|
|
41
|
+
function setSessionEnv(api, v) { api._sessionEnv = v; }
|
|
38
42
|
|
|
39
43
|
// envStrict validates an explicit staging|production value (no fallback).
|
|
40
44
|
function envStrict(env) {
|
|
@@ -48,36 +52,39 @@ function envStrict(env) {
|
|
|
48
52
|
// call), a later call for a DIFFERENT environment is refused unless allowCrossEnvironment is
|
|
49
53
|
// set — so staging and production can't be mixed in one session. The first explicit choice
|
|
50
54
|
// pins the session so subsequent calls inherit it. Never silently defaulted.
|
|
51
|
-
function requireEnv(env, allowCross = false) {
|
|
55
|
+
function requireEnv(api, env, allowCross = false) {
|
|
52
56
|
const e = String(env || '').toLowerCase();
|
|
53
57
|
const explicit = (e === 'staging' || e === 'production') ? e : null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const pinned = getSessionEnv(api);
|
|
59
|
+
if (pinned) {
|
|
60
|
+
if (explicit && explicit !== pinned && !allowCross) {
|
|
61
|
+
throw new Error(`environment_mismatch: this session is pinned to "${pinned}" but the call requested "${explicit}". Staging and production must not be mixed in one session (JNG #136). Pass allowCrossEnvironment:true to allow this one call, or re-pin with neo_set_context.`);
|
|
57
62
|
}
|
|
58
|
-
return explicit ||
|
|
63
|
+
return explicit || pinned;
|
|
59
64
|
}
|
|
60
|
-
if (explicit) {
|
|
65
|
+
if (explicit) { setSessionEnv(api, explicit); return explicit; } // pin the first explicit choice
|
|
61
66
|
throw new Error('environment_not_chosen: pin it once with neo_set_context({environment:"staging"|"production"}) (ask the user which if unclear; prefer staging), or pass environment explicitly. Never defaulted so staging and production don\'t get mixed (JNG #136).');
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
// Shared schema fragment: the per-call escape hatch for the session environment pin.
|
|
65
70
|
const allowCrossEnvProp = { allowCrossEnvironment: { type: 'boolean', description: 'Override the session environment pin for THIS call only (JNG #136). A call whose environment differs from the pinned one is refused by default so staging and production are not mixed; set true to allow this one cross-environment action.' } };
|
|
66
71
|
|
|
67
|
-
// resetSessionEnv clears
|
|
68
|
-
//
|
|
69
|
-
|
|
72
|
+
// resetSessionEnv clears a session's pin. With api-scoped state each test uses a fresh api,
|
|
73
|
+
// so pins never cross tests; kept exported and tolerant of any/no arg for the existing
|
|
74
|
+
// beforeEach(resetSessionEnv) hooks (a no-op when called without a real api).
|
|
75
|
+
export function resetSessionEnv(api) { if (api && typeof api === 'object') api._sessionEnv = null; }
|
|
70
76
|
|
|
71
77
|
// assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
|
|
72
78
|
// if the session is pinned and the resource lives in a different environment, refuse so an
|
|
73
79
|
// ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
|
|
74
80
|
// resources with no environment (buckets are shared) and an unpinned session are not guarded.
|
|
75
81
|
async function assertResourceEnv(api, getPath, allowCross = false) {
|
|
76
|
-
|
|
82
|
+
const pinned = getSessionEnv(api);
|
|
83
|
+
if (!pinned || allowCross) return;
|
|
77
84
|
let env;
|
|
78
85
|
try { env = (await api.get(getPath)).data?.environment; } catch { return; } // can't resolve → don't block
|
|
79
|
-
if (env && env !==
|
|
80
|
-
throw new Error(`environment_mismatch: this session is pinned to "${
|
|
86
|
+
if (env && env !== pinned) {
|
|
87
|
+
throw new Error(`environment_mismatch: this session is pinned to "${pinned}" 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
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -153,26 +160,33 @@ function validateBucketArgs(args) {
|
|
|
153
160
|
// args.confirm===true it returns the live-priced cost and creates NOTHING — the
|
|
154
161
|
// agent shows the user, then re-calls with confirm:true. plan/apply remains the
|
|
155
162
|
// server-immutable costed path; this is the lighter client-side gate (handoff 3.3).
|
|
156
|
-
async function confirmCost(api, args, spec) {
|
|
163
|
+
async function confirmCost(api, args, spec, currentSpec) {
|
|
157
164
|
if (args.confirm === true) return null;
|
|
158
165
|
let orgId = null;
|
|
159
166
|
try { orgId = await api.resolveOrgId(args.orgId); } catch { orgId = null; }
|
|
160
167
|
// The server's /estimate is the sole price authority (#170) — the CLI no longer prices
|
|
161
168
|
// locally. Prefer the project scope (exact org context); fall back to org scope for a
|
|
162
|
-
// project-less quote.
|
|
163
|
-
|
|
169
|
+
// project-less quote. When a CURRENT spec is given (a change flow), ask the server for
|
|
170
|
+
// the delta too (current → proposed → increase). Best-effort: a failed estimate must
|
|
171
|
+
// never fail the gate.
|
|
172
|
+
let cost = null, delta = null;
|
|
164
173
|
const resources = specToResources(spec);
|
|
174
|
+
const current = currentSpec ? specToResources(currentSpec) : null;
|
|
165
175
|
if (resources.length && (args.projectId || orgId)) {
|
|
166
|
-
try {
|
|
167
|
-
|
|
176
|
+
try {
|
|
177
|
+
const body = current && current.length ? { resources, current } : { resources };
|
|
178
|
+
const r = await api.estimate(args.projectId ? { projectId: args.projectId } : { orgId }, body);
|
|
179
|
+
cost = r.cost; delta = r.delta || null;
|
|
180
|
+
} catch { cost = null; }
|
|
168
181
|
}
|
|
169
182
|
return {
|
|
170
183
|
requiresConfirmation: true,
|
|
171
184
|
cost,
|
|
185
|
+
delta, // current → proposed → increase, when a current spec was given (#170 change delta)
|
|
172
186
|
priceBookVersion: cost?.priceBookVersion, // pins the quote to a price-book snapshot (#170)
|
|
173
187
|
acting: await actingOf(api, { orgId, projectId: args.projectId }), // whose/where — JNG #131
|
|
174
188
|
priceBook: cost?.priceBook || (orgId ? `org:${orgId}` : 'global-defaults'),
|
|
175
|
-
message: 'Billable
|
|
189
|
+
message: 'Billable change — show the user BOTH the hourly and monthly cost (and the increase delta, plus any per-use variable rates), then re-call with confirm:true to apply. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
|
|
176
190
|
};
|
|
177
191
|
}
|
|
178
192
|
|
|
@@ -194,6 +208,11 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
194
208
|
const resources = specToResources(spec);
|
|
195
209
|
if (resources.length) {
|
|
196
210
|
try { cost = (await api.estimate({ projectId }, { resources })).cost; } catch { cost = null; }
|
|
211
|
+
} else if (kind === 'database') {
|
|
212
|
+
// A managed DB is storage-billed only (metering.go/JNG #25); with no volume
|
|
213
|
+
// there is no fixed cost. Quote Rp0 rather than a bare null (#149 r18a note:
|
|
214
|
+
// "Redis create returns a null cost estimate") so the gate shows a real figure.
|
|
215
|
+
cost = { monthly: 0, hourly: 0, currency: 'IDR', note: 'storage-billed only; no volume specified' };
|
|
197
216
|
}
|
|
198
217
|
const costNote = cost ? formatCost(cost) : '';
|
|
199
218
|
|
|
@@ -203,7 +222,7 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
|
|
|
203
222
|
// from failing an otherwise-fine create (same spirit as the api-layer idempotent retry).
|
|
204
223
|
let mint;
|
|
205
224
|
for (let attempt = 1; ; attempt++) {
|
|
206
|
-
try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, environment, estimatedCost: costNote, priceBookVersion: cost?.priceBookVersion })).data; break; }
|
|
225
|
+
try { mint = (await api.post('/api/v1/confirmations', { action: 'billable_create', projectId, kind, name, environment, estimatedCost: costNote, priceBookVersion: cost?.priceBookVersion, cost })).data; break; }
|
|
207
226
|
catch (e) {
|
|
208
227
|
if (attempt < 3 && (e?.status >= 500 || e?.status === 0)) { await new Promise((r) => setTimeout(r, 300 * attempt)); continue; }
|
|
209
228
|
throw e;
|
|
@@ -263,8 +282,8 @@ export const TOOLS = [
|
|
|
263
282
|
required: ['environment'],
|
|
264
283
|
},
|
|
265
284
|
async call(api, args) {
|
|
266
|
-
|
|
267
|
-
return { environment:
|
|
285
|
+
setSessionEnv(api, envStrict(args.environment));
|
|
286
|
+
return { environment: getSessionEnv(api), pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
|
|
268
287
|
},
|
|
269
288
|
},
|
|
270
289
|
{
|
|
@@ -274,7 +293,7 @@ export const TOOLS = [
|
|
|
274
293
|
schema: { type: 'object', properties: {} },
|
|
275
294
|
async call(api) {
|
|
276
295
|
const acct = (typeof api.actingIdentity === 'function') ? await api.actingIdentity().catch(() => ({})) : {};
|
|
277
|
-
return { environment:
|
|
296
|
+
return { environment: getSessionEnv(api), ...acct };
|
|
278
297
|
},
|
|
279
298
|
},
|
|
280
299
|
{
|
|
@@ -310,6 +329,10 @@ export const TOOLS = [
|
|
|
310
329
|
const v = (await api.get('/version')).data || {};
|
|
311
330
|
controlPlane.version = v.version ?? null;
|
|
312
331
|
controlPlane.gitCommit = v.gitCommit ?? null;
|
|
332
|
+
// Report the PUBLIC control-plane URL when the server advertises one — the
|
|
333
|
+
// hosted sidecar reaches the control plane on loopback (127.0.0.1), so
|
|
334
|
+
// api.baseUrl alone would leak the internal address (#175 r18a retest).
|
|
335
|
+
if (v.publicUrl) controlPlane.baseUrl = v.publicUrl;
|
|
313
336
|
} catch { /* leave nulls */ }
|
|
314
337
|
|
|
315
338
|
// Price book is readable by any member; surface whether cost data is
|
|
@@ -328,7 +351,7 @@ export const TOOLS = [
|
|
|
328
351
|
{ type: 'template', create: true, deploy: true, scale: false, update: true, logs: true, variables: true, delete: true },
|
|
329
352
|
{ type: 'static_site', create: 'via_folder', deploy: true, scale: false, update: true, domains: true, logs: true, variables: true, delete: true, nativeStatic: true, note: 'neo_deploy_folder serviceType=static_site with buildCommand/publishDirectory → native nginx (0.4.27); neo_update_service for domain/tls/path' },
|
|
330
353
|
{ type: 'worker', create: 'via_folder', deploy: true, scale: true, update: true, health: true, autoscale: true, logs: true, variables: true, usage: true, delete: true, note: 'neo_deploy_folder serviceType=worker (no public ingress) + neo_update_service (compute/replicas/autoscale/command/health)' },
|
|
331
|
-
{ type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, restore: true, verify: true, publicAccess:
|
|
354
|
+
{ type: 'database', create: true, deploy: true, get: true, list: true, update: true, backup: true, restore: true, verify: true, publicAccess: 'postgres-only', delete: true, engines: ['postgres', 'mysql', 'mariadb', 'redis'], note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + restore (postgres & mysql/mariadb SQL dumps; redis RDB restores via pod file-swap) + verify (postgres/mysql SQL round-trip, redis SET/GET/TTL/DEL). Managed DBs are storage-billed only (no compute line). publicAccess is postgres-only today; mysql/redis are private-binding only (handoff 4.4, #149).' },
|
|
332
355
|
{ type: 'queue', create: true, deploy: true, get: true, list: true, update: true, verify: true, publicAccess: true, delete: true, note: 'neo_create/deploy/list/get/update/verify(publish→persist)/delete_queue + enable/disable_queue_public_access (handoff 4.5)' },
|
|
333
356
|
{ type: 'cron', create: true, get: true, list: true, run: true, runs: true, questionnaire: true, delete: true, note: 'neo_cron_questionnaire (guided) + create/list/get/run/list_runs/delete_cron (handoff 4.6)' },
|
|
334
357
|
{ type: 'object_storage', create: true, get: true, list: true, objects: true, upload: true, verify: true, delete: true, note: 'neo_create/list/get/verify(put→list→delete)/delete_bucket + list/upload/presign(get)/delete objects (handoff 4.7)' },
|
|
@@ -345,7 +368,7 @@ export const TOOLS = [
|
|
|
345
368
|
sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
|
|
346
369
|
environments: ['staging', 'production'],
|
|
347
370
|
// Active session context + the one-environment-per-session invariant (JNG #136).
|
|
348
|
-
context: { environment:
|
|
371
|
+
context: { environment: getSessionEnv(api), pinned: !!getSessionEnv(api) },
|
|
349
372
|
environmentIsolation: {
|
|
350
373
|
supported: true,
|
|
351
374
|
pinnedBy: ['neo_set_context', 'first explicit environment on an env-requiring call'],
|
|
@@ -499,21 +522,26 @@ export const TOOLS = [
|
|
|
499
522
|
// effective compute — the one being set, else the service's CURRENT compute. Without
|
|
500
523
|
// the current compute, an autoscale-only change estimated to IDR 0 (#123).
|
|
501
524
|
const replicas = args.replicas ?? args.autoscaleMax ?? args.autoscaleMin;
|
|
525
|
+
// Read the service's CURRENT compute/storage once — for the empty-tier default (#123)
|
|
526
|
+
// AND the change delta (current → proposed → increase).
|
|
527
|
+
let svc = {};
|
|
528
|
+
try { const d = (await api.get(`/api/v1/apps/${appId}`)).data; svc = (d && d.application) || d || {}; } catch { /* best-effort */ }
|
|
529
|
+
// A bundled service with no explicit tier bills at the default "micro" (store.DefaultTierKey);
|
|
530
|
+
// using the empty cpuLimit as-is priced the gate at IDR 0 (#123).
|
|
531
|
+
const svcSpec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
|
|
532
|
+
? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit) }
|
|
533
|
+
: { computeTier: svc.cpuLimit || 'micro' };
|
|
502
534
|
let spec;
|
|
503
535
|
if (computeTier) spec = { computeTier, replicas };
|
|
504
536
|
else if (args.cpuLimit || args.memoryLimit) spec = { vcpu: cpuToVcpu(args.cpuLimit), memoryMB: memToMB(args.memoryLimit), replicas };
|
|
505
|
-
else {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
spec = (svc.billingMode === 'unbundled' && (svc.cpuLimit || svc.memoryLimit))
|
|
511
|
-
? { vcpu: cpuToVcpu(svc.cpuLimit), memoryMB: memToMB(svc.memoryLimit), replicas }
|
|
512
|
-
: { computeTier: svc.cpuLimit || 'micro', replicas };
|
|
537
|
+
else spec = { ...svcSpec, replicas };
|
|
538
|
+
const currentSpec = { ...svcSpec, replicas: svc.replicas ?? svc.scaleMin };
|
|
539
|
+
if (args.volumeSize !== undefined) { // persistent-volume price in the gate (JNG #129)
|
|
540
|
+
spec = { ...spec, storageGB: gbFromSize(args.volumeSize) };
|
|
541
|
+
if (svc.volumeSize) currentSpec.storageGB = gbFromSize(svc.volumeSize);
|
|
513
542
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — re-call with confirm:true to apply.' };
|
|
543
|
+
const gate = await confirmCost(api, args, spec, currentSpec);
|
|
544
|
+
if (gate) return { ...gate, change: 'update service compute/scaling/volume', message: 'This changes cost — show BOTH the hourly and monthly cost and the increase, then re-call with confirm:true to apply.' };
|
|
517
545
|
}
|
|
518
546
|
const { status, data } = await api.put(`/api/v1/apps/${appId}`, body);
|
|
519
547
|
const out = redactSecrets(result(status, data));
|
|
@@ -717,7 +745,7 @@ export const TOOLS = [
|
|
|
717
745
|
required: ['projectId'],
|
|
718
746
|
},
|
|
719
747
|
async call(api, args) {
|
|
720
|
-
const env = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
748
|
+
const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
721
749
|
return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
|
|
722
750
|
},
|
|
723
751
|
},
|
|
@@ -769,22 +797,22 @@ export const TOOLS = [
|
|
|
769
797
|
},
|
|
770
798
|
{
|
|
771
799
|
name: 'neo_get_database_metrics',
|
|
772
|
-
description: 'Current resource usage for a managed postgres
|
|
800
|
+
description: 'Current resource usage for a managed database — postgres, mysql/mariadb, or redis (#149). Always: cpuMillicores + memoryBytes (metrics-server). SQL engines: databaseSizeBytes vs volumeSize, connections / maxConnections / connectionsPercent. Redis: usedMemoryBytes / maxMemoryBytes / memoryPercent, connectedClients, keys, opsPerSec, hitRatioPercent, evictedKeys, role. Snapshot only (no history). Use before a migration or resize to check headroom.',
|
|
773
801
|
mutating: false,
|
|
774
|
-
schema: { type: 'object', properties: { databaseId: str('Database id (
|
|
802
|
+
schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
|
|
775
803
|
async call(api, args) {
|
|
776
804
|
return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
|
|
777
805
|
},
|
|
778
806
|
},
|
|
779
807
|
{
|
|
780
808
|
name: 'neo_db_query',
|
|
781
|
-
description: 'Run a SQL query inside a managed
|
|
809
|
+
description: 'Run a SQL query inside a managed SQL database\'s primary pod (postgres or mysql/mariadb) and return the result (JNG #128, #149). READ-ONLY by default (the session is set read-only, so any write errors); pass readOnly:false to allow writes (CAUTION). Credentials are never exposed. The database must be deployed. For Redis use neo_redis_command. Use for quick checks like "SELECT count(*) FROM sessions;".',
|
|
782
810
|
mutating: true,
|
|
783
811
|
schema: {
|
|
784
812
|
type: 'object',
|
|
785
813
|
properties: {
|
|
786
|
-
databaseId: str('Database id (postgres, deployed)'),
|
|
787
|
-
sql: str('SQL to run (piped into
|
|
814
|
+
databaseId: str('Database id (postgres/mysql, deployed)'),
|
|
815
|
+
sql: str('SQL to run (piped into the engine client)'),
|
|
788
816
|
readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
|
|
789
817
|
...allowCrossEnvProp,
|
|
790
818
|
},
|
|
@@ -795,6 +823,24 @@ export const TOOLS = [
|
|
|
795
823
|
return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
|
|
796
824
|
},
|
|
797
825
|
},
|
|
826
|
+
{
|
|
827
|
+
name: 'neo_redis_command',
|
|
828
|
+
description: 'Run a single Redis command inside a managed redis instance\'s pod (safe inspection, #149). Destructive/administrative commands are blocked (FLUSHALL, FLUSHDB, CONFIG, SHUTDOWN, EVAL, CLUSTER, ACL, CLIENT, REPLICAOF, …); ordinary commands (GET, TTL, TYPE, DBSIZE, INFO, SCAN, LLEN, HGETALL, …) are allowed. Credentials are never exposed; the database must be deployed. For SQL engines use neo_db_query.',
|
|
829
|
+
mutating: true,
|
|
830
|
+
schema: {
|
|
831
|
+
type: 'object',
|
|
832
|
+
properties: {
|
|
833
|
+
databaseId: str('Database id (redis, deployed)'),
|
|
834
|
+
command: str('Redis command, e.g. "GET mykey" or "INFO memory"'),
|
|
835
|
+
...allowCrossEnvProp,
|
|
836
|
+
},
|
|
837
|
+
required: ['databaseId', 'command'],
|
|
838
|
+
},
|
|
839
|
+
async call(api, args) {
|
|
840
|
+
await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
841
|
+
return (await api.post(`/api/v1/databases/${args.databaseId}/redis-command`, { command: args.command })).data;
|
|
842
|
+
},
|
|
843
|
+
},
|
|
798
844
|
{
|
|
799
845
|
name: 'neo_set_variable',
|
|
800
846
|
description:
|
|
@@ -812,7 +858,7 @@ export const TOOLS = [
|
|
|
812
858
|
required: ['projectId', 'key', 'value'],
|
|
813
859
|
},
|
|
814
860
|
async call(api, args) {
|
|
815
|
-
const env = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
861
|
+
const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
816
862
|
const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
|
|
817
863
|
// The PUT replaces the whole set, so merge current vars first.
|
|
818
864
|
const current = (await api.get(path)).data?.vars || [];
|
|
@@ -864,15 +910,16 @@ export const TOOLS = [
|
|
|
864
910
|
},
|
|
865
911
|
async call(api, args) {
|
|
866
912
|
if (args.confirm !== true) {
|
|
867
|
-
let computeTier;
|
|
913
|
+
let computeTier, currentReplicas;
|
|
868
914
|
try {
|
|
869
915
|
const apps = (await api.get(`/api/v1/projects/${args.projectId}/apps`)).data;
|
|
870
916
|
const list = Array.isArray(apps) ? apps : (apps.applications || []);
|
|
871
917
|
const svc = list.find((a) => a.name === args.service || a.id === args.service);
|
|
872
|
-
computeTier = svc
|
|
918
|
+
if (svc) { computeTier = svc.cpuLimit || svc.computeTier; currentReplicas = svc.replicas ?? svc.scaleMin; }
|
|
873
919
|
} catch { /* best effort — gate regardless */ }
|
|
874
|
-
|
|
875
|
-
|
|
920
|
+
// Price the delta against the current replica count so the user sees current → proposed → increase.
|
|
921
|
+
const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas }, { computeTier, replicas: currentReplicas });
|
|
922
|
+
if (gate) return { ...gate, change: `scale "${args.service}" to ${args.replicas} replica(s)`, message: 'Scaling changes cost — show the user BOTH time bases and the increase, then re-call with confirm:true to apply.' };
|
|
876
923
|
}
|
|
877
924
|
const { status, data } = await api.post(
|
|
878
925
|
`/api/v1/projects/${args.projectId}/services/${args.service}/scale`,
|
|
@@ -1010,7 +1057,7 @@ export const TOOLS = [
|
|
|
1010
1057
|
},
|
|
1011
1058
|
{
|
|
1012
1059
|
name: 'neo_list_templates',
|
|
1013
|
-
description: 'List deployable templates (e.g.
|
|
1060
|
+
description: 'List deployable templates (e.g. n8n) with their manifest: the Creates/Reuses resource graph, pinned source/version, min resources, public routes, and limitations — enough to show the resource graph and a live estimate before deploy. WordPress was retired.',
|
|
1014
1061
|
mutating: false,
|
|
1015
1062
|
schema: { type: 'object', properties: {} },
|
|
1016
1063
|
async call(api) {
|
|
@@ -1019,7 +1066,7 @@ export const TOOLS = [
|
|
|
1019
1066
|
},
|
|
1020
1067
|
{
|
|
1021
1068
|
name: 'neo_deploy_template',
|
|
1022
|
-
description: 'Deploy a template (e.g.
|
|
1069
|
+
description: 'Deploy a template (e.g. n8n) into a project. May return pending_approval. Retired templates (e.g. wordpress) return 410 "no longer available".',
|
|
1023
1070
|
mutating: true,
|
|
1024
1071
|
schema: {
|
|
1025
1072
|
type: 'object',
|
|
@@ -1056,7 +1103,7 @@ export const TOOLS = [
|
|
|
1056
1103
|
required: ['projectId', 'path', 'environment'],
|
|
1057
1104
|
},
|
|
1058
1105
|
async call(api, args) {
|
|
1059
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
1106
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
1060
1107
|
const p = resolve(args.path);
|
|
1061
1108
|
let src;
|
|
1062
1109
|
try {
|
|
@@ -1116,7 +1163,7 @@ export const TOOLS = [
|
|
|
1116
1163
|
properties: {
|
|
1117
1164
|
projectId: str('Project id'),
|
|
1118
1165
|
name: str('Database name'),
|
|
1119
|
-
engine: { type: 'string', description: 'postgres | mysql (engine
|
|
1166
|
+
engine: { type: 'string', description: 'postgres | mysql | redis (also mariadb, mongo) — engine' },
|
|
1120
1167
|
version: str('Engine version, e.g. "16" (optional)'),
|
|
1121
1168
|
environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
|
|
1122
1169
|
...allowCrossEnvProp,
|
|
@@ -1129,7 +1176,7 @@ export const TOOLS = [
|
|
|
1129
1176
|
required: ['projectId', 'name', 'engine', 'environment'],
|
|
1130
1177
|
},
|
|
1131
1178
|
async call(api, args) {
|
|
1132
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1179
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1133
1180
|
validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
|
|
1134
1181
|
const gate = await billableGate(api, { projectId: args.projectId, kind: 'database', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
|
|
1135
1182
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
@@ -1184,8 +1231,12 @@ export const TOOLS = [
|
|
|
1184
1231
|
async call(api, args) {
|
|
1185
1232
|
const costChanging = args.volumeSize || args.replicas !== undefined;
|
|
1186
1233
|
if (costChanging && args.confirm !== true) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1234
|
+
// Read current storage for the change delta (current → proposed → increase).
|
|
1235
|
+
let cur = {};
|
|
1236
|
+
try { const d = (await api.get(`/api/v1/databases/${args.databaseId}`)).data; cur = (d && d.database) || d || {}; } catch { /* best-effort */ }
|
|
1237
|
+
const currentSpec = { storageGB: cur.volumeSize ? gbFromSize(cur.volumeSize) : undefined, replicas: cur.replicas };
|
|
1238
|
+
const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas }, currentSpec);
|
|
1239
|
+
if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — show BOTH time bases and the increase, then re-call with confirm:true to apply.' };
|
|
1189
1240
|
}
|
|
1190
1241
|
const body = {};
|
|
1191
1242
|
if (args.volumeSize) body.volumeSize = args.volumeSize;
|
|
@@ -1196,9 +1247,9 @@ export const TOOLS = [
|
|
|
1196
1247
|
},
|
|
1197
1248
|
{
|
|
1198
1249
|
name: 'neo_verify_database',
|
|
1199
|
-
description: 'Functionally verify a managed database (handoff §8.1): runs an in-cluster probe that
|
|
1250
|
+
description: 'Functionally verify a managed database (handoff §8.1, #149): runs an in-cluster probe that connects and does an engine-level round trip — postgres/mysql a disposable SQL transaction, redis a namespaced SET/GET/TTL/DEL — returning {verified, check, log} WITHOUT handing you any credentials. This is the authoritative readiness check after neo_deploy_database. Synchronous (waits up to ~60s for the probe).',
|
|
1200
1251
|
mutating: true,
|
|
1201
|
-
schema: { type: 'object', properties: { databaseId: str('Database id (
|
|
1252
|
+
schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
|
|
1202
1253
|
async call(api, args) {
|
|
1203
1254
|
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/verify`);
|
|
1204
1255
|
return result(status, data);
|
|
@@ -1206,13 +1257,13 @@ export const TOOLS = [
|
|
|
1206
1257
|
},
|
|
1207
1258
|
{
|
|
1208
1259
|
name: 'neo_restore_database',
|
|
1209
|
-
description: 'Restore a postgres database from a prior backup (handoff 4.4). DESTRUCTIVE — applies the backup dump into the LIVE database (overwrites/merges) and is irreversible, so it requires confirm:true. Pass backupId for a specific backup, else the latest successful one is used. Local-destination backups only for now.',
|
|
1260
|
+
description: 'Restore a postgres or mysql/mariadb database from a prior backup (handoff 4.4, #149). DESTRUCTIVE — applies the backup SQL dump into the LIVE database (overwrites/merges) and is irreversible, so it requires confirm:true. Pass backupId for a specific backup, else the latest successful one is used. Local-destination backups only for now. Redis RDB snapshots restore by a pod-level file swap, not here.',
|
|
1210
1261
|
mutating: true,
|
|
1211
1262
|
destructive: true,
|
|
1212
1263
|
schema: {
|
|
1213
1264
|
type: 'object',
|
|
1214
1265
|
properties: {
|
|
1215
|
-
databaseId: str('Database id (postgres)'),
|
|
1266
|
+
databaseId: str('Database id (postgres/mysql)'),
|
|
1216
1267
|
backupId: str('Backup id to restore from (optional; default the latest successful backup)'),
|
|
1217
1268
|
confirm: { type: 'boolean', description: 'Must be true — restore overwrites data in the live database.' },
|
|
1218
1269
|
},
|
|
@@ -1303,8 +1354,8 @@ export const TOOLS = [
|
|
|
1303
1354
|
required: ['projectId', 'name', 'environment'],
|
|
1304
1355
|
},
|
|
1305
1356
|
async call(api, args) {
|
|
1306
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1307
|
-
const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, {
|
|
1357
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1358
|
+
const gate = await billableGate(api, { projectId: args.projectId, kind: 'queue', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm, environment }, { queue: true }); // queue bills per-message only (JNG #52) — 0 fixed + queue_message rate
|
|
1308
1359
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1309
1360
|
const { projectId, confirm, confirmationId, orgId, ...body } = args;
|
|
1310
1361
|
body.environment = environment; // send the resolved env so the pin actually targets it
|
|
@@ -1359,8 +1410,10 @@ export const TOOLS = [
|
|
|
1359
1410
|
async call(api, args) {
|
|
1360
1411
|
const costChanging = args.volumeSize || args.planClass;
|
|
1361
1412
|
if (costChanging && args.confirm !== true) {
|
|
1362
|
-
|
|
1363
|
-
|
|
1413
|
+
// A queue has no fixed cost — it bills per message (queue_message, JNG #52), so a
|
|
1414
|
+
// storage/plan change doesn't move the fixed bill. The gate surfaces the per-message rate.
|
|
1415
|
+
const gate = await confirmCost(api, args, { queue: true });
|
|
1416
|
+
if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'Queue bills per message — no fixed-cost change; the queue_message rate is shown. Re-call with confirm:true to apply.' };
|
|
1364
1417
|
}
|
|
1365
1418
|
const { queueId, confirm, orgId, ...body } = args;
|
|
1366
1419
|
const { status, data } = await api.put(`/api/v1/queues/${queueId}`, body);
|
|
@@ -1443,8 +1496,8 @@ export const TOOLS = [
|
|
|
1443
1496
|
required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
|
|
1444
1497
|
},
|
|
1445
1498
|
async call(api, args) {
|
|
1446
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1447
|
-
const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, {}); // cron
|
|
1499
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1500
|
+
const gate = await billableGate(api, { projectId: args.projectId, kind: 'cron', name: args.name, confirmationId: args.confirmationId, confirm: args.confirm }, { cron: true }); // cron has no fixed cost — the contract surfaces the per-run `cron_run` rate in cost.variable
|
|
1448
1501
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1449
1502
|
// Merge contentType into headers for convenience (unless already set).
|
|
1450
1503
|
if (args.contentType) {
|
|
@@ -1618,8 +1671,9 @@ export const TOOLS = [
|
|
|
1618
1671
|
if (!allowCrossEnvironment) {
|
|
1619
1672
|
let cEnv;
|
|
1620
1673
|
try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
|
|
1621
|
-
|
|
1622
|
-
|
|
1674
|
+
const pinnedEnv = getSessionEnv(api);
|
|
1675
|
+
if (pinnedEnv && cEnv && cEnv !== pinnedEnv) {
|
|
1676
|
+
throw new Error(`environment_mismatch: this session is pinned to "${pinnedEnv}" but that service is in "${cEnv}" (JNG #136). Pass allowCrossEnvironment:true to override, or re-pin with neo_set_context.`);
|
|
1623
1677
|
}
|
|
1624
1678
|
if (args.targetType !== 'neo_object_storage') {
|
|
1625
1679
|
const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
|
|
@@ -1771,7 +1825,7 @@ export const TOOLS = [
|
|
|
1771
1825
|
required: ['projectId', 'environment', 'resources'],
|
|
1772
1826
|
},
|
|
1773
1827
|
async call(api, args) {
|
|
1774
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1828
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1775
1829
|
const { projectId, ...body } = args;
|
|
1776
1830
|
body.environment = environment; // send the resolved env so the pin actually targets it
|
|
1777
1831
|
const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);
|
|
@@ -2027,7 +2081,11 @@ export const TOOLS = [
|
|
|
2027
2081
|
},
|
|
2028
2082
|
async call(api, args) {
|
|
2029
2083
|
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
2030
|
-
|
|
2084
|
+
const out = redactSecrets(result(status, data));
|
|
2085
|
+
// Public access adds no fixed monthly charge, but egress through the gateway is metered
|
|
2086
|
+
// per GB (usage-based). Surface that so enabling it isn't a silent billing change (#170).
|
|
2087
|
+
if (out && typeof out === 'object') out.billingNote = 'No fixed monthly cost is added. Data transfer (egress) through the public TLS gateway is metered per GB — see the usage-based rates via neo_estimate (cost.variable).';
|
|
2088
|
+
return out;
|
|
2031
2089
|
},
|
|
2032
2090
|
},
|
|
2033
2091
|
{
|
|
@@ -2054,7 +2112,9 @@ export const TOOLS = [
|
|
|
2054
2112
|
},
|
|
2055
2113
|
async call(api, args) {
|
|
2056
2114
|
const { status, data } = await api.post(`/api/v1/queues/${args.queueId}/public-access`, { allowlistCidrs: args.allowlistCidrs || [] });
|
|
2057
|
-
|
|
2115
|
+
const out = redactSecrets(result(status, data));
|
|
2116
|
+
if (out && typeof out === 'object') out.billingNote = 'No fixed monthly cost is added. Data transfer (egress) through the public TLS gateway is metered per GB — see the usage-based rates via neo_estimate (cost.variable).';
|
|
2117
|
+
return out;
|
|
2058
2118
|
},
|
|
2059
2119
|
},
|
|
2060
2120
|
{
|