@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biznetgio/neo-app",
3
- "version": "0.4.70",
3
+ "version": "0.4.74",
4
4
  "description": "NEO App CLI — one-command agent onboarding, MCP server, and project ops for coding agents (Codex, Claude Code).",
5
5
  "type": "module",
6
6
  "bin": {
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.5 · **Last updated:** 2026-10-15
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/…), queues (NATS), and object storage — on managed Kubernetes, served
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 the human the cost and get an
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`.
@@ -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 MCP server (used by agents)
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') return void (await (await import('./mcp/server.js')).runMcpServer());
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 local stdio MCP server (`neo-app mcp`). Reuses the authenticated CLI
2
- // credential for the configured control-plane — the token is NEVER passed in agent
3
- // config, it is read here from the credential store (or NEO_APP_TOKEN). All logging
4
- // goes to stderr; stdout is the JSON-RPC channel.
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
- export async function runMcpServer() {
16
- const cfg = resolveConfig({});
17
- const cred = getCredential(cfg.baseUrl);
18
- const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
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. Run `neo-app login` or set NEO_APP_TOKEN.' }],
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
- // sessionEnv pins ONE target environment (staging|production) for this MCP session so an
35
- // agent doesn't mix staging and production within a task (JNG #136). Seeded from
36
- // NEO_APP_TARGET_ENV; set at runtime with neo_set_context. null = not chosen yet.
37
- let sessionEnv = (() => { const e = String(process.env.NEO_APP_TARGET_ENV || '').toLowerCase(); return (e === 'staging' || e === 'production') ? e : null; })();
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
- if (sessionEnv) {
55
- if (explicit && explicit !== sessionEnv && !allowCross) {
56
- throw new Error(`environment_mismatch: this session is pinned to "${sessionEnv}" 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.`);
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 || sessionEnv;
63
+ return explicit || pinned;
59
64
  }
60
- if (explicit) { sessionEnv = explicit; return explicit; } // pin the first explicit choice
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 the session pin. Exported for tests that exercise the pin
68
- // invariant in sequence (each must start from a clean, unpinned session).
69
- export function resetSessionEnv() { sessionEnv = null; }
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
- if (!sessionEnv || allowCross) return;
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 !== 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.`);
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. Best-effort: a failed estimate must never fail the gate.
163
- let cost = null;
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 { cost = (await api.estimate(args.projectId ? { projectId: args.projectId } : { orgId }, { resources })).cost; }
167
- catch { cost = null; }
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 resource — show the user this cost (and the acting account/org), then re-call with confirm:true to create. For an immutable, server-pinned quote use neo_plan_stack → neo_apply_stack.',
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
- sessionEnv = envStrict(args.environment);
267
- return { environment: sessionEnv, pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
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: sessionEnv, ...acct };
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: true, delete: true, note: 'neo_create/deploy/list/get/update(resize)/delete_database + backup + restore (postgres, local backups) + verify (SQL round-trip) + enable/disable_db_public_access (handoff 4.4)' },
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: sessionEnv, pinned: !!sessionEnv },
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
- let svc = {};
507
- try { const d = (await api.get(`/api/v1/apps/${appId}`)).data; svc = (d && d.application) || d || {}; } catch { /* best-effort */ }
508
- // A bundled service with no explicit tier bills at the default "micro" (store.DefaultTierKey);
509
- // using the empty cpuLimit as-is priced the gate at IDR 0 (#123).
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
- if (args.volumeSize !== undefined) spec = { ...spec, storageGB: gbFromSize(args.volumeSize) }; // persistent-volume price in the gate (JNG #129)
515
- const gate = await confirmCost(api, args, spec);
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 database (JNG #133): cpuMillicores + memoryBytes (metrics-server), databaseSizeBytes vs volumeSize (is it filling up?), and connections / maxConnections / connectionsPercent. Snapshot only (no history). Use before a migration or resize to check headroom.',
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 (postgres, deployed)') }, required: ['databaseId'] },
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 postgres database\'s primary pod and return the result (JNG #128). 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. Use for quick checks like "SELECT count(*) FROM sessions;".',
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 psql)'),
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 && (svc.cpuLimit || svc.computeTier);
918
+ if (svc) { computeTier = svc.cpuLimit || svc.computeTier; currentReplicas = svc.replicas ?? svc.scaleMin; }
873
919
  } catch { /* best effort — gate regardless */ }
874
- const gate = await confirmCost(api, args, { computeTier, replicas: args.replicas });
875
- 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.' };
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. wordpress, n8n, redis).',
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. wordpress, n8n) into a project. May return pending_approval.',
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
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize), replicas: args.replicas });
1188
- if (gate) return { ...gate, change: 'resize database (storage/replicas)', message: 'Resizing changes cost — re-call with confirm:true to apply.' };
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 opens the DB and does a SQL round-trip (SELECT 1), returning {verified, log} — WITHOUT handing you any credentials. Postgres only for now. Synchronous (waits up to ~60s for the probe).',
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 (postgres)') }, required: ['databaseId'] },
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 }, { storageGB: gbFromSize(args.volumeSize) });
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
- const gate = await confirmCost(api, args, { storageGB: gbFromSize(args.volumeSize) });
1363
- if (gate) return { ...gate, change: 'update queue (storage/plan)', message: 'This changes cost — re-call with confirm:true to apply.' };
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 cost is variable (per-run) — gate surfaces the rate
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
- if (sessionEnv && cEnv && cEnv !== sessionEnv) {
1622
- 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.`);
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
- return redactSecrets(result(status, data));
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
- return redactSecrets(result(status, data));
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
  {