@biznetgio/neo-app 0.4.73 → 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.73",
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`.
package/src/index.js CHANGED
@@ -21,7 +21,8 @@ ${c.bold('Commands')}
21
21
  project create [name] Create a project (defaults to the dir name) and link it
22
22
  project list List projects in your organization (find ids to link)
23
23
  status | whoami Show connected account, org, environment, linked project
24
- 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>]
25
26
 
26
27
  ${c.bold('Options')}
27
28
  --project <id> Target/link a project (agent setup, deploy)
@@ -80,6 +81,9 @@ const options = {
80
81
  replicas: { type: 'string' },
81
82
  queue: { type: 'boolean' },
82
83
  cron: { type: 'boolean' },
84
+ http: { type: 'boolean' },
85
+ sse: { type: 'boolean' },
86
+ host: { type: 'string' },
83
87
  version: { type: 'boolean', short: 'v' },
84
88
  help: { type: 'boolean', short: 'h' },
85
89
  };
@@ -119,7 +123,12 @@ export async function main(argv = process.argv.slice(2)) {
119
123
  if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
120
124
  if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
121
125
  if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
122
- 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
+ }
123
132
  if (cmd === 'version') return void console.log(version());
124
133
  if (cmd === 'help') return void console.log(HELP);
125
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
 
@@ -201,6 +208,11 @@ async function billableGate(api, { projectId, kind, name, confirmationId, confir
201
208
  const resources = specToResources(spec);
202
209
  if (resources.length) {
203
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' };
204
216
  }
205
217
  const costNote = cost ? formatCost(cost) : '';
206
218
 
@@ -270,8 +282,8 @@ export const TOOLS = [
270
282
  required: ['environment'],
271
283
  },
272
284
  async call(api, args) {
273
- sessionEnv = envStrict(args.environment);
274
- 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.' };
275
287
  },
276
288
  },
277
289
  {
@@ -281,7 +293,7 @@ export const TOOLS = [
281
293
  schema: { type: 'object', properties: {} },
282
294
  async call(api) {
283
295
  const acct = (typeof api.actingIdentity === 'function') ? await api.actingIdentity().catch(() => ({})) : {};
284
- return { environment: sessionEnv, ...acct };
296
+ return { environment: getSessionEnv(api), ...acct };
285
297
  },
286
298
  },
287
299
  {
@@ -317,6 +329,10 @@ export const TOOLS = [
317
329
  const v = (await api.get('/version')).data || {};
318
330
  controlPlane.version = v.version ?? null;
319
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;
320
336
  } catch { /* leave nulls */ }
321
337
 
322
338
  // Price book is readable by any member; surface whether cost data is
@@ -335,7 +351,7 @@ export const TOOLS = [
335
351
  { type: 'template', create: true, deploy: true, scale: false, update: true, logs: true, variables: true, delete: true },
336
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' },
337
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)' },
338
- { 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).' },
339
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)' },
340
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)' },
341
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)' },
@@ -352,7 +368,7 @@ export const TOOLS = [
352
368
  sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
353
369
  environments: ['staging', 'production'],
354
370
  // Active session context + the one-environment-per-session invariant (JNG #136).
355
- context: { environment: sessionEnv, pinned: !!sessionEnv },
371
+ context: { environment: getSessionEnv(api), pinned: !!getSessionEnv(api) },
356
372
  environmentIsolation: {
357
373
  supported: true,
358
374
  pinnedBy: ['neo_set_context', 'first explicit environment on an env-requiring call'],
@@ -729,7 +745,7 @@ export const TOOLS = [
729
745
  required: ['projectId'],
730
746
  },
731
747
  async call(api, args) {
732
- const env = requireEnv(args.environment, args.allowCrossEnvironment);
748
+ const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
733
749
  return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
734
750
  },
735
751
  },
@@ -781,22 +797,22 @@ export const TOOLS = [
781
797
  },
782
798
  {
783
799
  name: 'neo_get_database_metrics',
784
- 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.',
785
801
  mutating: false,
786
- schema: { type: 'object', properties: { databaseId: str('Database id (postgres, deployed)') }, required: ['databaseId'] },
802
+ schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
787
803
  async call(api, args) {
788
804
  return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
789
805
  },
790
806
  },
791
807
  {
792
808
  name: 'neo_db_query',
793
- 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;".',
794
810
  mutating: true,
795
811
  schema: {
796
812
  type: 'object',
797
813
  properties: {
798
- databaseId: str('Database id (postgres, deployed)'),
799
- 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)'),
800
816
  readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
801
817
  ...allowCrossEnvProp,
802
818
  },
@@ -807,6 +823,24 @@ export const TOOLS = [
807
823
  return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
808
824
  },
809
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
+ },
810
844
  {
811
845
  name: 'neo_set_variable',
812
846
  description:
@@ -824,7 +858,7 @@ export const TOOLS = [
824
858
  required: ['projectId', 'key', 'value'],
825
859
  },
826
860
  async call(api, args) {
827
- const env = requireEnv(args.environment, args.allowCrossEnvironment);
861
+ const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
828
862
  const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
829
863
  // The PUT replaces the whole set, so merge current vars first.
830
864
  const current = (await api.get(path)).data?.vars || [];
@@ -1023,7 +1057,7 @@ export const TOOLS = [
1023
1057
  },
1024
1058
  {
1025
1059
  name: 'neo_list_templates',
1026
- 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.',
1027
1061
  mutating: false,
1028
1062
  schema: { type: 'object', properties: {} },
1029
1063
  async call(api) {
@@ -1032,7 +1066,7 @@ export const TOOLS = [
1032
1066
  },
1033
1067
  {
1034
1068
  name: 'neo_deploy_template',
1035
- 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".',
1036
1070
  mutating: true,
1037
1071
  schema: {
1038
1072
  type: 'object',
@@ -1069,7 +1103,7 @@ export const TOOLS = [
1069
1103
  required: ['projectId', 'path', 'environment'],
1070
1104
  },
1071
1105
  async call(api, args) {
1072
- const environment = requireEnv(args.environment, args.allowCrossEnvironment);
1106
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment);
1073
1107
  const p = resolve(args.path);
1074
1108
  let src;
1075
1109
  try {
@@ -1129,7 +1163,7 @@ export const TOOLS = [
1129
1163
  properties: {
1130
1164
  projectId: str('Project id'),
1131
1165
  name: str('Database name'),
1132
- engine: { type: 'string', description: 'postgres | mysql (engine)' },
1166
+ engine: { type: 'string', description: 'postgres | mysql | redis (also mariadb, mongo) — engine' },
1133
1167
  version: str('Engine version, e.g. "16" (optional)'),
1134
1168
  environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
1135
1169
  ...allowCrossEnvProp,
@@ -1142,7 +1176,7 @@ export const TOOLS = [
1142
1176
  required: ['projectId', 'name', 'engine', 'environment'],
1143
1177
  },
1144
1178
  async call(api, args) {
1145
- 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)
1146
1180
  validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
1147
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 });
1148
1182
  if (gate.requiresConfirmation) return { environment, ...gate };
@@ -1213,9 +1247,9 @@ export const TOOLS = [
1213
1247
  },
1214
1248
  {
1215
1249
  name: 'neo_verify_database',
1216
- 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).',
1217
1251
  mutating: true,
1218
- schema: { type: 'object', properties: { databaseId: str('Database id (postgres)') }, required: ['databaseId'] },
1252
+ schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
1219
1253
  async call(api, args) {
1220
1254
  const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/verify`);
1221
1255
  return result(status, data);
@@ -1223,13 +1257,13 @@ export const TOOLS = [
1223
1257
  },
1224
1258
  {
1225
1259
  name: 'neo_restore_database',
1226
- 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.',
1227
1261
  mutating: true,
1228
1262
  destructive: true,
1229
1263
  schema: {
1230
1264
  type: 'object',
1231
1265
  properties: {
1232
- databaseId: str('Database id (postgres)'),
1266
+ databaseId: str('Database id (postgres/mysql)'),
1233
1267
  backupId: str('Backup id to restore from (optional; default the latest successful backup)'),
1234
1268
  confirm: { type: 'boolean', description: 'Must be true — restore overwrites data in the live database.' },
1235
1269
  },
@@ -1320,7 +1354,7 @@ export const TOOLS = [
1320
1354
  required: ['projectId', 'name', 'environment'],
1321
1355
  },
1322
1356
  async call(api, args) {
1323
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1357
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1324
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
1325
1359
  if (gate.requiresConfirmation) return { environment, ...gate };
1326
1360
  const { projectId, confirm, confirmationId, orgId, ...body } = args;
@@ -1462,7 +1496,7 @@ export const TOOLS = [
1462
1496
  required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
1463
1497
  },
1464
1498
  async call(api, args) {
1465
- const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1499
+ const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
1466
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
1467
1501
  if (gate.requiresConfirmation) return { environment, ...gate };
1468
1502
  // Merge contentType into headers for convenience (unless already set).
@@ -1637,8 +1671,9 @@ export const TOOLS = [
1637
1671
  if (!allowCrossEnvironment) {
1638
1672
  let cEnv;
1639
1673
  try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
1640
- if (sessionEnv && cEnv && cEnv !== sessionEnv) {
1641
- 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.`);
1642
1677
  }
1643
1678
  if (args.targetType !== 'neo_object_storage') {
1644
1679
  const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
@@ -1790,7 +1825,7 @@ export const TOOLS = [
1790
1825
  required: ['projectId', 'environment', 'resources'],
1791
1826
  },
1792
1827
  async call(api, args) {
1793
- 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)
1794
1829
  const { projectId, ...body } = args;
1795
1830
  body.environment = environment; // send the resolved env so the pin actually targets it
1796
1831
  const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);