ultipa-mcp 1.0.1
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/LICENSE +15 -0
- package/README.md +223 -0
- package/dist/helpers/api.d.ts +9 -0
- package/dist/helpers/api.js +25 -0
- package/dist/helpers/dataplane.d.ts +5 -0
- package/dist/helpers/dataplane.js +88 -0
- package/dist/helpers/env.d.ts +9 -0
- package/dist/helpers/env.js +19 -0
- package/dist/helpers/import.d.ts +45 -0
- package/dist/helpers/import.js +261 -0
- package/dist/helpers/progress.d.ts +1 -0
- package/dist/helpers/progress.js +16 -0
- package/dist/helpers/wait.d.ts +8 -0
- package/dist/helpers/wait.js +79 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +106 -0
- package/dist/instructions.d.ts +1 -0
- package/dist/instructions.js +47 -0
- package/dist/tools/account.d.ts +2 -0
- package/dist/tools/account.js +4 -0
- package/dist/tools/alerts.d.ts +2 -0
- package/dist/tools/alerts.js +6 -0
- package/dist/tools/backups.d.ts +2 -0
- package/dist/tools/backups.js +83 -0
- package/dist/tools/billing.d.ts +2 -0
- package/dist/tools/billing.js +55 -0
- package/dist/tools/dataplane.d.ts +2 -0
- package/dist/tools/dataplane.js +558 -0
- package/dist/tools/docs.d.ts +2 -0
- package/dist/tools/docs.js +197 -0
- package/dist/tools/firewall.d.ts +2 -0
- package/dist/tools/firewall.js +36 -0
- package/dist/tools/instances.d.ts +2 -0
- package/dist/tools/instances.js +158 -0
- package/dist/tools/logs.d.ts +2 -0
- package/dist/tools/logs.js +14 -0
- package/dist/tools/metrics.d.ts +2 -0
- package/dist/tools/metrics.js +15 -0
- package/package.json +59 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Import helpers used by `import_data` in src/tools/dataplane.ts.
|
|
2
|
+
// Kept separate so they're easy to unit-test and so dataplane.ts stays focused
|
|
3
|
+
// on tool registrations.
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
// Minimal RFC 4180-style CSV parser used by `import_data`'s CSV pass-through.
|
|
6
|
+
// Handles quoted fields with delimiter/newlines inside, escaped quotes
|
|
7
|
+
// (`""` → `"`), CRLF / LF line endings, UTF-8 BOM, and a missing trailing
|
|
8
|
+
// newline. Defaults: delimiter = `,`, quote = `"`. Both single-character
|
|
9
|
+
// overrides supported for TSV / semicolon CSV / Windows CSVs etc. For more
|
|
10
|
+
// exotic formats (multi-char delimiter, leading metadata rows), the agent
|
|
11
|
+
// should preprocess client-side and use canonical `nodes`/`edges` arrays.
|
|
12
|
+
export function parseCsv(text, opts = {}) {
|
|
13
|
+
const delimiter = opts.delimiter ?? ",";
|
|
14
|
+
const quote = opts.quote ?? '"';
|
|
15
|
+
if (delimiter.length !== 1) {
|
|
16
|
+
throw new Error(`CSV delimiter must be a single character; got "${delimiter}".`);
|
|
17
|
+
}
|
|
18
|
+
if (quote.length !== 1) {
|
|
19
|
+
throw new Error(`CSV quote must be a single character; got "${quote}".`);
|
|
20
|
+
}
|
|
21
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
22
|
+
text = text.slice(1);
|
|
23
|
+
const rows = [];
|
|
24
|
+
let row = [];
|
|
25
|
+
let field = "";
|
|
26
|
+
let inQuotes = false;
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const c = text[i];
|
|
29
|
+
if (inQuotes) {
|
|
30
|
+
if (c === quote) {
|
|
31
|
+
if (text[i + 1] === quote) {
|
|
32
|
+
field += quote;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
inQuotes = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
field += c;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (c === quote) {
|
|
44
|
+
inQuotes = true;
|
|
45
|
+
}
|
|
46
|
+
else if (c === delimiter) {
|
|
47
|
+
row.push(field);
|
|
48
|
+
field = "";
|
|
49
|
+
}
|
|
50
|
+
else if (c === "\n") {
|
|
51
|
+
row.push(field);
|
|
52
|
+
rows.push(row);
|
|
53
|
+
row = [];
|
|
54
|
+
field = "";
|
|
55
|
+
}
|
|
56
|
+
else if (c === "\r") {
|
|
57
|
+
// ignore; `\n` handles row break
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
field += c;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (field !== "" || row.length > 0) {
|
|
64
|
+
row.push(field);
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
// Coerce a raw CSV cell (always a string) into the requested type. Empty cells
|
|
70
|
+
// become null. Unknown / passthrough types return the original string so the
|
|
71
|
+
// server can do its own coercion if applicable.
|
|
72
|
+
export function coerceCell(value, type) {
|
|
73
|
+
if (value === "")
|
|
74
|
+
return null;
|
|
75
|
+
if (!type)
|
|
76
|
+
return value;
|
|
77
|
+
switch (type.toUpperCase()) {
|
|
78
|
+
case "INT":
|
|
79
|
+
case "INTEGER":
|
|
80
|
+
case "BIGINT": {
|
|
81
|
+
const n = parseInt(value, 10);
|
|
82
|
+
if (Number.isNaN(n))
|
|
83
|
+
throw new Error(`Cannot coerce "${value}" to INT`);
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
case "FLOAT":
|
|
87
|
+
case "DOUBLE": {
|
|
88
|
+
const f = parseFloat(value);
|
|
89
|
+
if (Number.isNaN(f))
|
|
90
|
+
throw new Error(`Cannot coerce "${value}" to FLOAT`);
|
|
91
|
+
return f;
|
|
92
|
+
}
|
|
93
|
+
case "BOOL":
|
|
94
|
+
case "BOOLEAN": {
|
|
95
|
+
const v = value.trim().toLowerCase();
|
|
96
|
+
if (["true", "t", "yes", "y", "1"].includes(v))
|
|
97
|
+
return true;
|
|
98
|
+
if (["false", "f", "no", "n", "0"].includes(v))
|
|
99
|
+
return false;
|
|
100
|
+
throw new Error(`Cannot coerce "${value}" to BOOL`);
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
// STRING, TIMESTAMP, DATE, ZONED_DATETIME, etc. — pass through.
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Convert parsed CSV content + companion fields into canonical NodeData[] /
|
|
108
|
+
// EdgeData[]. Shared by inline `csv` content mode and `filePath` (CSV files).
|
|
109
|
+
export function csvToCanonical(content, opts) {
|
|
110
|
+
if (!!opts.fromColumn !== !!opts.toColumn) {
|
|
111
|
+
throw new Error("CSV edge mode requires BOTH `csvFromColumn` and `csvToColumn`.");
|
|
112
|
+
}
|
|
113
|
+
const isEdgeMode = !!opts.fromColumn;
|
|
114
|
+
const rows = parseCsv(content, {
|
|
115
|
+
delimiter: opts.delimiter,
|
|
116
|
+
quote: opts.quote,
|
|
117
|
+
});
|
|
118
|
+
const header = rows[0];
|
|
119
|
+
if (!header)
|
|
120
|
+
throw new Error("CSV is empty (no header row).");
|
|
121
|
+
const dataRows = rows
|
|
122
|
+
.slice(1)
|
|
123
|
+
.filter((r) => !(r.length === 1 && r[0] === ""));
|
|
124
|
+
const colIdx = (name) => {
|
|
125
|
+
const i = header.indexOf(name);
|
|
126
|
+
if (i < 0)
|
|
127
|
+
throw new Error(`CSV column "${name}" not found in header: ${header.join(", ")}`);
|
|
128
|
+
return i;
|
|
129
|
+
};
|
|
130
|
+
const idIdx = opts.idColumn ? colIdx(opts.idColumn) : -1;
|
|
131
|
+
const fromIdx = isEdgeMode ? colIdx(opts.fromColumn) : -1;
|
|
132
|
+
const toIdx = isEdgeMode ? colIdx(opts.toColumn) : -1;
|
|
133
|
+
const excluded = new Set([idIdx, fromIdx, toIdx].filter((i) => i >= 0));
|
|
134
|
+
const propMapping = opts.properties
|
|
135
|
+
? opts.properties.map((m) => ({
|
|
136
|
+
property: m.property,
|
|
137
|
+
colIdx: colIdx(m.column),
|
|
138
|
+
type: m.type,
|
|
139
|
+
}))
|
|
140
|
+
: header
|
|
141
|
+
.map((col, i) => excluded.has(i) ? null : { property: col, colIdx: i })
|
|
142
|
+
.filter((m) => m !== null);
|
|
143
|
+
const buildProps = (row) => {
|
|
144
|
+
const props = {};
|
|
145
|
+
for (const m of propMapping) {
|
|
146
|
+
props[m.property] = coerceCell(row[m.colIdx] ?? "", m.type);
|
|
147
|
+
}
|
|
148
|
+
return props;
|
|
149
|
+
};
|
|
150
|
+
if (isEdgeMode) {
|
|
151
|
+
const edges = dataRows.map((row, i) => {
|
|
152
|
+
const fromNodeId = row[fromIdx];
|
|
153
|
+
const toNodeId = row[toIdx];
|
|
154
|
+
if (!fromNodeId || !toNodeId)
|
|
155
|
+
throw new Error(`Row ${i + 2}: missing _from / _to (both required).`);
|
|
156
|
+
const edge = {
|
|
157
|
+
label: opts.label,
|
|
158
|
+
fromNodeId,
|
|
159
|
+
toNodeId,
|
|
160
|
+
properties: buildProps(row),
|
|
161
|
+
};
|
|
162
|
+
if (idIdx >= 0 && row[idIdx])
|
|
163
|
+
edge.id = row[idIdx];
|
|
164
|
+
return edge;
|
|
165
|
+
});
|
|
166
|
+
return { edges, rowCount: edges.length };
|
|
167
|
+
}
|
|
168
|
+
const nodes = dataRows.map((row) => {
|
|
169
|
+
const node = {
|
|
170
|
+
labels: [opts.label],
|
|
171
|
+
properties: buildProps(row),
|
|
172
|
+
};
|
|
173
|
+
if (idIdx >= 0 && row[idIdx])
|
|
174
|
+
node.id = row[idIdx];
|
|
175
|
+
return node;
|
|
176
|
+
});
|
|
177
|
+
return { nodes, rowCount: nodes.length };
|
|
178
|
+
}
|
|
179
|
+
// Convert JSON / JSONL content into canonical NodeData[] / EdgeData[].
|
|
180
|
+
// Auto-detects shape: `NodeData[]` (labels array on first item), `EdgeData[]`
|
|
181
|
+
// (fromNodeId/toNodeId on first item), or `{nodes, edges}` mixed object.
|
|
182
|
+
export function jsonToCanonical(content, isJsonl) {
|
|
183
|
+
let parsed;
|
|
184
|
+
if (isJsonl) {
|
|
185
|
+
parsed = content
|
|
186
|
+
.split(/\r?\n/)
|
|
187
|
+
.map((line) => line.trim())
|
|
188
|
+
.filter((line) => line !== "")
|
|
189
|
+
.map((line, i) => {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(line);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
throw new Error(`Invalid JSON on line ${i + 1}: ${e?.message ?? String(e)}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(content);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
throw new Error(`Invalid JSON: ${e?.message ?? String(e)}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(parsed)) {
|
|
207
|
+
if (parsed.length === 0)
|
|
208
|
+
throw new Error("JSON array is empty.");
|
|
209
|
+
const first = parsed[0];
|
|
210
|
+
if (first &&
|
|
211
|
+
typeof first === "object" &&
|
|
212
|
+
"fromNodeId" in first &&
|
|
213
|
+
"toNodeId" in first) {
|
|
214
|
+
return { edges: parsed };
|
|
215
|
+
}
|
|
216
|
+
if (first && typeof first === "object" && Array.isArray(first.labels)) {
|
|
217
|
+
return { nodes: parsed };
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`Cannot detect JSON shape. Array items must be NodeData ({labels, properties, id?}) or EdgeData ({label, fromNodeId, toNodeId, properties, id?}). First item: ${JSON.stringify(first).slice(0, 200)}`);
|
|
220
|
+
}
|
|
221
|
+
if (parsed && typeof parsed === "object" && (parsed.nodes || parsed.edges)) {
|
|
222
|
+
return {
|
|
223
|
+
nodes: parsed.nodes,
|
|
224
|
+
edges: parsed.edges,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
throw new Error("JSON root must be NodeData[] / EdgeData[] / {nodes, edges}.");
|
|
228
|
+
}
|
|
229
|
+
// Dispatch a host file path to the right parser by extension. CSV requires
|
|
230
|
+
// `csvLabel` + companion fields; JSON / JSONL parse straight into canonical
|
|
231
|
+
// shape with no extra config.
|
|
232
|
+
export async function loadFilePath(path, csvOpts) {
|
|
233
|
+
const ext = path.toLowerCase().split(".").pop();
|
|
234
|
+
let content;
|
|
235
|
+
try {
|
|
236
|
+
content = await readFile(path, "utf-8");
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
throw new Error(`Cannot read file "${path}": ${e?.message ?? String(e)}. Path must be readable by the MCP process (typically the user's local machine for stdio MCPs).`);
|
|
240
|
+
}
|
|
241
|
+
if (ext === "csv") {
|
|
242
|
+
if (!csvOpts.label) {
|
|
243
|
+
throw new Error("CSV `filePath` requires `csvLabel` (the node or edge label).");
|
|
244
|
+
}
|
|
245
|
+
const { nodes, edges, rowCount } = csvToCanonical(content, {
|
|
246
|
+
label: csvOpts.label,
|
|
247
|
+
idColumn: csvOpts.idColumn,
|
|
248
|
+
fromColumn: csvOpts.fromColumn,
|
|
249
|
+
toColumn: csvOpts.toColumn,
|
|
250
|
+
properties: csvOpts.properties,
|
|
251
|
+
delimiter: csvOpts.delimiter,
|
|
252
|
+
quote: csvOpts.quote,
|
|
253
|
+
});
|
|
254
|
+
return { nodes, edges, format: "csv", rowCount };
|
|
255
|
+
}
|
|
256
|
+
if (ext === "json" || ext === "jsonl") {
|
|
257
|
+
const { nodes, edges } = jsonToCanonical(content, ext === "jsonl");
|
|
258
|
+
return { nodes, edges, format: ext };
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`Unsupported file extension ".${ext}" for "${path}". Supported: .csv, .json, .jsonl`);
|
|
261
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function makeProgressReporter(extra: any): ((step: string, status: string | undefined) => Promise<void>) | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function makeProgressReporter(extra) {
|
|
2
|
+
const token = extra?._meta?.progressToken;
|
|
3
|
+
if (token === undefined || token === null) {
|
|
4
|
+
console.error(`[ultipa-mcp] tool invoked WITHOUT _meta.progressToken — progress notifications will not be sent. Client cannot render mid-call progress.`);
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
console.error(`[ultipa-mcp] progressToken received: ${JSON.stringify(token)} — will stream notifications/progress.`);
|
|
8
|
+
let tick = 0;
|
|
9
|
+
return async (step, status) => {
|
|
10
|
+
const message = status ? `${step} (status: ${status})` : step;
|
|
11
|
+
await extra.sendNotification({
|
|
12
|
+
method: "notifications/progress",
|
|
13
|
+
params: { progressToken: token, progress: ++tick, message },
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const STATUS_IN_TRANSITION: Set<string>;
|
|
2
|
+
export type WaitOpts = {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
pollIntervalMs?: number;
|
|
5
|
+
onProgress?: (step: string, status: string | undefined) => void | Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function waitForSettled(id: string, target: "running" | "paused" | "deleted", opts?: WaitOpts): Promise<any>;
|
|
8
|
+
export declare function waitForBackup(instanceId: string, backupId: string, opts?: WaitOpts): Promise<any>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Why two fields (status + progressStep)?
|
|
2
|
+
//
|
|
3
|
+
// Ultipa Cloud has no job API — state-change endpoints (resume, restart, etc.)
|
|
4
|
+
// return 200 immediately and the work continues in the background. The polling
|
|
5
|
+
// target is the instance object on /v1/instances/:id, but the transition
|
|
6
|
+
// signal requires reading BOTH:
|
|
7
|
+
//
|
|
8
|
+
// - `status`: provisioning | running | paused | suspended | error |
|
|
9
|
+
// deleting | upgrading | deleted. The first three are themselves
|
|
10
|
+
// in-transition values.
|
|
11
|
+
// - `progressStep`: a non-empty string ALWAYS means "in transition",
|
|
12
|
+
// regardless of `status`.
|
|
13
|
+
//
|
|
14
|
+
// Gotcha: resume-from-paused keeps `status: 'paused'` for the entire op — the
|
|
15
|
+
// only signal it's in flight is `progressStep` (e.g. "Resuming instance...").
|
|
16
|
+
// Same pattern for restart and set_log_level (status stays 'running', only
|
|
17
|
+
// `progressStep` changes). Don't strip the `progressStep` guard thinking it's
|
|
18
|
+
// redundant with `status` — it isn't.
|
|
19
|
+
import { api } from "./api.js";
|
|
20
|
+
export const STATUS_IN_TRANSITION = new Set([
|
|
21
|
+
"provisioning",
|
|
22
|
+
"upgrading",
|
|
23
|
+
"deleting",
|
|
24
|
+
]);
|
|
25
|
+
export async function waitForSettled(id, target, opts = {}) {
|
|
26
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
27
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 3000;
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
let last = null;
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
try {
|
|
32
|
+
last = await api(`/v1/instances/${id}`);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (target === "deleted" && String(e).includes("404")) {
|
|
36
|
+
await opts.onProgress?.("Instance deleted.", "deleted");
|
|
37
|
+
return { _id: id, status: "deleted" };
|
|
38
|
+
}
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
const status = last?.status;
|
|
42
|
+
const progressStep = last?.progressStep ?? "";
|
|
43
|
+
const inTransition = progressStep !== "" ||
|
|
44
|
+
(status ? STATUS_IN_TRANSITION.has(status) : false);
|
|
45
|
+
if (!inTransition) {
|
|
46
|
+
if (status === target)
|
|
47
|
+
return last;
|
|
48
|
+
if (status === "error" || status === "suspended") {
|
|
49
|
+
throw new Error(`Instance ${id} settled on "${status}" (failure). Last state: ${JSON.stringify(last)}`);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Instance ${id} settled on "${status}", expected "${target}". Last state: ${JSON.stringify(last)}`);
|
|
52
|
+
}
|
|
53
|
+
await opts.onProgress?.(progressStep || `Waiting for status "${target}"...`, status);
|
|
54
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for instance ${id} to reach "${target}". Last status: "${last?.status}", progressStep: "${last?.progressStep ?? ""}".`);
|
|
57
|
+
}
|
|
58
|
+
export async function waitForBackup(instanceId, backupId, opts = {}) {
|
|
59
|
+
const timeoutMs = opts.timeoutMs ?? 600_000;
|
|
60
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
let backup = null;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
const backups = (await api(`/v1/instances/${instanceId}/backups`));
|
|
65
|
+
backup = backups.find((b) => b?._id === backupId);
|
|
66
|
+
if (!backup) {
|
|
67
|
+
throw new Error(`Backup ${backupId} not found on instance ${instanceId} during wait.`);
|
|
68
|
+
}
|
|
69
|
+
const status = backup.status;
|
|
70
|
+
if (status === "completed")
|
|
71
|
+
return backup;
|
|
72
|
+
if (status === "failed") {
|
|
73
|
+
throw new Error(`Backup ${backupId} failed. Last state: ${JSON.stringify(backup)}`);
|
|
74
|
+
}
|
|
75
|
+
await opts.onProgress?.(`Backup ${status}...`, status);
|
|
76
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for backup ${backupId} on instance ${instanceId}. Last status: "${backup?.status}".`);
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { DEBUG, INSTANCE_HOST, INSTANCE_PASSWORD, INSTANCE_USER, hasModeA, hasModeB, } from "./helpers/env.js";
|
|
8
|
+
import { SERVER_INSTRUCTIONS } from "./instructions.js";
|
|
9
|
+
import { closeAllDataPlaneClients } from "./helpers/dataplane.js";
|
|
10
|
+
import { registerAccountTools } from "./tools/account.js";
|
|
11
|
+
import { registerInstanceTools } from "./tools/instances.js";
|
|
12
|
+
import { registerMetricsTools } from "./tools/metrics.js";
|
|
13
|
+
import { registerLogTools } from "./tools/logs.js";
|
|
14
|
+
import { registerAlertTools } from "./tools/alerts.js";
|
|
15
|
+
import { registerFirewallTools } from "./tools/firewall.js";
|
|
16
|
+
import { registerBillingTools } from "./tools/billing.js";
|
|
17
|
+
import { registerBackupTools } from "./tools/backups.js";
|
|
18
|
+
import { registerDataPlaneTools } from "./tools/dataplane.js";
|
|
19
|
+
import { registerDocsTools } from "./tools/docs.js";
|
|
20
|
+
// Read package version at runtime so it stays in sync with package.json.
|
|
21
|
+
// Works in both dev (tsx, file at src/index.ts) and prod (dist/index.js) since
|
|
22
|
+
// package.json sits two levels up from either.
|
|
23
|
+
const PKG_VERSION = (() => {
|
|
24
|
+
try {
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
27
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "0.0.0";
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
// ── Fail-fast — neither mode configured, OR Direct mode partially configured ─
|
|
34
|
+
const partialDirect = !hasModeB && (!!INSTANCE_HOST || !!INSTANCE_USER || !!INSTANCE_PASSWORD);
|
|
35
|
+
if (!hasModeA && (!hasModeB || partialDirect)) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (partialDirect) {
|
|
38
|
+
const missing = [];
|
|
39
|
+
if (!INSTANCE_HOST)
|
|
40
|
+
missing.push("ULTIPA_HOST");
|
|
41
|
+
if (!INSTANCE_USER)
|
|
42
|
+
missing.push("ULTIPA_USERNAME");
|
|
43
|
+
if (!INSTANCE_PASSWORD)
|
|
44
|
+
missing.push("ULTIPA_PASSWORD");
|
|
45
|
+
lines.push(`Direct instance config is incomplete — missing: ${missing.join(", ")}.`, "All three of ULTIPA_HOST, ULTIPA_USERNAME, ULTIPA_PASSWORD are required for Direct mode.", "");
|
|
46
|
+
}
|
|
47
|
+
lines.push("Ultipa MCP needs at least one auth mode configured:", "", " Ultipa Cloud (manage instances and run GQL against any instance on the account):", " ULTIPA_CLOUD_API_KEY=uc_...", " Get a key at https://dbaas.ultipa.com → Settings → API Keys.", "", " Direct instance (run GQL against one specific GQLDB instance, no Cloud account needed):", " ULTIPA_HOST=host:port", " ULTIPA_USERNAME=...", " ULTIPA_PASSWORD=...", " ULTIPA_GRAPH=... (optional default graph)", "", "Either or both can be set. Set them in your MCP client's server config under `env`, or export them in the shell that launches the server.");
|
|
48
|
+
console.error(lines.join("\n"));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
// ── Cleanup data-plane gRPC connections on shutdown ──────────────────────
|
|
52
|
+
process.on("SIGINT", () => {
|
|
53
|
+
closeAllDataPlaneClients().finally(() => process.exit(0));
|
|
54
|
+
});
|
|
55
|
+
process.on("SIGTERM", () => {
|
|
56
|
+
closeAllDataPlaneClients().finally(() => process.exit(0));
|
|
57
|
+
});
|
|
58
|
+
// ── Server setup ─────────────────────────────────────────────────────────
|
|
59
|
+
const server = new McpServer({ name: "ultipa-mcp", version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
60
|
+
// ── ULTIPA_MCP_DEBUG=1: log every tool call name + latency to stderr ─────
|
|
61
|
+
// Wrap server.tool() so every registered tool's handler is instrumented. Only
|
|
62
|
+
// installed when DEBUG is on, so the default path stays free of overhead.
|
|
63
|
+
if (DEBUG) {
|
|
64
|
+
const originalTool = server.tool.bind(server);
|
|
65
|
+
server.tool = (...args) => {
|
|
66
|
+
const handlerIdx = args.length - 1;
|
|
67
|
+
const handler = args[handlerIdx];
|
|
68
|
+
if (typeof handler === "function") {
|
|
69
|
+
const name = args[0];
|
|
70
|
+
args[handlerIdx] = async (...callArgs) => {
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
try {
|
|
73
|
+
const result = await handler(...callArgs);
|
|
74
|
+
console.error(`[ultipa-mcp] ${name} ok ${Date.now() - start}ms`);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.error(`[ultipa-mcp] ${name} err ${Date.now() - start}ms ${e?.message ?? String(e)}`);
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return originalTool(...args);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ── Ultipa Cloud tools (control plane) ───────────────────────────────────
|
|
87
|
+
if (hasModeA) {
|
|
88
|
+
registerAccountTools(server);
|
|
89
|
+
registerInstanceTools(server);
|
|
90
|
+
registerMetricsTools(server);
|
|
91
|
+
registerLogTools(server);
|
|
92
|
+
registerAlertTools(server);
|
|
93
|
+
registerFirewallTools(server);
|
|
94
|
+
registerBillingTools(server);
|
|
95
|
+
registerBackupTools(server);
|
|
96
|
+
}
|
|
97
|
+
// ── Data-plane tools + GQL docs lookup ───────────────────────────────────
|
|
98
|
+
// Both gated by the same guard: lookup_docs is only useful when the
|
|
99
|
+
// agent can actually compose and run queries, which requires a data-plane
|
|
100
|
+
// target (either Direct instance or Ultipa Cloud with `id`).
|
|
101
|
+
if (hasModeA || hasModeB) {
|
|
102
|
+
registerDataPlaneTools(server);
|
|
103
|
+
registerDocsTools(server);
|
|
104
|
+
}
|
|
105
|
+
const transport = new StdioServerTransport();
|
|
106
|
+
await server.connect(transport);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Server-level instructions surfaced to the MCP client alongside the tool list.
|
|
2
|
+
// These guide the agent's behavior across all calls.
|
|
3
|
+
export const SERVER_INSTRUCTIONS = [
|
|
4
|
+
"**1. Two Paths to a GQLDB Instance**",
|
|
5
|
+
"a. **Cloud-managed instances** (set `ULTIPA_CLOUD_API_KEY` in MCP's env). Ultipa Cloud (https://dbaas.ultipa.com) is a DBaaS platfrom that fully manages GQLDB instances. Instances discoverable via `list_instances`. State-change tools (create, pause, resume, delete, etc.) operate on these. To connect to one of these instances, pass its `_id` as the `id` arg and get credientials via `get_instance_credentials`. Run queries on the instance with data-plane tools (`run_gql_query`, etc.).",
|
|
6
|
+
"b. **Directly-configured instance** (set `ULTIPA_HOST` + `ULTIPA_USERNAME` + `ULTIPA_PASSWORD` + optional `ULTIPA_GRAPH` in MCP's env). To connect to it, call a data-plane tool WITHOUT an `id` arg, the MCP routes the call to the env-configured instance.",
|
|
7
|
+
"",
|
|
8
|
+
"`list_instances` only sees Cloud instances. A Direct instance does NOT appear there but is reachable via data-plane tools without an `id` arg. If user asks about a self-managed / local instance, do not conclude 'not visible' from `list_instances` — call `test_connection` (omit `id`). If it returns `ok: true`, a Direct instance is reachable; if it errors with 'Direct instance is not configured', then only Ultipa Cloud is set up in this MCP.",
|
|
9
|
+
"",
|
|
10
|
+
"For Cloud instances, `list_instances` or `get_instance` does NOT return instance `adminPassword`. It's returned by `create_instance` — surface it to the user immediately after a create. For an existing instance, call `get_instance_credentials` (requires `ULTIPA_CLOUD_API_KEY` to have the `instances:credentials` permission).",
|
|
11
|
+
"",
|
|
12
|
+
"If `get_instance_credentials` fails on permissions, the recovery options are: (1) copy the password from https://dbaas.ultipa.com → Instance Details page, (2) regenerate the API key with the `instances:credentials` permission from https://dbaas.ultipa.com → Settings → API Keys, or (3) only when the user explicitly asks to rotate the password, call `reset_admin_password`.",
|
|
13
|
+
"",
|
|
14
|
+
"**2. Graph Query and Analytics**",
|
|
15
|
+
'**Use `lookup_docs` aggressively.** Training-time knowledge of Ultipa GQLDB or GQL is often wrong on edges. For any Ultipa or GQL specific question — schema design, GQL syntax, function signatures, ontology features, algorithm catalog — call `lookup_docs` FIRST and use the returned markdown as ground truth instead of guessing. The tool\'s description and empty-call response list the curated entry points; call `lookup_docs({ topic: "?" })` for the full live index.',
|
|
16
|
+
"",
|
|
17
|
+
"The query language is **GQL** (ISO/IEC 39075 standard Graph Query Language). It is NOT UQL, Cypher, or GraphQL.",
|
|
18
|
+
"",
|
|
19
|
+
"To answer data questions against a graph (e.g. 'what's the max age of users', 'count books by author X'):",
|
|
20
|
+
"a. An instance can host multiple graphs (check via `list_graphs`) with one marked as 'current', and queries run on it. Before running a query, ensure you know which graph the user is now working with (`ULTIPA_GRAPH` set in env or another one).",
|
|
21
|
+
"b. If you don't already know the graph schema/structure, call `describe_schema`. Discover node/edge labels and properties before composing GQL.",
|
|
22
|
+
"c. When composing the GQL query, for any syntax you're not certain about, follow the `lookup_docs` rule above.",
|
|
23
|
+
"d. Run GQL query via `run_gql_query` and report the result in natural language.",
|
|
24
|
+
"e. If the first run returns zero rows or errors, the schema is probably mis-guessed. Re-check `describe_schema` and retry, don't keep guessing.",
|
|
25
|
+
"",
|
|
26
|
+
"For complex analytical questions ('find influential users', 'detect communities', 'graph embedding'), check whether a built-in graph algorithm or stored procedure fits BEFORE composing custom GQL. They are dramatically faster on large graphs and can do things normal GQL query cannot achieve. Discovery:",
|
|
27
|
+
"- **Built-in algorithms** (run via `run_algo`): `lookup_docs('graph-algorithms/introduction')` for the algo catalog by category, then `lookup_docs('graph-algorithms/<category>/<algorithm>')` for one algorithm details (e.g. `centrality/pagerank`, `community-detection/louvain`). If needs to further process algo results, you may combine algo with other GQL statements and run via `run_gql_query`.",
|
|
28
|
+
"- **Stored procedures**: to call a procedure, use `run_gql_query` with `CALL <procedureName>(...)` (list available with `SHOW PROCEDURES`; see `lookup_docs('stored-procedures/calling-procedures')`). To **create** a procedure, use `write_procedure` — the procedure body has its own grammar (NOT GQL), so always `lookup_docs('stored-procedures/procedure-body/procedure-body-language')` first.",
|
|
29
|
+
"",
|
|
30
|
+
"**3. Data Writing Options",
|
|
31
|
+
"BEFORE choosing a tool to use, consider:",
|
|
32
|
+
"- If user **wrote out a small literal record in-conversation** (e.g. 'add a User node with name=Alice') → use `write_data` with hand-composed GQL.",
|
|
33
|
+
"- If user **attached / uploaded / pasted ANY file with row data** (csv, json, jsonl, graphml, tsv, pasted table) → MUST use `import_data`. **CRITICAL:** You MUST NOT compose `INSERT` statements row-by-row from file rows. `write_data` is for hand-composed GQL only — not for file-derived data. This rule overrides whatever INSERT-shaped reflex you have from training data.",
|
|
34
|
+
"If user has **large imports** (thousands of rows) or **other data sources** (SQL databases, other graph platforms, big-data systems) → suggest Ultipa Manager → Data Integration (UI wizard, built into Manager — Cloud instances open via `managerUrl`) or **Ultipa Transporter** (batch ETL CLI, https://www.ultipa.com/docs/tools/transporter).",
|
|
35
|
+
"",
|
|
36
|
+
"4. Improve Query Performance",
|
|
37
|
+
"If queries on a large graph are slow, suggest performance options to the user before just retrying:",
|
|
38
|
+
"- **Index** accelerate property filtering in predicates, created via `CREATE INDEX` (run through `run_gql_query`, syntax in `lookup_docs('gql/query-acceleration/index')`). For text search use `CREATE FULLTEXT INDEX` (`lookup_docs('gql/query-acceleration/fulltext-index')`). **Do NOT create indexes without explicit user confirmation** — each index costs storage and slows writes, so wrong indexes are net-negative.",
|
|
39
|
+
"- **Enable Computing Engine** for analytical / heavy workloads (graph algorithms like PageRank / community detection, multi-hop traversals, heavy aggregations). It's an instance-level add-on. Details in `lookup_docs('computing-engine/introduction')`.",
|
|
40
|
+
"",
|
|
41
|
+
"5. Connection Options",
|
|
42
|
+
"If user asks how to connect to an instance, refer the options below:",
|
|
43
|
+
"a.. **Ultipa Manager.** If it's a Cloud instance, open the instance's `managerUrl` in a browser. Easiest first-touch — gives a query editor, schema view, metrics, etc.",
|
|
44
|
+
"b. **Drivers.** Include Python, Java, Go, and . See https://www.ultipa.com/docs/drivers.",
|
|
45
|
+
"c. **Ultipa CLI.** A command-line tool for running queries. See https://www.ultipa.com/docs/tools/cli.",
|
|
46
|
+
"",
|
|
47
|
+
].join("\n");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { api, json } from "../helpers/api.js";
|
|
2
|
+
export function registerAccountTools(server) {
|
|
3
|
+
server.tool("get_account", "Get the authenticated account's profile (email, name, balance flags, billing-related metadata). Useful as a 'who am I?' check or to surface account-wide info to the user.", {}, async () => json(await api("/v1/account")));
|
|
4
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api, json } from "../helpers/api.js";
|
|
3
|
+
export function registerAlertTools(server) {
|
|
4
|
+
server.tool("list_alerts", "List all alerts across the account's instances.", {}, async () => json(await api("/v1/instances/alerts")));
|
|
5
|
+
server.tool("list_instance_alerts", "List alerts for a single instance (all statuses).", { id: z.string().describe("The instance ID") }, async ({ id }) => json(await api(`/v1/instances/${id}/alerts`)));
|
|
6
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api, json } from "../helpers/api.js";
|
|
3
|
+
import { waitForSettled, waitForBackup } from "../helpers/wait.js";
|
|
4
|
+
import { makeProgressReporter } from "../helpers/progress.js";
|
|
5
|
+
export function registerBackupTools(server) {
|
|
6
|
+
server.tool("list_backups", "List all backups for an instance. Each backup has `_id`, `status` (`in_progress` | `completed` | `failed` | `restoring`), `createdAt`, and storage details.", { id: z.string().describe("The instance ID") }, async ({ id }) => json(await api(`/v1/instances/${id}/backups`)));
|
|
7
|
+
server.tool("create_backup", "Trigger an on-demand backup of an instance. Blocks until the backup reaches `completed` (or throws on `failed`). Default timeout 10 min — large instances can take longer; raise via `timeoutMs` if needed.", {
|
|
8
|
+
id: z.string().describe("The instance ID"),
|
|
9
|
+
timeoutMs: z
|
|
10
|
+
.number()
|
|
11
|
+
.int()
|
|
12
|
+
.positive()
|
|
13
|
+
.default(600_000)
|
|
14
|
+
.describe("Max wait, in milliseconds. Default 600000 (10 min)."),
|
|
15
|
+
}, async ({ id, timeoutMs }, extra) => {
|
|
16
|
+
const onProgress = makeProgressReporter(extra);
|
|
17
|
+
const backup = (await api(`/v1/instances/${id}/backups`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
}));
|
|
20
|
+
try {
|
|
21
|
+
return json(await waitForBackup(id, backup._id, { timeoutMs, onProgress }));
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new Error(`Backup ${backup._id} WAS triggered on instance ${id} but waiting for "completed" failed: ${e?.message ?? e}. Do NOT call create_backup again — that would queue a duplicate backup. Call list_backups(id="${id}") to poll the current backup's status, or just wait and check again later.`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
server.tool("restore_backup", "Restore an instance from one of its completed backups. **Destructive: overwrites the instance's current data.** Blocks until the restore completes and the instance is back to 'running' (status stays 'running' throughout — only `progressStep` ('Restoring backup...') indicates the work). Default timeout 10 min.", {
|
|
28
|
+
id: z.string().describe("The instance ID"),
|
|
29
|
+
backupId: z
|
|
30
|
+
.string()
|
|
31
|
+
.describe("The backup ID to restore from (must belong to this instance and have status 'completed')"),
|
|
32
|
+
}, async ({ id, backupId }, extra) => {
|
|
33
|
+
const onProgress = makeProgressReporter(extra);
|
|
34
|
+
await api(`/v1/instances/${id}/restore`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: { backupId },
|
|
37
|
+
});
|
|
38
|
+
return json(await waitForSettled(id, "running", {
|
|
39
|
+
onProgress,
|
|
40
|
+
timeoutMs: 600_000,
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
server.tool("set_backup_schedule", "Set or update an automated backup schedule on an instance. Synchronous; returns the updated instance. For `weekly`, `dayOfWeek` is required (0 = Sunday).", {
|
|
44
|
+
id: z.string().describe("The instance ID"),
|
|
45
|
+
frequency: z
|
|
46
|
+
.enum(["daily", "weekly"])
|
|
47
|
+
.describe("How often the backup should run"),
|
|
48
|
+
UTC_hour: z
|
|
49
|
+
.number()
|
|
50
|
+
.int()
|
|
51
|
+
.min(0)
|
|
52
|
+
.max(23)
|
|
53
|
+
.describe("Hour-of-day (UTC) at which to run, 0–23"),
|
|
54
|
+
dayOfWeek: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(0)
|
|
58
|
+
.max(6)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Day-of-week 0–6 (0 = Sunday). Required when frequency is 'weekly'."),
|
|
61
|
+
}, async ({ id, frequency, UTC_hour, dayOfWeek }) => {
|
|
62
|
+
if (frequency === "weekly" && dayOfWeek === undefined) {
|
|
63
|
+
throw new Error("dayOfWeek is required when frequency is 'weekly'.");
|
|
64
|
+
}
|
|
65
|
+
const body = { frequency, UTC_hour };
|
|
66
|
+
if (dayOfWeek !== undefined)
|
|
67
|
+
body.dayOfWeek = dayOfWeek;
|
|
68
|
+
return json(await api(`/v1/instances/${id}/backup-schedule`, {
|
|
69
|
+
method: "PUT",
|
|
70
|
+
body,
|
|
71
|
+
}));
|
|
72
|
+
});
|
|
73
|
+
server.tool("delete_backup", "Permanently delete a specific backup from an instance. Synchronous. Requires `instances:delete` scope on the API key. Does NOT affect the instance itself — only removes the backup's stored snapshot. Irreversible.", {
|
|
74
|
+
id: z.string().describe("The instance ID the backup belongs to"),
|
|
75
|
+
backupId: z.string().describe("The backup ID to delete"),
|
|
76
|
+
}, async ({ id, backupId }) => {
|
|
77
|
+
await api(`/v1/instances/${id}/backups/${backupId}`, {
|
|
78
|
+
method: "DELETE",
|
|
79
|
+
});
|
|
80
|
+
return json({ deleted: true, instanceId: id, backupId });
|
|
81
|
+
});
|
|
82
|
+
server.tool("clear_backup_schedule", "Remove the automated backup schedule from an instance (existing backups are kept). Synchronous; returns the updated instance.", { id: z.string().describe("The instance ID") }, async ({ id }) => json(await api(`/v1/instances/${id}/backup-schedule`, { method: "DELETE" })));
|
|
83
|
+
}
|