@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 +1 -1
- package/prompt.md +4 -3
- package/src/index.js +11 -2
- package/src/mcp/server.js +117 -16
- package/src/mcp/tools.js +77 -42
package/package.json
CHANGED
package/prompt.md
CHANGED
|
@@ -6,7 +6,7 @@ Keep this in sync with cli/prompt.md (the npm package's bundled copy).
|
|
|
6
6
|
-->
|
|
7
7
|
# Set this repo up on NEO App
|
|
8
8
|
|
|
9
|
-
**Version:** 0.4.
|
|
9
|
+
**Version:** 0.4.74 · **Last updated:** 2026-11-05
|
|
10
10
|
|
|
11
11
|
These are the official setup instructions from NEO App (Biznet Gio's managed-app
|
|
12
12
|
platform). If you are an AI coding agent, complete the steps below yourself by running
|
|
@@ -17,7 +17,7 @@ into chat, repository files, the skill, or MCP configuration.
|
|
|
17
17
|
## What NEO App runs
|
|
18
18
|
|
|
19
19
|
Web services, background workers, scheduled jobs, static sites, managed databases
|
|
20
|
-
(PostgreSQL/MySQL
|
|
20
|
+
(PostgreSQL/MySQL/Redis), queues (NATS), and object storage — on managed Kubernetes, served
|
|
21
21
|
over HTTPS with automatic TLS. It is **not** for GPU training or for apps that need
|
|
22
22
|
several containers running side by side at runtime (a single app container alongside
|
|
23
23
|
managed databases is fine). If the repo has no server to put behind a URL (a library or
|
|
@@ -118,7 +118,8 @@ tools — never the portal UI. The lifecycle is the same for every resource type
|
|
|
118
118
|
components in a monorepo. Ask the human which to deploy; never collapse a monorepo
|
|
119
119
|
into one service.
|
|
120
120
|
2. **Plan and show the cost**: `neo_plan_stack` returns an immutable, priced plan
|
|
121
|
-
(`planId`, `planHash`, cost in IDR, expiry). Show
|
|
121
|
+
(`planId`, `planHash`, cost in IDR with monthly and hourly totals, expiry). Show
|
|
122
|
+
the human the cost and get an
|
|
122
123
|
explicit **Approve & Create**. Individual creates (`neo_create_database`,
|
|
123
124
|
`neo_create_queue`) instead return the cost and create nothing until you re-call
|
|
124
125
|
with `confirm: true`.
|
package/src/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
|
|
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')
|
|
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
|
|
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'],
|
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
1641
|
-
|
|
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);
|