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,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api, json } from "../helpers/api.js";
|
|
3
|
+
export function registerBillingTools(server) {
|
|
4
|
+
server.tool("get_balance", "Get the account's current Ultipa Cloud balance and related billing flags. Useful as a pre-check before `create_instance` on paid sizes — a paid-tier create with `balance <= 0` will be rejected with HTTP 402.", {}, async () => json(await api("/v1/billing/balance")));
|
|
5
|
+
server.tool("list_transactions", "List the account's balance transactions (top-ups, charges, refunds, adjustments). Ordered by date.", {}, async () => json(await api("/v1/billing/transactions")));
|
|
6
|
+
server.tool("get_usage", "Return the usage-based billing summary for a month (per-instance breakdown of compute, storage, and data-transfer charges). Default: current month.", {
|
|
7
|
+
month: z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^\d{4}-\d{2}$/)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Month in `YYYY-MM` format, e.g. '2026-06'. Omit for current month."),
|
|
12
|
+
}, async ({ month }) => json(await api(`/v1/billing/usage${month ? `?month=${month}` : ""}`)));
|
|
13
|
+
server.tool("get_payment_method", "Return the saved payment method on file (card brand, last4, expiry), or `null` if none. To add or change a card, the user must go to https://dbaas.ultipa.com → Billing — the Stripe card flow requires client-side Stripe.js and can't be driven via MCP.", {}, async () => json(await api("/v1/billing/payment-method")));
|
|
14
|
+
server.tool("get_auto_reload", "Return the account's auto-reload settings: `{ enabled, thresholdCents, targetCents }`. When enabled, the account auto-tops-up to `targetCents` whenever balance drops below `thresholdCents`, charging the saved payment method.", {}, async () => json(await api("/v1/billing/auto-reload")));
|
|
15
|
+
server.tool("topup_balance", "Top up the account's Cloud balance. If a saved payment method exists and the charge doesn't require 3D Secure, the balance is credited immediately and `clientSecret` will be null. If 3DS is required, or there's no saved card, `clientSecret` will be returned and the user must complete the payment at https://dbaas.ultipa.com → Billing (Stripe.js can't be driven from MCP). Either way, `paymentIntentId` is returned for tracking. **DO NOT retry on error** — the previous attempt may have charged the card. Call `list_transactions` first to check whether the top-up went through, then retry only if it's clearly absent.", {
|
|
16
|
+
amountCents: z
|
|
17
|
+
.number()
|
|
18
|
+
.int()
|
|
19
|
+
.min(500)
|
|
20
|
+
.describe("Amount to top up, in cents. Minimum 500 ($5.00)."),
|
|
21
|
+
}, async ({ amountCents }) => json(await api("/v1/billing/top-up", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: { amountCents },
|
|
24
|
+
})));
|
|
25
|
+
server.tool("start_payment_method_setup", "Start a Stripe Checkout session for adding/replacing the saved payment method. Returns `{ url }` — give the URL to the user; they click it, complete card entry in their browser, and Stripe handles the rest. The new card becomes the default automatically. (For the inline-card flow used by the Cloud portal's own UI, use the portal — MCP can't drive inline Stripe.js.)", {
|
|
26
|
+
returnPath: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Optional path on dbaas.ultipa.com to return to after setup (e.g. '/billing'). Defaults to the billing page."),
|
|
30
|
+
}, async ({ returnPath }) => {
|
|
31
|
+
const body = {};
|
|
32
|
+
if (returnPath !== undefined)
|
|
33
|
+
body.returnPath = returnPath;
|
|
34
|
+
return json(await api("/v1/billing/setup-session", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body,
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
server.tool("set_auto_reload", "Update the account's auto-reload settings. Server validates: when `enabled` is true, `targetCents` must be > `thresholdCents`, `targetCents` must be ≥ 500 ($5.00), AND a saved payment method must exist (use `get_payment_method` to check first).", {
|
|
40
|
+
enabled: z.boolean().describe("Turn auto-reload on or off"),
|
|
41
|
+
thresholdCents: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(0)
|
|
45
|
+
.describe("Trigger top-up when balance drops below this (in cents)"),
|
|
46
|
+
targetCents: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(0)
|
|
50
|
+
.describe("Top up to this amount (in cents). Must be > thresholdCents and ≥ 500 when enabled."),
|
|
51
|
+
}, async ({ enabled, thresholdCents, targetCents }) => json(await api("/v1/billing/auto-reload", {
|
|
52
|
+
method: "PUT",
|
|
53
|
+
body: { enabled, thresholdCents, targetCents },
|
|
54
|
+
})));
|
|
55
|
+
}
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HealthStatus, InsertType, } from "@ultipa-graph/ultipa-driver";
|
|
3
|
+
import { json } from "../helpers/api.js";
|
|
4
|
+
import { getDataPlaneClient, resolveDataPlaneTarget, serializeResponse, } from "../helpers/dataplane.js";
|
|
5
|
+
import { DEFAULT_GRAPH, INSTANCE_HOST, hasModeA, hasModeB, } from "../helpers/env.js";
|
|
6
|
+
import { csvToCanonical, loadFilePath } from "../helpers/import.js";
|
|
7
|
+
export function registerDataPlaneTools(server) {
|
|
8
|
+
// Conditional `id` arg: required under Ultipa Cloud only, optional when a Direct
|
|
9
|
+
// instance is also configured, absent under Direct-only.
|
|
10
|
+
const idArg = hasModeA
|
|
11
|
+
? {
|
|
12
|
+
id: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(hasModeB
|
|
16
|
+
? `Instance ID. Omit to target the Direct instance (ULTIPA_HOST${INSTANCE_HOST ? ` = ${INSTANCE_HOST}` : ""}). Pass any other instance ID to route via Ultipa Cloud.`
|
|
17
|
+
: "Instance ID. Required — no Direct instance is configured, so there's no default target."),
|
|
18
|
+
}
|
|
19
|
+
: {};
|
|
20
|
+
const graphArg = {
|
|
21
|
+
graph: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe(`Target graph name. ${DEFAULT_GRAPH ? `Defaults to ULTIPA_GRAPH = "${DEFAULT_GRAPH}"` : "No default configured (set ULTIPA_GRAPH or pass this arg)"}.`),
|
|
25
|
+
};
|
|
26
|
+
server.tool("test_connection", "Quick health check on the target GQLDB instance — call this when the user asks 'can you see/connect my instance' / 'is my GQLDB reachable'. Verifies connectivity and login and returns `{ ok, target, status, latencyMs, error? }`. Much faster than running a real query — use this as the first probe.", { ...idArg }, async (args) => {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
let target;
|
|
29
|
+
try {
|
|
30
|
+
target = resolveDataPlaneTarget(args.id);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return json({
|
|
34
|
+
ok: false,
|
|
35
|
+
target: null,
|
|
36
|
+
latencyMs: Date.now() - start,
|
|
37
|
+
error: e?.message ?? String(e),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const client = await getDataPlaneClient(target);
|
|
42
|
+
const status = await client.healthCheck();
|
|
43
|
+
return json({
|
|
44
|
+
ok: status === HealthStatus.SERVING,
|
|
45
|
+
target,
|
|
46
|
+
status: HealthStatus[status] ?? String(status),
|
|
47
|
+
latencyMs: Date.now() - start,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return json({
|
|
52
|
+
ok: false,
|
|
53
|
+
target,
|
|
54
|
+
latencyMs: Date.now() - start,
|
|
55
|
+
error: e?.message ?? String(e),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
server.tool("run_gql_query", "Execute a literal GQL query against a GQLDB instance and return the result rows. For Ultipa Cloud targets, the API key needs the `instances:credentials` scope (used to fetch the instance's admin password). Call `lookup_docs` for the relevant topic first if uncertain about Ultipa-specific GQL syntax — training data is patchy on edges.", {
|
|
60
|
+
...idArg,
|
|
61
|
+
query: z.string().min(1).describe("The GQL query to run"),
|
|
62
|
+
...graphArg,
|
|
63
|
+
}, async (args) => {
|
|
64
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
65
|
+
const client = await getDataPlaneClient(target);
|
|
66
|
+
const cfg = {};
|
|
67
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
68
|
+
if (graphName)
|
|
69
|
+
cfg.graphName = graphName;
|
|
70
|
+
const response = await client.gql(args.query, cfg);
|
|
71
|
+
return json(serializeResponse(response));
|
|
72
|
+
});
|
|
73
|
+
server.tool("explain_query", "Return the execution plan for a GQL query without running it. Same connection model as `run_gql_query`.", {
|
|
74
|
+
...idArg,
|
|
75
|
+
query: z.string().min(1).describe("The GQL query to explain"),
|
|
76
|
+
...graphArg,
|
|
77
|
+
}, async (args) => {
|
|
78
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
79
|
+
const client = await getDataPlaneClient(target);
|
|
80
|
+
const cfg = {};
|
|
81
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
82
|
+
if (graphName)
|
|
83
|
+
cfg.graphName = graphName;
|
|
84
|
+
const plan = await client.explain(args.query, cfg);
|
|
85
|
+
return json({ plan });
|
|
86
|
+
});
|
|
87
|
+
server.tool("run_algo", "Run a built-in graph algorithm. GQLDB ships dozens of algorithms across categories: **centrality** (PageRank, Betweenness, Closeness, ArticleRank, Katz, HITS, etc.), **community detection** (Louvain, Leiden, Label Propagation, K-Means, HANP, etc.), **similarity**, **pathfinding** (shortest paths, BFS/DFS, k-hop), **graph embeddings**, and more. Reach for this on analytical questions ('find influential users' → PageRank; 'detect communities' → Louvain; 'shortest route X to Y' → ShortestPath) instead of hand-computing them in raw GQL — the built-ins are dramatically faster on large graphs. **Discovery**: call `lookup_docs('graph-algorithms/introduction')` for the full catalog by category, then `lookup_docs('graph-algorithms/<category>/<algorithm>')` for one algorithm's exact signature and parameters (e.g. `centrality/pagerank`, `community-detection/louvain`, `pathfinding/mst`). Same execution path as `run_gql_query`; this is a focused affordance so the agent surfaces the algorithm catalog instead of missing it. For non-algo, use `run_gql_query` directly.", {
|
|
88
|
+
...idArg,
|
|
89
|
+
gql: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.describe("The full `CALL algo.<name>(...)` statement. Compose it from `lookup_docs` of the algorithm's reference page."),
|
|
93
|
+
...graphArg,
|
|
94
|
+
}, async (args) => {
|
|
95
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
96
|
+
const client = await getDataPlaneClient(target);
|
|
97
|
+
const cfg = {};
|
|
98
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
99
|
+
if (graphName)
|
|
100
|
+
cfg.graphName = graphName;
|
|
101
|
+
const response = await client.gql(args.gql, cfg);
|
|
102
|
+
return json(serializeResponse(response));
|
|
103
|
+
});
|
|
104
|
+
server.tool("list_graphs", "List all graphs available on the target GQLDB instance (returns the SDK's `GraphInfo[]` — name, mode, node/edge counts, etc.).", { ...idArg }, async (args) => {
|
|
105
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
106
|
+
const client = await getDataPlaneClient(target);
|
|
107
|
+
return json(await client.listGraphs());
|
|
108
|
+
});
|
|
109
|
+
server.tool("describe_schema", "Return the schema of a graph in one shot. Step 1 runs `DESC GRAPH <graph>` to detect the graph's `graph_mode` (OPEN | CLOSED | ONTOLOGY). Step 2 always runs `RETURN db.overview()` (mode-independent holistic view). Step 3 runs the mode-specific introspection: CLOSED → `SHOW NODE TYPES` + `SHOW EDGE TYPES` + `RETURN db.stats()` (labels & properties are defined with each node/edge type); OPEN → `RETURN db.stats()` (labels & properties are free-form, surfaced via stats); ONTOLOGY → `SHOW ONTOLOGY` + `SHOW PREFIX` + `SHOW CLASSES` + `SHOW PROPERTIES`. Lets the agent learn the schema in a single tool call regardless of graph kind.", {
|
|
110
|
+
...idArg,
|
|
111
|
+
...graphArg,
|
|
112
|
+
}, async (args) => {
|
|
113
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
114
|
+
const client = await getDataPlaneClient(target);
|
|
115
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
116
|
+
if (!graphName) {
|
|
117
|
+
throw new Error("describe_schema needs a graph name. Pass the `graph` arg or set ULTIPA_GRAPH.");
|
|
118
|
+
}
|
|
119
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(graphName)) {
|
|
120
|
+
throw new Error(`Graph name "${graphName}" must match [a-zA-Z_][a-zA-Z0-9_]*. Rename the graph or escape it manually via run_gql_query.`);
|
|
121
|
+
}
|
|
122
|
+
const cfg = { graphName };
|
|
123
|
+
const safe = async (query) => {
|
|
124
|
+
try {
|
|
125
|
+
return serializeResponse(await client.gql(query, cfg));
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
return { error: e?.message ?? String(e) };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
// Step 1: detect graph mode via DESC GRAPH.
|
|
132
|
+
let mode;
|
|
133
|
+
const describeGraph = await safe(`DESC GRAPH ${graphName}`);
|
|
134
|
+
const firstRow = describeGraph?.rows?.[0];
|
|
135
|
+
if (firstRow && typeof firstRow === "object") {
|
|
136
|
+
const v = firstRow.graph_mode ?? firstRow.GRAPH_MODE ?? firstRow.mode;
|
|
137
|
+
if (v !== undefined && v !== null)
|
|
138
|
+
mode = String(v).toUpperCase();
|
|
139
|
+
}
|
|
140
|
+
// Step 2: `db.overview()` works for any mode — fire it now, await with the rest.
|
|
141
|
+
const overviewPromise = safe("RETURN db.overview()");
|
|
142
|
+
// Step 3: branch on mode and run the mode-appropriate introspection.
|
|
143
|
+
const out = {
|
|
144
|
+
graph: graphName,
|
|
145
|
+
mode,
|
|
146
|
+
describeGraph,
|
|
147
|
+
};
|
|
148
|
+
if (mode === "CLOSED") {
|
|
149
|
+
const [overview, nodeTypes, edgeTypes, stats] = await Promise.all([
|
|
150
|
+
overviewPromise,
|
|
151
|
+
safe("SHOW NODE TYPES"),
|
|
152
|
+
safe("SHOW EDGE TYPES"),
|
|
153
|
+
safe("RETURN db.stats()"),
|
|
154
|
+
]);
|
|
155
|
+
Object.assign(out, { overview, nodeTypes, edgeTypes, stats });
|
|
156
|
+
}
|
|
157
|
+
else if (mode === "OPEN") {
|
|
158
|
+
const [overview, stats] = await Promise.all([
|
|
159
|
+
overviewPromise,
|
|
160
|
+
safe("RETURN db.stats()"),
|
|
161
|
+
]);
|
|
162
|
+
Object.assign(out, { overview, stats });
|
|
163
|
+
}
|
|
164
|
+
else if (mode === "ONTOLOGY") {
|
|
165
|
+
const [overview, ontology, prefix, classes, properties] = await Promise.all([
|
|
166
|
+
overviewPromise,
|
|
167
|
+
safe("SHOW ONTOLOGY"),
|
|
168
|
+
safe("SHOW PREFIX"),
|
|
169
|
+
safe("SHOW CLASSES"),
|
|
170
|
+
safe("SHOW PROPERTIES"),
|
|
171
|
+
]);
|
|
172
|
+
Object.assign(out, { overview, ontology, prefix, classes, properties });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
// Couldn't extract graph_mode — fall back to running everything safely.
|
|
176
|
+
out.note =
|
|
177
|
+
"graph_mode not found in DESC GRAPH output; running generic introspection.";
|
|
178
|
+
const [overview, nodeTypes, edgeTypes, labels, stats] = await Promise.all([
|
|
179
|
+
overviewPromise,
|
|
180
|
+
safe("SHOW NODE TYPES"),
|
|
181
|
+
safe("SHOW EDGE TYPES"),
|
|
182
|
+
safe("SHOW LABELS"),
|
|
183
|
+
safe("RETURN db.stats()"),
|
|
184
|
+
]);
|
|
185
|
+
Object.assign(out, { overview, nodeTypes, edgeTypes, labels, stats });
|
|
186
|
+
}
|
|
187
|
+
return json(out);
|
|
188
|
+
});
|
|
189
|
+
server.tool("create_graph", "Create a new graph on the GQLDB instance. Ask user which graph mode is wanted if you do not know. Three options: (1) **open**: schema-free graph; labels and properties spring into existence as data is inserted. (2) **closed**: schema-enforced graph; must supply one of: `inlineDefinition` (raw graph-type fragment that goes inside `{ ... }`), `likeGraph` (copy another graph's schema), or `typedName` (bind to a named graph type). `inlineDefinition` tips: node/edge type name is the key label, node type carries optional implied labels via `:Label` syntax (joined with `&` for multiples). Example, `'NODE User (:Employee {name STRING, age UINT32}), NODE Product ({name STRING}), EDGE PURCHASED (User)-[{ts DATETIME}]->(Product)'` creates `User` nodes with label set `:User&Employee`. Edge types do NOT support implied labels. Before composing a non-trivial `inlineDefinition`, call `lookup_docs('gql/graph-management/closed-graphs')` for reference. (3) **ontology**: special mode for modeling RDF data with OWL semantics. After creation, the user loads prefixes and defines classes/properties separately, you can call `lookup_docs('ontology/introduction')`, `lookup_docs('ontology/class-definitions')`, `lookup_docs('ontology/object-properties')` and `lookup_docs('ontology/data-properties')` for reference if user needs further direction.", {
|
|
190
|
+
...idArg,
|
|
191
|
+
name: z
|
|
192
|
+
.string()
|
|
193
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
194
|
+
.describe("Graph name. Must start with a letter or underscore, then letters / digits / underscores only."),
|
|
195
|
+
mode: z
|
|
196
|
+
.enum(["open", "closed", "ontology"])
|
|
197
|
+
.default("open")
|
|
198
|
+
.describe("'open' for schema-free; 'closed' for schema-enforced (requires one of typedName / likeGraph / inlineDefinition); 'ontology' for RDF / OWL-style semantic graphs."),
|
|
199
|
+
typedName: z
|
|
200
|
+
.string()
|
|
201
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
202
|
+
.optional()
|
|
203
|
+
.describe("Closed mode only. Name of an existing named graph type to bind to. Mutually exclusive with likeGraph and inlineDefinition."),
|
|
204
|
+
likeGraph: z
|
|
205
|
+
.string()
|
|
206
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
207
|
+
.optional()
|
|
208
|
+
.describe("Closed mode only. Name of an existing graph whose schema should be copied (no binding). Mutually exclusive with typedName and inlineDefinition."),
|
|
209
|
+
inlineDefinition: z
|
|
210
|
+
.string()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("Closed mode only. Raw GQL type-definition fragment to embed inside `{ ... }`. Mutually exclusive with typedName and likeGraph."),
|
|
213
|
+
}, async (args) => {
|
|
214
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
215
|
+
const client = await getDataPlaneClient(target);
|
|
216
|
+
let gql;
|
|
217
|
+
if (args.mode === "open") {
|
|
218
|
+
if (args.typedName || args.likeGraph || args.inlineDefinition) {
|
|
219
|
+
throw new Error("Open graphs don't take typedName / likeGraph / inlineDefinition. Drop those args or change mode.");
|
|
220
|
+
}
|
|
221
|
+
gql = `CREATE GRAPH ${args.name}`;
|
|
222
|
+
}
|
|
223
|
+
else if (args.mode === "ontology") {
|
|
224
|
+
if (args.typedName || args.likeGraph || args.inlineDefinition) {
|
|
225
|
+
throw new Error("Ontology graphs don't take typedName / likeGraph / inlineDefinition. The user defines classes / properties / prefixes after create (see https://www.ultipa.com/docs/ontology).");
|
|
226
|
+
}
|
|
227
|
+
gql = `CREATE GRAPH ${args.name} WITH ONTOLOGY`;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const provided = [
|
|
231
|
+
args.typedName,
|
|
232
|
+
args.likeGraph,
|
|
233
|
+
args.inlineDefinition,
|
|
234
|
+
].filter((v) => v !== undefined && v !== "").length;
|
|
235
|
+
if (provided !== 1) {
|
|
236
|
+
throw new Error("Closed graphs require exactly one of: typedName, likeGraph, or inlineDefinition.");
|
|
237
|
+
}
|
|
238
|
+
if (args.typedName) {
|
|
239
|
+
gql = `CREATE GRAPH ${args.name} TYPED ${args.typedName}`;
|
|
240
|
+
}
|
|
241
|
+
else if (args.likeGraph) {
|
|
242
|
+
gql = `CREATE GRAPH ${args.name} LIKE ${args.likeGraph}`;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
gql = `CREATE GRAPH ${args.name} { ${args.inlineDefinition} }`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const response = await client.gql(gql);
|
|
249
|
+
return json({
|
|
250
|
+
created: true,
|
|
251
|
+
name: args.name,
|
|
252
|
+
mode: args.mode,
|
|
253
|
+
statement: gql,
|
|
254
|
+
result: serializeResponse(response),
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
server.tool("delete_graph", "Drop a graph from the GQLDB instance. **Destructive: permanently removes all nodes, edges, and indices belonging to the graph.** The instance and other graphs are not affected. Only call when the user has explicitly confirmed they want this graph gone — once dropped, the data is unrecoverable.", {
|
|
258
|
+
...idArg,
|
|
259
|
+
name: z
|
|
260
|
+
.string()
|
|
261
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
262
|
+
.describe("Graph name to drop. Must start with a letter or underscore, then letters / digits / underscores only."),
|
|
263
|
+
}, async (args) => {
|
|
264
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
265
|
+
const client = await getDataPlaneClient(target);
|
|
266
|
+
const gql = `DROP GRAPH ${args.name}`;
|
|
267
|
+
const response = await client.gql(gql);
|
|
268
|
+
return json({
|
|
269
|
+
deleted: true,
|
|
270
|
+
name: args.name,
|
|
271
|
+
statement: gql,
|
|
272
|
+
result: serializeResponse(response),
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
server.tool("write_data", "If the GQL you are about to send contains `INSERT` statements built from rows in a file/CSV/JSON the user shared, you are using the WRONG tool — call `import_data` instead. This rule applies regardless of how natural composing INSERT statements feels; file-derived bulk writes MUST go through `import_data`. Continue here only if user wrote out a small literal record in-conversation.", {
|
|
276
|
+
...idArg,
|
|
277
|
+
gql: z
|
|
278
|
+
.string()
|
|
279
|
+
.min(1)
|
|
280
|
+
.describe("The GQL write statement (INSERT / INSERT OVERWRITE / UPSERT / MERGE / FOREACH). Call `lookup_docs('gql/data-manipulation/<statement>')` first if unsure of syntax (slugs: `insert`, `insert-overwrite`, `upsert`, `merge`, `foreach`). For node/edge `_id` semantics, call `lookup_docs('gql/data-manipulation/node-and-edge-ids')`."),
|
|
281
|
+
...graphArg,
|
|
282
|
+
}, async (args) => {
|
|
283
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
284
|
+
const client = await getDataPlaneClient(target);
|
|
285
|
+
const cfg = {};
|
|
286
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
287
|
+
if (graphName)
|
|
288
|
+
cfg.graphName = graphName;
|
|
289
|
+
const response = await client.gql(args.gql, cfg);
|
|
290
|
+
return json(serializeResponse(response));
|
|
291
|
+
});
|
|
292
|
+
server.tool("import_data", "Use this when user uploaded/pasted any file or row-shaped dat to bulk-write structured nodes/edges to a graph. Do NOT compose `INSERT` statements from file rows. Three import methods: (1) `filePath`. If user hasn't point you a filepath but only uploaded files to chat, STOP and ASK user where the files are stored locally so you can get `filePath`. Emphasize 'fast', 'save tokens' and 'no bacthing'. DO NOT fallback to other two methods. NOTE: once files uploaded to chat, you get paths in sandbox, but that path will FAIL. Keep asking user where the files are saved on local machine. (2) `csv` (raw CSV string; subject to output cap, needs batching). (3) `nodes`/`edges` (canonical arrays; subject to output cap, needs batching). **For >2000 rows, push HARDER**: methods 2/3 are painfully slow, prone to truncation, and burn tokens. Only proceed with methods 2/3 if the user explicitly insists. **Before calling**: preview the plan (method, labels, `_id` / `_from` / `_to` columns, property mapping, `mode`, row counts) and wait for explicit user approval. **Batching**: per-response output cap ~8k tokens; if rows > ~500 (CSV) / ~150 (arrays), split into multiple calls. **Retry safety**: before retrying after a failed/truncated import, run `run_gql_query` (`MATCH (n:<Label>) RETURN count(n)` etc.) to see what's already imported and resend only missing rows. `mode`: `normal` (error on duplicate `_id`, default), `overwrite` (replace), `upsert` (merge). Nodes written BEFORE edges so edges can reference fresh node `_id`s.", {
|
|
293
|
+
...idArg,
|
|
294
|
+
...graphArg,
|
|
295
|
+
nodes: z
|
|
296
|
+
.array(z.object({
|
|
297
|
+
id: z
|
|
298
|
+
.string()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Custom `_id`. Omit to let GQLDB assign a UUID v4. See `lookup_docs('gql/data-manipulation/node-and-edge-ids')`."),
|
|
301
|
+
labels: z
|
|
302
|
+
.array(z.string())
|
|
303
|
+
.describe("Node labels. Open graphs accept any; closed graphs must match a defined node type's full label set (e.g. `['User', 'Employee']`)."),
|
|
304
|
+
properties: z
|
|
305
|
+
.record(z.string(), z.any())
|
|
306
|
+
.describe("Property name → value. Closed graphs validate against the node type's defined properties; open graphs accept any."),
|
|
307
|
+
}))
|
|
308
|
+
.optional()
|
|
309
|
+
.describe("Nodes to insert. At least one of `nodes` / `edges` must be non-empty."),
|
|
310
|
+
edges: z
|
|
311
|
+
.array(z.object({
|
|
312
|
+
id: z
|
|
313
|
+
.string()
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("Custom `_id`. Requires `EDGE_ID ENABLED` on the graph; omit otherwise."),
|
|
316
|
+
label: z
|
|
317
|
+
.string()
|
|
318
|
+
.describe("Edge label (single label per edge — GQL edges don't support multi-label)."),
|
|
319
|
+
fromNodeId: z.string().describe("Source node's `_id`."),
|
|
320
|
+
toNodeId: z.string().describe("Destination node's `_id`."),
|
|
321
|
+
properties: z
|
|
322
|
+
.record(z.string(), z.any())
|
|
323
|
+
.describe("Property name → value."),
|
|
324
|
+
}))
|
|
325
|
+
.optional()
|
|
326
|
+
.describe("Edges to insert. Written AFTER nodes in the same call, so edges may reference nodes inserted in this batch."),
|
|
327
|
+
filePath: z
|
|
328
|
+
.string()
|
|
329
|
+
.min(1)
|
|
330
|
+
.optional()
|
|
331
|
+
.describe("Path to `.csv` / `.json` / `.jsonl` on the **MCP host's filesystem** (typically the user's machine for stdio MCPs) — NOT your agent sandbox. Sandbox paths like `/mnt/user-data/uploads/...` will fail with ENOENT. Use only paths the user explicitly gave you. Format detected by extension. CSV requires `csvLabel` + companion fields. JSON/JSONL must be canonical shape (`NodeData[]` / `EdgeData[]` / `{nodes, edges}`)."),
|
|
332
|
+
csv: z
|
|
333
|
+
.string()
|
|
334
|
+
.min(1)
|
|
335
|
+
.optional()
|
|
336
|
+
.describe('Raw CSV with header row. Requires `csvLabel` (+ `csvFromColumn` / `csvToColumn` for edges). Default `,` delimiter, `"` quote. For exotic CSV, preprocess and use `nodes` / `edges`.'),
|
|
337
|
+
csvLabel: z
|
|
338
|
+
.string()
|
|
339
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
340
|
+
.optional()
|
|
341
|
+
.describe("Node label (when `csvFromColumn`/`csvToColumn` unset) or edge label (when both set). Single label only — for multi-label nodes use the `nodes` array form."),
|
|
342
|
+
csvIdColumn: z
|
|
343
|
+
.string()
|
|
344
|
+
.min(1)
|
|
345
|
+
.optional()
|
|
346
|
+
.describe("CSV column to use as `_id`. Omit to let GQLDB assign UUIDs."),
|
|
347
|
+
csvFromColumn: z
|
|
348
|
+
.string()
|
|
349
|
+
.min(1)
|
|
350
|
+
.optional()
|
|
351
|
+
.describe("Edge source `_id` column. Triggers edge mode (pair with `csvToColumn`)."),
|
|
352
|
+
csvToColumn: z
|
|
353
|
+
.string()
|
|
354
|
+
.min(1)
|
|
355
|
+
.optional()
|
|
356
|
+
.describe("Edge destination `_id` column. Triggers edge mode (pair with `csvFromColumn`)."),
|
|
357
|
+
csvProperties: z
|
|
358
|
+
.array(z.object({
|
|
359
|
+
property: z
|
|
360
|
+
.string()
|
|
361
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
362
|
+
.describe("Property name to write."),
|
|
363
|
+
column: z
|
|
364
|
+
.string()
|
|
365
|
+
.min(1)
|
|
366
|
+
.describe("CSV header column to read from."),
|
|
367
|
+
type: z
|
|
368
|
+
.string()
|
|
369
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*$/)
|
|
370
|
+
.optional()
|
|
371
|
+
.describe("Optional type coercion (`INT`, `FLOAT`, `BOOL`). Other types (`STRING`, `TIMESTAMP`, `DATE`, …) pass through as strings."),
|
|
372
|
+
}))
|
|
373
|
+
.optional()
|
|
374
|
+
.describe("Per-property mapping. Omit to default every non-`_id` / `_from` / `_to` column to a same-named STRING property."),
|
|
375
|
+
csvDelimiter: z
|
|
376
|
+
.string()
|
|
377
|
+
.length(1)
|
|
378
|
+
.optional()
|
|
379
|
+
.describe("Single-char field delimiter. Default `,`. Set to `\\t` for TSV, `;` for European CSVs, etc."),
|
|
380
|
+
csvQuote: z
|
|
381
|
+
.string()
|
|
382
|
+
.length(1)
|
|
383
|
+
.optional()
|
|
384
|
+
.describe('Single-char quote character. Default `"`.'),
|
|
385
|
+
mode: z
|
|
386
|
+
.enum(["normal", "overwrite", "upsert"])
|
|
387
|
+
.default("normal")
|
|
388
|
+
.describe("Duplicate-`_id` semantics. `normal` errors. `overwrite` replaces the whole record. `upsert` merges (preserves existing properties, updates supplied; unions labels for nodes)."),
|
|
389
|
+
skipInvalidEdges: z
|
|
390
|
+
.boolean()
|
|
391
|
+
.default(true)
|
|
392
|
+
.describe("Edges only. Skip edges whose endpoint `_id` is missing in the graph (counted in `skippedCount`). When `false`, an invalid endpoint errors the batch."),
|
|
393
|
+
}, async (args) => {
|
|
394
|
+
// ── 1. Validate import method ─────────────────────────────────────
|
|
395
|
+
const usingArrays = !!(args.nodes?.length || args.edges?.length);
|
|
396
|
+
const usingCsv = !!args.csv;
|
|
397
|
+
const usingFilePath = !!args.filePath;
|
|
398
|
+
const modesSet = [usingArrays, usingCsv, usingFilePath].filter(Boolean).length;
|
|
399
|
+
if (modesSet === 0) {
|
|
400
|
+
throw new Error("import_data needs one of: `nodes`/`edges` (canonical arrays), `csv` (raw CSV content), or `filePath` (host path to a .csv, .json, or .jsonl file).");
|
|
401
|
+
}
|
|
402
|
+
if (modesSet > 1) {
|
|
403
|
+
throw new Error("import_data: `nodes`/`edges`, `csv`, and `filePath` are mutually exclusive — pick one import method per call.");
|
|
404
|
+
}
|
|
405
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
406
|
+
if (!graphName) {
|
|
407
|
+
throw new Error("import_data needs a graph name. Pass the `graph` arg or set ULTIPA_GRAPH.");
|
|
408
|
+
}
|
|
409
|
+
// ── 2. Compute final nodes / edges arrays ─────────────────────────
|
|
410
|
+
let nodes;
|
|
411
|
+
let edges;
|
|
412
|
+
let parsedFrom;
|
|
413
|
+
if (usingArrays) {
|
|
414
|
+
nodes = args.nodes;
|
|
415
|
+
edges = args.edges;
|
|
416
|
+
}
|
|
417
|
+
else if (usingFilePath) {
|
|
418
|
+
const loaded = await loadFilePath(args.filePath, {
|
|
419
|
+
label: args.csvLabel,
|
|
420
|
+
idColumn: args.csvIdColumn,
|
|
421
|
+
fromColumn: args.csvFromColumn,
|
|
422
|
+
toColumn: args.csvToColumn,
|
|
423
|
+
properties: args.csvProperties,
|
|
424
|
+
delimiter: args.csvDelimiter,
|
|
425
|
+
quote: args.csvQuote,
|
|
426
|
+
});
|
|
427
|
+
nodes = loaded.nodes;
|
|
428
|
+
edges = loaded.edges;
|
|
429
|
+
parsedFrom = {
|
|
430
|
+
source: `filePath=${args.filePath}`,
|
|
431
|
+
format: loaded.format,
|
|
432
|
+
rows: loaded.rowCount,
|
|
433
|
+
label: args.csvLabel,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
// Inline CSV content
|
|
438
|
+
if (!args.csvLabel) {
|
|
439
|
+
throw new Error("import_data CSV mode requires `csvLabel` (the node or edge label).");
|
|
440
|
+
}
|
|
441
|
+
const { nodes: csvNodes, edges: csvEdges, rowCount, } = csvToCanonical(args.csv, {
|
|
442
|
+
label: args.csvLabel,
|
|
443
|
+
idColumn: args.csvIdColumn,
|
|
444
|
+
fromColumn: args.csvFromColumn,
|
|
445
|
+
toColumn: args.csvToColumn,
|
|
446
|
+
properties: args.csvProperties,
|
|
447
|
+
delimiter: args.csvDelimiter,
|
|
448
|
+
quote: args.csvQuote,
|
|
449
|
+
});
|
|
450
|
+
nodes = csvNodes;
|
|
451
|
+
edges = csvEdges;
|
|
452
|
+
parsedFrom = {
|
|
453
|
+
source: "csv (inline content)",
|
|
454
|
+
format: "csv",
|
|
455
|
+
rows: rowCount,
|
|
456
|
+
label: args.csvLabel,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// ── 3. Open a bulk-import session ─────────────────────────────────
|
|
460
|
+
// The driver's insertNodes/insertEdges (bulk gRPC path) require an
|
|
461
|
+
// active bulk-import session on the server side. Without
|
|
462
|
+
// `bulkImportSessionId`, the request fails with "insert without an
|
|
463
|
+
// active bulk-import session". The session is per-import: open before
|
|
464
|
+
// any insert, end after all inserts succeed, abort on failure.
|
|
465
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
466
|
+
const client = await getDataPlaneClient(target);
|
|
467
|
+
const insertType = args.mode === "overwrite"
|
|
468
|
+
? InsertType.Overwrite
|
|
469
|
+
: args.mode === "upsert"
|
|
470
|
+
? InsertType.Upsert
|
|
471
|
+
: InsertType.Normal;
|
|
472
|
+
const session = await client.startBulkImport(graphName, {
|
|
473
|
+
estimatedNodes: nodes?.length,
|
|
474
|
+
estimatedEdges: edges?.length,
|
|
475
|
+
});
|
|
476
|
+
if (!session.success) {
|
|
477
|
+
throw new Error(`Failed to start bulk-import session on graph "${graphName}": ${session.message}`);
|
|
478
|
+
}
|
|
479
|
+
// ── 4. Insert under the session, then end (or abort on error) ────
|
|
480
|
+
const out = {
|
|
481
|
+
graph: graphName,
|
|
482
|
+
mode: args.mode,
|
|
483
|
+
sessionId: session.sessionId,
|
|
484
|
+
};
|
|
485
|
+
if (parsedFrom)
|
|
486
|
+
out.parsedFrom = parsedFrom;
|
|
487
|
+
try {
|
|
488
|
+
if (nodes?.length) {
|
|
489
|
+
out.nodes = await client.insertNodesBatchAuto(graphName, nodes, {
|
|
490
|
+
options: { mode: insertType },
|
|
491
|
+
bulkImportSessionId: session.sessionId,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (edges?.length) {
|
|
495
|
+
out.edges = await client.insertEdgesBatchAuto(graphName, edges, {
|
|
496
|
+
options: {
|
|
497
|
+
mode: insertType,
|
|
498
|
+
skipInvalidNodes: args.skipInvalidEdges,
|
|
499
|
+
},
|
|
500
|
+
bulkImportSessionId: session.sessionId,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const ended = await client.endBulkImport(session.sessionId);
|
|
504
|
+
out.endResult = {
|
|
505
|
+
totalRecords: ended.totalRecords,
|
|
506
|
+
message: ended.message,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
await client.abortBulkImport(session.sessionId).catch(() => { });
|
|
511
|
+
throw new Error(`Bulk-import session ${session.sessionId} aborted: ${e?.message ?? String(e)}`);
|
|
512
|
+
}
|
|
513
|
+
return json(out);
|
|
514
|
+
});
|
|
515
|
+
server.tool("write_procedure", "Create a stored procedure in GQLDB. **CRITICAL**: the procedure body is **NOT GQL** — it has its own grammar (control flow, expressions, iterators, traversal, parallel execution, built-in functions). Do NOT compose the body from GQL knowledge alone — the model's training data does not cover Ultipa's procedure body language. **Always `lookup_docs` BEFORE composing**: start with `lookup_docs('stored-procedures/procedure-body/procedure-body-language')` for the overall grammar, then per-topic pages as needed: `procedure-body/control-flow` (if/while/for), `procedure-body/expressions`, `procedure-body/iterators-and-traversal`, `procedure-body/data-operations`, `procedure-body/parallel-execution`, `procedure-body/builtin-functions`. For the outer `CREATE PROCEDURE` envelope and parameter syntax, see `lookup_docs('stored-procedures/procedure-management')`. To CALL the procedure later, use `run_gql_query` with `CALL <name>(...)`. To MANAGE (drop / show / alter) procedures, use `run_gql_query` directly.", {
|
|
516
|
+
...idArg,
|
|
517
|
+
gql: z
|
|
518
|
+
.string()
|
|
519
|
+
.min(1)
|
|
520
|
+
.describe("The full `CREATE PROCEDURE <name>(<params>) ...` statement including the body. Compose from `lookup_docs` of the procedure body language reference."),
|
|
521
|
+
...graphArg,
|
|
522
|
+
}, async (args) => {
|
|
523
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
524
|
+
const client = await getDataPlaneClient(target);
|
|
525
|
+
const cfg = {};
|
|
526
|
+
const graphName = args.graph ?? DEFAULT_GRAPH;
|
|
527
|
+
if (graphName)
|
|
528
|
+
cfg.graphName = graphName;
|
|
529
|
+
const response = await client.gql(args.gql, cfg);
|
|
530
|
+
return json(serializeResponse(response));
|
|
531
|
+
});
|
|
532
|
+
server.tool("get_db_version", "Return the live GQLDB version reported by the instance itself. Use this when you want ground truth — the Cloud control plane's `get_instance.version` field is what Ultipa Cloud *believes* the instance runs (from metadata), which can briefly diverge during/after an upgrade. Direct-instance users can only get the version this way.", { ...idArg }, async (args) => {
|
|
533
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
534
|
+
const client = await getDataPlaneClient(target);
|
|
535
|
+
const response = await client.gql("RETURN db.version()");
|
|
536
|
+
// db.version() returns a single-row, single-column scalar — surface it cleanly.
|
|
537
|
+
let version;
|
|
538
|
+
try {
|
|
539
|
+
version = response.singleString?.();
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
/* fall back to serialized response below */
|
|
543
|
+
}
|
|
544
|
+
return json(version !== undefined ? { version } : serializeResponse(response));
|
|
545
|
+
});
|
|
546
|
+
server.tool("get_db_license", "Return the instance's edition and license info. Useful for confirming the running edition (Community / Enterprise / etc.), license expiry, and any feature flags tied to the license.", { ...idArg }, async (args) => {
|
|
547
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
548
|
+
const client = await getDataPlaneClient(target);
|
|
549
|
+
const response = await client.gql("RETURN db.license()");
|
|
550
|
+
return json(serializeResponse(response));
|
|
551
|
+
});
|
|
552
|
+
server.tool("reload_db_stats", "Rebuild the instance's stored statistics. Use when the stats look stale or wrong (e.g. after a bulk import, or if `describe_schema`'s `stats` field looks off). Side effect: can be heavy on large datasets — avoid calling mid-traffic on a busy production instance unless you have to.", { ...idArg }, async (args) => {
|
|
553
|
+
const target = resolveDataPlaneTarget(args.id);
|
|
554
|
+
const client = await getDataPlaneClient(target);
|
|
555
|
+
const response = await client.gql("RETURN db.reload_stats()");
|
|
556
|
+
return json(serializeResponse(response));
|
|
557
|
+
});
|
|
558
|
+
}
|