@biznetgio/neo-app 0.4.73 → 0.4.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/prompt.md +10 -3
- package/src/commands/db.js +93 -0
- package/src/index.js +17 -5
- package/src/mcp/server.js +117 -16
- package/src/mcp/tools.js +90 -51
package/package.json
CHANGED
package/prompt.md
CHANGED
|
@@ -6,7 +6,7 @@ Keep this in sync with cli/prompt.md (the npm package's bundled copy).
|
|
|
6
6
|
-->
|
|
7
7
|
# Set this repo up on NEO App
|
|
8
8
|
|
|
9
|
-
**Version:** 0.4.
|
|
9
|
+
**Version:** 0.4.74 · **Last updated:** 2026-11-05
|
|
10
10
|
|
|
11
11
|
These are the official setup instructions from NEO App (Biznet Gio's managed-app
|
|
12
12
|
platform). If you are an AI coding agent, complete the steps below yourself by running
|
|
@@ -17,7 +17,7 @@ into chat, repository files, the skill, or MCP configuration.
|
|
|
17
17
|
## What NEO App runs
|
|
18
18
|
|
|
19
19
|
Web services, background workers, scheduled jobs, static sites, managed databases
|
|
20
|
-
(PostgreSQL/MySQL
|
|
20
|
+
(PostgreSQL/MySQL/Redis), queues (NATS), and object storage — on managed Kubernetes, served
|
|
21
21
|
over HTTPS with automatic TLS. It is **not** for GPU training or for apps that need
|
|
22
22
|
several containers running side by side at runtime (a single app container alongside
|
|
23
23
|
managed databases is fine). If the repo has no server to put behind a URL (a library or
|
|
@@ -118,10 +118,17 @@ tools — never the portal UI. The lifecycle is the same for every resource type
|
|
|
118
118
|
components in a monorepo. Ask the human which to deploy; never collapse a monorepo
|
|
119
119
|
into one service.
|
|
120
120
|
2. **Plan and show the cost**: `neo_plan_stack` returns an immutable, priced plan
|
|
121
|
-
(`planId`, `planHash`, cost in IDR, expiry). Show
|
|
121
|
+
(`planId`, `planHash`, cost in IDR with monthly and hourly totals, expiry). Show
|
|
122
|
+
the human the cost and get an
|
|
122
123
|
explicit **Approve & Create**. Individual creates (`neo_create_database`,
|
|
123
124
|
`neo_create_queue`) instead return the cost and create nothing until you re-call
|
|
124
125
|
with `confirm: true`.
|
|
126
|
+
**Pricing is resource-based and hourly (#144).** Compute is priced per **vCPU-hour**
|
|
127
|
+
and per **GB-RAM-hour** independently — there are no packages/tiers. Size a service
|
|
128
|
+
with `vcpu` + `memoryMB` (or `cpuLimit`/`memoryLimit`); a legacy tier key still works
|
|
129
|
+
but resolves to its concrete vCPU/RAM. Storage and object storage bill per GB-hour.
|
|
130
|
+
Time-based components accrue hourly and are **capped at 730 billable hours/month**; a
|
|
131
|
+
monthly figure is a non-authoritative estimate (hourly × up to 730).
|
|
125
132
|
3. **Apply**: `neo_apply_stack({planId, planHash})`. If the price book changed since the
|
|
126
133
|
plan, apply is refused — re-plan to re-quote. A `pending_approval` result is the
|
|
127
134
|
approval policy working, not an error; wait for it.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `neo-app db` — terminal discoverability for the managed-database lifecycle (#149 retest).
|
|
2
|
+
// Read-only inspection (list/get) runs here directly; billable/mutating operations
|
|
3
|
+
// (create, verify, query, backup, restore, redis-command, delete) stay on the MCP
|
|
4
|
+
// server's audited cost-consent path and are listed here so a terminal-only user can
|
|
5
|
+
// discover them from the CLI command tree instead of only through an agent.
|
|
6
|
+
|
|
7
|
+
import { resolveConfig } from '../config.js';
|
|
8
|
+
import { getCredential } from '../creds.js';
|
|
9
|
+
import { NeoApi } from '../api.js';
|
|
10
|
+
import { readLink } from '../link.js';
|
|
11
|
+
import { log, ok, fail, c } from '../util.js';
|
|
12
|
+
|
|
13
|
+
// The full lifecycle, with the MCP tool that performs each one. Shown by `neo-app db`.
|
|
14
|
+
const OPS = [
|
|
15
|
+
['list', 'neo-app db list', 'List databases in the linked project (read-only)'],
|
|
16
|
+
['get <id>', 'neo-app db get <id>', 'Show one database, incl. redacted connection string (read-only)'],
|
|
17
|
+
['create', 'neo_create_database', 'Provision PostgreSQL / MySQL / Redis (billable — cost consent)'],
|
|
18
|
+
['deploy', 'neo_deploy_database', 'Start/roll out the database pods'],
|
|
19
|
+
['verify', 'neo_verify_database', 'Round-trip health probe (engine-aware)'],
|
|
20
|
+
['query', 'neo_db_query', 'Read-only SQL (postgres/mysql)'],
|
|
21
|
+
['redis', 'neo_redis_command', 'Run a safe Redis command (denylist-guarded)'],
|
|
22
|
+
['metrics', 'neo_get_database_metrics', 'Engine-aware metrics snapshot'],
|
|
23
|
+
['backup', 'neo_run_backup / neo_create_backup', 'Create and run a logical backup'],
|
|
24
|
+
['restore', 'neo_restore_database', 'Restore a SQL dump (postgres/mysql)'],
|
|
25
|
+
['public', 'neo_enable_db_public_access','Enable/disable TLS public access'],
|
|
26
|
+
['delete', 'neo_delete_database', 'Delete a database (billable stop)'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function usage() {
|
|
30
|
+
log(`${c.bold('neo-app db')} — managed database lifecycle (PostgreSQL · MySQL · Redis)\n`);
|
|
31
|
+
log(c.bold('Run directly from the CLI (read-only):'));
|
|
32
|
+
log(' neo-app db list');
|
|
33
|
+
log(' neo-app db get <database-id>\n');
|
|
34
|
+
log(c.bold('Mutating / billable operations run through the MCP server'));
|
|
35
|
+
log(`(start it with ${c.bold('neo-app mcp')} — your agent calls these; the Portal exposes them too):`);
|
|
36
|
+
for (const [, how, desc] of OPS.slice(2)) log(` · ${c.bold(how.padEnd(34))} ${c.dim(desc)}`);
|
|
37
|
+
log(`\nPrice a database before creating it: ${c.bold('neo-app estimate --storage <GB>')}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function connect(opts) {
|
|
41
|
+
const cfg = resolveConfig(opts);
|
|
42
|
+
const cred = getCredential(cfg.baseUrl);
|
|
43
|
+
if (!cred?.token) { fail(`not authenticated for ${cfg.baseUrl} — run 'neo-app login'`); process.exit(1); }
|
|
44
|
+
return new NeoApi({ baseUrl: cfg.baseUrl, token: cred.token });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requireProject(opts) {
|
|
48
|
+
const projectId = opts.project || readLink()?.projectId;
|
|
49
|
+
if (!projectId) { fail("no linked project — run 'neo-app link <project-id>' or pass --project <id>"); process.exit(1); }
|
|
50
|
+
return projectId;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function db(sub, opts = {}, positionals = []) {
|
|
54
|
+
if (!sub || sub === 'help') return void usage();
|
|
55
|
+
|
|
56
|
+
if (sub === 'list' || sub === 'ls') {
|
|
57
|
+
const api = connect(opts);
|
|
58
|
+
const projectId = requireProject(opts);
|
|
59
|
+
let dbs;
|
|
60
|
+
try { dbs = (await api.get(`/api/v1/projects/${projectId}/databases`)).data?.databases || []; }
|
|
61
|
+
catch (e) { fail(`list failed: ${e.message || e}`); process.exit(1); }
|
|
62
|
+
if (opts.json) return void process.stdout.write(JSON.stringify({ databases: dbs }, null, 2) + '\n');
|
|
63
|
+
if (!dbs.length) return void ok('no databases in this project');
|
|
64
|
+
for (const d of dbs) {
|
|
65
|
+
const status = d.status || (d.host ? 'deployed' : 'not_deployed');
|
|
66
|
+
log(` ${c.bold(d.name)} ${c.dim(`${d.engine} ${d.version || ''}`.trim())} ${status} ${c.dim(d.environment || 'production')} ${c.dim(d.id)}`);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (sub === 'get' || sub === 'show') {
|
|
72
|
+
const id = positionals[0];
|
|
73
|
+
if (!id) { fail('usage: neo-app db get <database-id>'); process.exit(2); }
|
|
74
|
+
const api = connect(opts);
|
|
75
|
+
let view;
|
|
76
|
+
try { view = (await api.get(`/api/v1/databases/${id}`)).data; }
|
|
77
|
+
catch (e) { fail(`get failed: ${e.message || e}`); process.exit(1); }
|
|
78
|
+
if (opts.json) return void process.stdout.write(JSON.stringify(view, null, 2) + '\n');
|
|
79
|
+
for (const [k, v] of Object.entries(view)) if (v !== undefined && v !== null && v !== '') log(` ${c.dim(k.padEnd(18))} ${v}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Any lifecycle verb typed at the CLI: point the user at the discoverable path.
|
|
84
|
+
const known = OPS.find(([key]) => key.split(' ')[0] === sub);
|
|
85
|
+
if (known) {
|
|
86
|
+
log(`${c.bold(sub)} runs through the MCP server (${c.bold(known[1])}) or the Portal — not as a direct CLI write.`);
|
|
87
|
+
log(`Start the MCP server with ${c.bold('neo-app mcp')} so your agent can call it, or use the Portal.`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
fail(`unknown db subcommand: ${sub}`);
|
|
91
|
+
usage();
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,8 +20,11 @@ ${c.bold('Commands')}
|
|
|
20
20
|
(or pass the same via --source <path|.zip|git-url>)
|
|
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
|
+
db [list|get|...] Managed database lifecycle (PostgreSQL/MySQL/Redis);
|
|
24
|
+
list/get run here, the rest via the MCP server
|
|
23
25
|
status | whoami Show connected account, org, environment, linked project
|
|
24
|
-
mcp Run the local stdio
|
|
26
|
+
mcp Run the MCP server: local stdio (default), or hosted
|
|
27
|
+
Streamable HTTP + SSE with --http [--port <n>] [--host <h>]
|
|
25
28
|
|
|
26
29
|
${c.bold('Options')}
|
|
27
30
|
--project <id> Target/link a project (agent setup, deploy)
|
|
@@ -37,8 +40,8 @@ ${c.bold('Options')}
|
|
|
37
40
|
--yes Non-interactive (headless/CI); also approves a billable deploy without the cost prompt
|
|
38
41
|
--dry-run Print intended changes without writing
|
|
39
42
|
--json Machine-readable output incl. the normalized cost (deploy, estimate)
|
|
40
|
-
--
|
|
41
|
-
--
|
|
43
|
+
--vcpu <n> --mem <MB> estimate: compute, priced per vCPU-hour + GB-RAM-hour (#144)
|
|
44
|
+
--tier <key> estimate: legacy size shorthand (micro|…|xlarge) → vCPU/RAM
|
|
42
45
|
--storage <GB> estimate: database/persistent storage
|
|
43
46
|
--object <GB> estimate: object-storage quota (billed monthly-upfront)
|
|
44
47
|
--queue / --cron estimate: a queue (per-message) / cron (per-run) resource
|
|
@@ -80,6 +83,9 @@ const options = {
|
|
|
80
83
|
replicas: { type: 'string' },
|
|
81
84
|
queue: { type: 'boolean' },
|
|
82
85
|
cron: { type: 'boolean' },
|
|
86
|
+
http: { type: 'boolean' },
|
|
87
|
+
sse: { type: 'boolean' },
|
|
88
|
+
host: { type: 'string' },
|
|
83
89
|
version: { type: 'boolean', short: 'v' },
|
|
84
90
|
help: { type: 'boolean', short: 'h' },
|
|
85
91
|
};
|
|
@@ -102,7 +108,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
102
108
|
// Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
|
|
103
109
|
// still error (nonzero exit) even with `--help`, so a bad command isn't silently
|
|
104
110
|
// accepted (JNG #97). Per-subcommand help is tracked in #89.
|
|
105
|
-
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
111
|
+
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'estimate', 'link', 'project', 'status', 'whoami', 'mcp', 'db', 'version', 'help']);
|
|
106
112
|
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
|
|
107
113
|
|
|
108
114
|
try {
|
|
@@ -119,7 +125,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
119
125
|
if (cmd === 'project' && sub === 'create') return void (await (await import('./commands/link.js')).createProject(positionals[2], opts));
|
|
120
126
|
if (cmd === 'project' && (sub === 'list' || sub === 'ls')) return void (await (await import('./commands/link.js')).listProjects(opts));
|
|
121
127
|
if (cmd === 'status' || cmd === 'whoami') return void (await (await import('./commands/status.js')).status(opts));
|
|
122
|
-
if (cmd === '
|
|
128
|
+
if (cmd === 'db') return void (await (await import('./commands/db.js')).db(sub, opts, positionals.slice(2)));
|
|
129
|
+
if (cmd === 'mcp') {
|
|
130
|
+
const mcp = await import('./mcp/server.js');
|
|
131
|
+
// Hosted mode: Streamable HTTP + SSE over one listener (auth per request). Default: stdio.
|
|
132
|
+
if (opts.http || opts.sse || opts.port) return void (await mcp.runMcpHttpServer({ port: opts.port, host: opts.host }));
|
|
133
|
+
return void (await mcp.runMcpServer());
|
|
134
|
+
}
|
|
123
135
|
if (cmd === 'version') return void console.log(version());
|
|
124
136
|
if (cmd === 'help') return void console.log(HELP);
|
|
125
137
|
|
package/src/mcp/server.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
// NEO App
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// NEO App MCP server. ONE tool registry (./tools.js, the canonical neo_* contract) served
|
|
2
|
+
// over three transports so every surface is capability- and version-identical (#175):
|
|
3
|
+
// • stdio (`neo-app mcp`) — local agent, reuses the authenticated CLI credential.
|
|
4
|
+
// • Streamable HTTP (`mcp --http`) — hosted, STATELESS (fresh server+transport per POST,
|
|
5
|
+
// JSON responses), auth from the request's Bearer token.
|
|
6
|
+
// Stateless keeps it multi-replica-safe and lets the
|
|
7
|
+
// control plane front it per-request (it owns sessions).
|
|
8
|
+
// • SSE (same `--http` listener) — hosted, session per connection (for direct clients).
|
|
9
|
+
// serverInfo.version is the packed npm version for every transport (never "docker").
|
|
10
|
+
// All logging goes to stderr; stdout is the stdio JSON-RPC channel.
|
|
5
11
|
|
|
12
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
6
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
16
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
8
17
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
18
|
import { resolveConfig } from '../config.js';
|
|
10
19
|
import { getCredential } from '../creds.js';
|
|
@@ -12,16 +21,10 @@ import { NeoApi } from '../api.js';
|
|
|
12
21
|
import { pkgVersion } from '../util.js';
|
|
13
22
|
import { TOOLS } from './tools.js';
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!cred?.token) {
|
|
20
|
-
console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// serverInfo.version MUST equal the packed npm artifact version (handoff 3.1):
|
|
24
|
-
// derive it from package.json, never hard-code it.
|
|
24
|
+
// buildServer wires the shared tool registry onto a fresh MCP Server bound to ONE NeoApi
|
|
25
|
+
// (one session's auth token + env pin). Every transport reuses it, so stdio and hosted
|
|
26
|
+
// HTTP/SSE serve the identical neo_* contract, annotations, and version (#175 parity).
|
|
27
|
+
function buildServer(api) {
|
|
25
28
|
const server = new Server({ name: 'neo-app', version: pkgVersion() }, { capabilities: { tools: {} } });
|
|
26
29
|
|
|
27
30
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -40,7 +43,7 @@ export async function runMcpServer() {
|
|
|
40
43
|
}
|
|
41
44
|
if (!api.token) {
|
|
42
45
|
return {
|
|
43
|
-
content: [{ type: 'text', text: 'Not authenticated.
|
|
46
|
+
content: [{ type: 'text', text: 'Not authenticated. Send an Authorization: Bearer <kap_ token> header (hosted), run `neo-app login`, or set NEO_APP_TOKEN (stdio).' }],
|
|
44
47
|
isError: true,
|
|
45
48
|
};
|
|
46
49
|
}
|
|
@@ -54,6 +57,104 @@ export async function runMcpServer() {
|
|
|
54
57
|
}
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
return server;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// runMcpServer: local stdio. One process = one local credential (unchanged behavior).
|
|
64
|
+
export async function runMcpServer() {
|
|
65
|
+
const cfg = resolveConfig({});
|
|
66
|
+
const cred = getCredential(cfg.baseUrl);
|
|
67
|
+
const api = new NeoApi({ baseUrl: cfg.baseUrl, token: cred?.token });
|
|
68
|
+
if (!cred?.token) {
|
|
69
|
+
console.error(`[neo-app mcp] no credential for ${cfg.baseUrl} — run 'neo-app login' or set NEO_APP_TOKEN`);
|
|
70
|
+
}
|
|
71
|
+
// serverInfo.version MUST equal the packed npm artifact version (handoff 3.1): buildServer
|
|
72
|
+
// derives it from package.json.
|
|
73
|
+
const server = buildServer(api);
|
|
57
74
|
await server.connect(new StdioServerTransport());
|
|
58
|
-
console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl}`);
|
|
75
|
+
console.error(`[neo-app mcp] ready: ${cfg.env} ${cfg.baseUrl} — ${TOOLS.length} tools v${pkgVersion()}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bearer = (req) => {
|
|
79
|
+
const m = /^Bearer\s+(.+)$/i.exec(req.headers['authorization'] || '');
|
|
80
|
+
return m ? m[1].trim() : null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// runMcpHttpServer: hosted Streamable HTTP + SSE. The agent's per-request Bearer token
|
|
84
|
+
// becomes that session's NeoApi token — NEVER a local credential. baseUrl is the control
|
|
85
|
+
// plane this MCP fronts (config / NEO_APP_URL). Returns the http.Server (for tests).
|
|
86
|
+
export async function runMcpHttpServer({ port, host } = {}) {
|
|
87
|
+
const cfg = resolveConfig({});
|
|
88
|
+
// Honor an explicit port, including 0 (ephemeral, used by tests); else PORT, else 8750.
|
|
89
|
+
const listenPort = (port != null && port !== '') ? Number(port) : Number(process.env.PORT || 8750);
|
|
90
|
+
const listenHost = host || process.env.HOST || '0.0.0.0';
|
|
91
|
+
// Mount point for the MCP routes. Default '/mcp'; set NEO_APP_MCP_BASE_PATH=/api/v1/mcp
|
|
92
|
+
// when the control plane reverse-proxies that public path straight through to this sidecar,
|
|
93
|
+
// so the SSE "endpoint" event the client is told to POST back to resolves correctly.
|
|
94
|
+
const base = (process.env.NEO_APP_MCP_BASE_PATH || '/mcp').replace(/\/+$/, '');
|
|
95
|
+
const apiFor = (req) => new NeoApi({ baseUrl: cfg.baseUrl, token: bearer(req) });
|
|
96
|
+
|
|
97
|
+
// SSE keeps per-connection state in-process (sessionId -> transport); the agent's Bearer
|
|
98
|
+
// token is bound to the session's NeoApi when the stream opens. Streamable HTTP is stateless
|
|
99
|
+
// (no map). ponytail: the SSE map → run as a SINGLE replica; add sticky routing or a shared
|
|
100
|
+
// store before scaling SSE horizontally. Streamable HTTP has no such limit.
|
|
101
|
+
const sseSessions = new Map(); // sessionId -> SSEServerTransport
|
|
102
|
+
|
|
103
|
+
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
|
104
|
+
|
|
105
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
108
|
+
|
|
109
|
+
// Unauthenticated liveness/capability probe so infra can confirm the hosted MCP is up
|
|
110
|
+
// and advertises the full contract (version + tool count) — the opposite of the #175
|
|
111
|
+
// "connected but silently reduced" failure.
|
|
112
|
+
if (req.method === 'GET' && (url.pathname === '/healthz' || url.pathname === '/version')) {
|
|
113
|
+
return json(res, 200, { component: 'neo-app-mcp', version: pkgVersion(), tools: TOOLS.length, transports: ['streamable-http', 'sse'] });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Streamable HTTP (stateless): a fresh server + transport per POST, JSON responses.
|
|
117
|
+
// Each request is self-contained (the SDK answers tools/list without a prior initialize),
|
|
118
|
+
// so no session id is minted here — the control plane fronting this owns session state. ---
|
|
119
|
+
if (url.pathname === base) {
|
|
120
|
+
if (req.method !== 'POST') {
|
|
121
|
+
res.setHeader('allow', 'POST');
|
|
122
|
+
return json(res, 405, { error: 'method not allowed; stateless Streamable HTTP MCP uses POST' });
|
|
123
|
+
}
|
|
124
|
+
const server = buildServer(apiFor(req));
|
|
125
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
126
|
+
res.on('close', () => { transport.close(); server.close(); });
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
await transport.handleRequest(req, res); // SDK reads the JSON-RPC body from the stream
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- SSE (stateful): GET <base>/sse opens the stream, POST <base>/messages carries messages ---
|
|
133
|
+
if (url.pathname === `${base}/sse` && req.method === 'GET') {
|
|
134
|
+
const server = buildServer(apiFor(req));
|
|
135
|
+
const transport = new SSEServerTransport(`${base}/messages`, res);
|
|
136
|
+
sseSessions.set(transport.sessionId, transport);
|
|
137
|
+
res.on('close', () => { sseSessions.delete(transport.sessionId); server.close(); });
|
|
138
|
+
await server.connect(transport); // calls transport.start() → sends the endpoint event
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (url.pathname === `${base}/messages` && req.method === 'POST') {
|
|
142
|
+
const transport = sseSessions.get(url.searchParams.get('sessionId'));
|
|
143
|
+
if (!transport) return json(res, 404, { error: 'no such SSE session' });
|
|
144
|
+
await transport.handlePostMessage(req, res);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return json(res, 404, { error: 'not found' });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error('[neo-app mcp] request error', e?.message || e);
|
|
151
|
+
if (!res.headersSent) json(res, 500, { error: String(e?.message || e) });
|
|
152
|
+
else try { res.end(); } catch { /* already closed */ }
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await new Promise((resolve) => httpServer.listen(listenPort, listenHost, resolve));
|
|
157
|
+
const boundPort = httpServer.address()?.port ?? listenPort;
|
|
158
|
+
console.error(`[neo-app mcp] hosted ready: http://${listenHost}:${boundPort}${base} (streamable) + ${base}/sse — fronting ${cfg.baseUrl}, ${TOOLS.length} tools v${pkgVersion()}`);
|
|
159
|
+
return httpServer;
|
|
59
160
|
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -31,10 +31,14 @@ function result(status, data) {
|
|
|
31
31
|
// §2 safety: a create/deploy mutation must name its environment explicitly —
|
|
32
32
|
// NEVER default to production. A silent prod mutation is the exact risk the
|
|
33
33
|
// handoff calls out ("Never default a missing environment to production").
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// The session env pin (JNG #136) is PER-API-SESSION state, not a module global, so one
|
|
35
|
+
// hosted MCP process can serve many tenants concurrently without their env pins bleeding
|
|
36
|
+
// across sessions — each MCP session has its own NeoApi, and the pin lives on it. Stdio
|
|
37
|
+
// has a single api for the whole process, so the behavior is identical to a module global.
|
|
38
|
+
// Seeded once per api from NEO_APP_TARGET_ENV; null = not chosen yet.
|
|
39
|
+
function seedSessionEnv() { const e = String(process.env.NEO_APP_TARGET_ENV || '').toLowerCase(); return (e === 'staging' || e === 'production') ? e : null; }
|
|
40
|
+
function getSessionEnv(api) { if (api._sessionEnv === undefined) api._sessionEnv = seedSessionEnv(); return api._sessionEnv; }
|
|
41
|
+
function setSessionEnv(api, v) { api._sessionEnv = v; }
|
|
38
42
|
|
|
39
43
|
// envStrict validates an explicit staging|production value (no fallback).
|
|
40
44
|
function envStrict(env) {
|
|
@@ -48,36 +52,39 @@ function envStrict(env) {
|
|
|
48
52
|
// call), a later call for a DIFFERENT environment is refused unless allowCrossEnvironment is
|
|
49
53
|
// set — so staging and production can't be mixed in one session. The first explicit choice
|
|
50
54
|
// pins the session so subsequent calls inherit it. Never silently defaulted.
|
|
51
|
-
function requireEnv(env, allowCross = false) {
|
|
55
|
+
function requireEnv(api, env, allowCross = false) {
|
|
52
56
|
const e = String(env || '').toLowerCase();
|
|
53
57
|
const explicit = (e === 'staging' || e === 'production') ? e : null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const pinned = getSessionEnv(api);
|
|
59
|
+
if (pinned) {
|
|
60
|
+
if (explicit && explicit !== pinned && !allowCross) {
|
|
61
|
+
throw new Error(`environment_mismatch: this session is pinned to "${pinned}" but the call requested "${explicit}". Staging and production must not be mixed in one session (JNG #136). Pass allowCrossEnvironment:true to allow this one call, or re-pin with neo_set_context.`);
|
|
57
62
|
}
|
|
58
|
-
return explicit ||
|
|
63
|
+
return explicit || pinned;
|
|
59
64
|
}
|
|
60
|
-
if (explicit) {
|
|
65
|
+
if (explicit) { setSessionEnv(api, explicit); return explicit; } // pin the first explicit choice
|
|
61
66
|
throw new Error('environment_not_chosen: pin it once with neo_set_context({environment:"staging"|"production"}) (ask the user which if unclear; prefer staging), or pass environment explicitly. Never defaulted so staging and production don\'t get mixed (JNG #136).');
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
// Shared schema fragment: the per-call escape hatch for the session environment pin.
|
|
65
70
|
const allowCrossEnvProp = { allowCrossEnvironment: { type: 'boolean', description: 'Override the session environment pin for THIS call only (JNG #136). A call whose environment differs from the pinned one is refused by default so staging and production are not mixed; set true to allow this one cross-environment action.' } };
|
|
66
71
|
|
|
67
|
-
// resetSessionEnv clears
|
|
68
|
-
//
|
|
69
|
-
|
|
72
|
+
// resetSessionEnv clears a session's pin. With api-scoped state each test uses a fresh api,
|
|
73
|
+
// so pins never cross tests; kept exported and tolerant of any/no arg for the existing
|
|
74
|
+
// beforeEach(resetSessionEnv) hooks (a no-op when called without a real api).
|
|
75
|
+
export function resetSessionEnv(api) { if (api && typeof api === 'object') api._sessionEnv = null; }
|
|
70
76
|
|
|
71
77
|
// assertResourceEnv enforces the session pin on an EXISTING resource addressed by id (JNG #136 §4):
|
|
72
78
|
// if the session is pinned and the resource lives in a different environment, refuse so an
|
|
73
79
|
// ID-based op can't touch the wrong environment's resource. allowCrossEnvironment overrides it;
|
|
74
80
|
// resources with no environment (buckets are shared) and an unpinned session are not guarded.
|
|
75
81
|
async function assertResourceEnv(api, getPath, allowCross = false) {
|
|
76
|
-
|
|
82
|
+
const pinned = getSessionEnv(api);
|
|
83
|
+
if (!pinned || allowCross) return;
|
|
77
84
|
let env;
|
|
78
85
|
try { env = (await api.get(getPath)).data?.environment; } catch { return; } // can't resolve → don't block
|
|
79
|
-
if (env && env !==
|
|
80
|
-
throw new Error(`environment_mismatch: this session is pinned to "${
|
|
86
|
+
if (env && env !== pinned) {
|
|
87
|
+
throw new Error(`environment_mismatch: this session is pinned to "${pinned}" but that resource is in "${env}". Staging and production must not be mixed (JNG #136). Pass allowCrossEnvironment:true to act on it anyway, or re-pin with neo_set_context.`);
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -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
|
-
|
|
274
|
-
return { environment:
|
|
285
|
+
setSessionEnv(api, envStrict(args.environment));
|
|
286
|
+
return { environment: getSessionEnv(api), pinned: true, note: 'Pinned for this session. Env-requiring tools now use it; override per-call by passing environment explicitly.' };
|
|
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:
|
|
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:
|
|
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:
|
|
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'],
|
|
@@ -468,9 +484,9 @@ export const TOOLS = [
|
|
|
468
484
|
type: 'object',
|
|
469
485
|
properties: {
|
|
470
486
|
appId: str('Service/app id'),
|
|
471
|
-
computeTier: str('
|
|
472
|
-
cpuLimit: str('
|
|
473
|
-
memoryLimit: str('
|
|
487
|
+
computeTier: str('Legacy size shorthand (micro|small|medium|large|xlarge) — maps to its concrete vCPU/RAM. Prefer cpuLimit+memoryLimit. Compute is resource-based (#144): priced per vCPU-hour + GB-RAM-hour, no packages.'),
|
|
488
|
+
cpuLimit: str('CPU, e.g. "500m" or "1" (use with memoryLimit) — resource-based billing'),
|
|
489
|
+
memoryLimit: str('Memory, e.g. "512Mi" or "1Gi" (use with cpuLimit) — resource-based billing'),
|
|
474
490
|
replicas: int('Fixed replica count'),
|
|
475
491
|
autoscaleMin: int('Autoscale min replicas'),
|
|
476
492
|
autoscaleMax: int('Autoscale max replicas'),
|
|
@@ -498,7 +514,9 @@ export const TOOLS = [
|
|
|
498
514
|
const { appId, confirm, orgId, computeTier, allowCrossEnvironment, ...rest } = args;
|
|
499
515
|
await assertResourceEnv(api, `/api/v1/apps/${appId}`, allowCrossEnvironment); // JNG #136 §4
|
|
500
516
|
const body = { ...rest };
|
|
501
|
-
|
|
517
|
+
// #144: compute is resource-based. A legacy tier shorthand is passed through as
|
|
518
|
+
// cpuLimit; the control plane maps it to concrete vCPU/RAM and bills unbundled.
|
|
519
|
+
if (computeTier) { body.cpuLimit = computeTier; }
|
|
502
520
|
else if (args.cpuLimit || args.memoryLimit) { body.billingMode = 'unbundled'; }
|
|
503
521
|
const costChanging = computeTier || args.cpuLimit || args.memoryLimit || args.replicas !== undefined || args.autoscaleMax !== undefined || args.autoscaleMin !== undefined || args.volumeSize !== undefined || (Array.isArray(args.mounts) && args.mounts.length > 0);
|
|
504
522
|
if (costChanging && confirm !== true) {
|
|
@@ -729,7 +747,7 @@ export const TOOLS = [
|
|
|
729
747
|
required: ['projectId'],
|
|
730
748
|
},
|
|
731
749
|
async call(api, args) {
|
|
732
|
-
const env = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
750
|
+
const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
733
751
|
return { environment: env, ...(await api.get(`/api/v1/projects/${args.projectId}/environments/${env}/config`)).data };
|
|
734
752
|
},
|
|
735
753
|
},
|
|
@@ -781,22 +799,22 @@ export const TOOLS = [
|
|
|
781
799
|
},
|
|
782
800
|
{
|
|
783
801
|
name: 'neo_get_database_metrics',
|
|
784
|
-
description: 'Current resource usage for a managed postgres
|
|
802
|
+
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
803
|
mutating: false,
|
|
786
|
-
schema: { type: 'object', properties: { databaseId: str('Database id (
|
|
804
|
+
schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
|
|
787
805
|
async call(api, args) {
|
|
788
806
|
return (await api.get(`/api/v1/databases/${args.databaseId}/metrics`)).data;
|
|
789
807
|
},
|
|
790
808
|
},
|
|
791
809
|
{
|
|
792
810
|
name: 'neo_db_query',
|
|
793
|
-
description: 'Run a SQL query inside a managed
|
|
811
|
+
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
812
|
mutating: true,
|
|
795
813
|
schema: {
|
|
796
814
|
type: 'object',
|
|
797
815
|
properties: {
|
|
798
|
-
databaseId: str('Database id (postgres, deployed)'),
|
|
799
|
-
sql: str('SQL to run (piped into
|
|
816
|
+
databaseId: str('Database id (postgres/mysql, deployed)'),
|
|
817
|
+
sql: str('SQL to run (piped into the engine client)'),
|
|
800
818
|
readOnly: { type: 'boolean', description: 'true (default) runs in a read-only session; false allows writes' },
|
|
801
819
|
...allowCrossEnvProp,
|
|
802
820
|
},
|
|
@@ -807,6 +825,24 @@ export const TOOLS = [
|
|
|
807
825
|
return (await api.post(`/api/v1/databases/${args.databaseId}/query`, { sql: args.sql, readOnly: args.readOnly })).data;
|
|
808
826
|
},
|
|
809
827
|
},
|
|
828
|
+
{
|
|
829
|
+
name: 'neo_redis_command',
|
|
830
|
+
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.',
|
|
831
|
+
mutating: true,
|
|
832
|
+
schema: {
|
|
833
|
+
type: 'object',
|
|
834
|
+
properties: {
|
|
835
|
+
databaseId: str('Database id (redis, deployed)'),
|
|
836
|
+
command: str('Redis command, e.g. "GET mykey" or "INFO memory"'),
|
|
837
|
+
...allowCrossEnvProp,
|
|
838
|
+
},
|
|
839
|
+
required: ['databaseId', 'command'],
|
|
840
|
+
},
|
|
841
|
+
async call(api, args) {
|
|
842
|
+
await assertResourceEnv(api, `/api/v1/databases/${args.databaseId}`, args.allowCrossEnvironment); // JNG #136 §4
|
|
843
|
+
return (await api.post(`/api/v1/databases/${args.databaseId}/redis-command`, { command: args.command })).data;
|
|
844
|
+
},
|
|
845
|
+
},
|
|
810
846
|
{
|
|
811
847
|
name: 'neo_set_variable',
|
|
812
848
|
description:
|
|
@@ -824,7 +860,7 @@ export const TOOLS = [
|
|
|
824
860
|
required: ['projectId', 'key', 'value'],
|
|
825
861
|
},
|
|
826
862
|
async call(api, args) {
|
|
827
|
-
const env = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
863
|
+
const env = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
828
864
|
const path = `/api/v1/projects/${args.projectId}/environments/${env}/config`;
|
|
829
865
|
// The PUT replaces the whole set, so merge current vars first.
|
|
830
866
|
const current = (await api.get(path)).data?.vars || [];
|
|
@@ -936,14 +972,14 @@ export const TOOLS = [
|
|
|
936
972
|
{
|
|
937
973
|
name: 'neo_estimate',
|
|
938
974
|
description:
|
|
939
|
-
'Estimate the hourly and monthly cost (IDR) of a service or resource from the LIVE price book, so you can show the user the cost BEFORE a billable create/deploy (handoff 3.3/3.4). Read-only. Covers compute (a tier
|
|
975
|
+
'Estimate the hourly and monthly cost (IDR) of a service or resource from the LIVE price book, so you can show the user the cost BEFORE a billable create/deploy (handoff 3.3/3.4). Read-only. Compute is resource-based (#144): priced per vCPU-hour + GB-RAM-hour independently (no packages), hourly, capped at 730 billable hours/month. Covers compute (vcpu+memoryMB, or a legacy tier shorthand that resolves to concrete vCPU/RAM), persistent storage, and object storage (all per GB-hour); variable usage (queue messages, cron runs) is excluded and noted. The monthly figure is a non-authoritative estimate (hourly × up to 730). Pass orgId to apply that org\'s price overrides.',
|
|
940
976
|
mutating: false,
|
|
941
977
|
schema: {
|
|
942
978
|
type: 'object',
|
|
943
979
|
properties: {
|
|
944
|
-
computeTier: str('
|
|
945
|
-
vcpu: { type: 'number', description: '
|
|
946
|
-
memoryMB: int('
|
|
980
|
+
computeTier: str('Legacy size shorthand (micro|small|medium|large|xlarge) — resolves to concrete vCPU/RAM. Prefer vcpu+memoryMB. Compute is resource-based (#144), not packaged.'),
|
|
981
|
+
vcpu: { type: 'number', description: 'vCPU count — priced per vCPU-hour (preferred over computeTier)' },
|
|
982
|
+
memoryMB: int('Memory in MB — priced per GB-RAM-hour (use with vcpu)'),
|
|
947
983
|
storageGB: int('Persistent/database storage in GB (optional)'),
|
|
948
984
|
objectStorageGB: int('Object-storage quota in GB (optional)'),
|
|
949
985
|
replicas: int('Replica count (default 1)'),
|
|
@@ -1023,7 +1059,7 @@ export const TOOLS = [
|
|
|
1023
1059
|
},
|
|
1024
1060
|
{
|
|
1025
1061
|
name: 'neo_list_templates',
|
|
1026
|
-
description: 'List deployable templates (e.g.
|
|
1062
|
+
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
1063
|
mutating: false,
|
|
1028
1064
|
schema: { type: 'object', properties: {} },
|
|
1029
1065
|
async call(api) {
|
|
@@ -1032,7 +1068,7 @@ export const TOOLS = [
|
|
|
1032
1068
|
},
|
|
1033
1069
|
{
|
|
1034
1070
|
name: 'neo_deploy_template',
|
|
1035
|
-
description: 'Deploy a template (e.g.
|
|
1071
|
+
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
1072
|
mutating: true,
|
|
1037
1073
|
schema: {
|
|
1038
1074
|
type: 'object',
|
|
@@ -1069,7 +1105,7 @@ export const TOOLS = [
|
|
|
1069
1105
|
required: ['projectId', 'path', 'environment'],
|
|
1070
1106
|
},
|
|
1071
1107
|
async call(api, args) {
|
|
1072
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment);
|
|
1108
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment);
|
|
1073
1109
|
const p = resolve(args.path);
|
|
1074
1110
|
let src;
|
|
1075
1111
|
try {
|
|
@@ -1129,7 +1165,7 @@ export const TOOLS = [
|
|
|
1129
1165
|
properties: {
|
|
1130
1166
|
projectId: str('Project id'),
|
|
1131
1167
|
name: str('Database name'),
|
|
1132
|
-
engine: { type: 'string', description: 'postgres | mysql (engine
|
|
1168
|
+
engine: { type: 'string', description: 'postgres | mysql | redis (also mariadb, mongo) — engine' },
|
|
1133
1169
|
version: str('Engine version, e.g. "16" (optional)'),
|
|
1134
1170
|
environment: { type: 'string', enum: ['staging', 'production'], description: 'REQUIRED — staging or production (never defaulted, handoff §2)' },
|
|
1135
1171
|
...allowCrossEnvProp,
|
|
@@ -1142,7 +1178,7 @@ export const TOOLS = [
|
|
|
1142
1178
|
required: ['projectId', 'name', 'engine', 'environment'],
|
|
1143
1179
|
},
|
|
1144
1180
|
async call(api, args) {
|
|
1145
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1181
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1146
1182
|
validateDbArgs(args); // reject sub-10Gi / EOL config before pricing (JNG #132)
|
|
1147
1183
|
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
1184
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
@@ -1213,9 +1249,9 @@ export const TOOLS = [
|
|
|
1213
1249
|
},
|
|
1214
1250
|
{
|
|
1215
1251
|
name: 'neo_verify_database',
|
|
1216
|
-
description: 'Functionally verify a managed database (handoff §8.1): runs an in-cluster probe that
|
|
1252
|
+
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
1253
|
mutating: true,
|
|
1218
|
-
schema: { type: 'object', properties: { databaseId: str('Database id (
|
|
1254
|
+
schema: { type: 'object', properties: { databaseId: str('Database id (deployed)') }, required: ['databaseId'] },
|
|
1219
1255
|
async call(api, args) {
|
|
1220
1256
|
const { status, data } = await api.post(`/api/v1/databases/${args.databaseId}/verify`);
|
|
1221
1257
|
return result(status, data);
|
|
@@ -1223,13 +1259,13 @@ export const TOOLS = [
|
|
|
1223
1259
|
},
|
|
1224
1260
|
{
|
|
1225
1261
|
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.',
|
|
1262
|
+
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
1263
|
mutating: true,
|
|
1228
1264
|
destructive: true,
|
|
1229
1265
|
schema: {
|
|
1230
1266
|
type: 'object',
|
|
1231
1267
|
properties: {
|
|
1232
|
-
databaseId: str('Database id (postgres)'),
|
|
1268
|
+
databaseId: str('Database id (postgres/mysql)'),
|
|
1233
1269
|
backupId: str('Backup id to restore from (optional; default the latest successful backup)'),
|
|
1234
1270
|
confirm: { type: 'boolean', description: 'Must be true — restore overwrites data in the live database.' },
|
|
1235
1271
|
},
|
|
@@ -1320,7 +1356,7 @@ export const TOOLS = [
|
|
|
1320
1356
|
required: ['projectId', 'name', 'environment'],
|
|
1321
1357
|
},
|
|
1322
1358
|
async call(api, args) {
|
|
1323
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1359
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1324
1360
|
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
1361
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1326
1362
|
const { projectId, confirm, confirmationId, orgId, ...body } = args;
|
|
@@ -1462,7 +1498,7 @@ export const TOOLS = [
|
|
|
1462
1498
|
required: ['projectId', 'name', 'mode', 'scheduleType', 'environment'],
|
|
1463
1499
|
},
|
|
1464
1500
|
async call(api, args) {
|
|
1465
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1501
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1466
1502
|
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
1503
|
if (gate.requiresConfirmation) return { environment, ...gate };
|
|
1468
1504
|
// Merge contentType into headers for convenience (unless already set).
|
|
@@ -1637,8 +1673,9 @@ export const TOOLS = [
|
|
|
1637
1673
|
if (!allowCrossEnvironment) {
|
|
1638
1674
|
let cEnv;
|
|
1639
1675
|
try { cEnv = (await api.get(`/api/v1/apps/${appId}`)).data?.environment; } catch { /* can't resolve → don't block */ }
|
|
1640
|
-
|
|
1641
|
-
|
|
1676
|
+
const pinnedEnv = getSessionEnv(api);
|
|
1677
|
+
if (pinnedEnv && cEnv && cEnv !== pinnedEnv) {
|
|
1678
|
+
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
1679
|
}
|
|
1643
1680
|
if (args.targetType !== 'neo_object_storage') {
|
|
1644
1681
|
const tPath = args.targetType === 'neo_db' ? `/api/v1/databases/${args.targetId}` : `/api/v1/queues/${args.targetId}`;
|
|
@@ -1744,7 +1781,9 @@ export const TOOLS = [
|
|
|
1744
1781
|
branch: str('app: git branch'),
|
|
1745
1782
|
deps: { type: 'array', items: { type: 'string', enum: ['database', 'queue', 'object_storage'] }, description: 'app: managed-resource dependencies this service needs (from neo_scan_repo `deps`). The plan flags any dep not satisfied by a binding as incompleteTopology and apply refuses it unless acknowledged — so a service needing NATS_URL/DATABASE_URL isn\'t deployed into a 503 (JNG #112 §7).' },
|
|
1746
1783
|
port: int('app: container port'),
|
|
1747
|
-
computeTier: str('app
|
|
1784
|
+
computeTier: str('app: legacy size shorthand (micro|small|medium|large|xlarge) → concrete vCPU/RAM; prefer cpuLimit+memoryLimit. Compute is resource-based (#144).'),
|
|
1785
|
+
cpuLimit: str('app: CPU, e.g. "0.5" or "1" (use with memoryLimit) — resource-based'),
|
|
1786
|
+
memoryLimit: str('app: memory, e.g. "512M" or "1Gi" (use with cpuLimit) — resource-based'),
|
|
1748
1787
|
engine: str('database: postgres | mysql'),
|
|
1749
1788
|
version: str('database: engine version, e.g. 16'),
|
|
1750
1789
|
volumeSize: str('database: storage size, e.g. 10Gi'),
|
|
@@ -1790,7 +1829,7 @@ export const TOOLS = [
|
|
|
1790
1829
|
required: ['projectId', 'environment', 'resources'],
|
|
1791
1830
|
},
|
|
1792
1831
|
async call(api, args) {
|
|
1793
|
-
const environment = requireEnv(args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1832
|
+
const environment = requireEnv(api, args.environment, args.allowCrossEnvironment); // explicit, else the session pin (JNG #136)
|
|
1794
1833
|
const { projectId, ...body } = args;
|
|
1795
1834
|
body.environment = environment; // send the resolved env so the pin actually targets it
|
|
1796
1835
|
const { status, data } = await api.post(`/api/v1/projects/${projectId}/plans`, body);
|