@datanimbus/dnio-mcp 1.0.0
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/Dockerfile +20 -0
- package/docs/README.md +35 -0
- package/docs/architecture.md +171 -0
- package/docs/authentication.md +74 -0
- package/docs/tools/apps.md +59 -0
- package/docs/tools/connectors.md +76 -0
- package/docs/tools/data-pipes.md +286 -0
- package/docs/tools/data-services.md +105 -0
- package/docs/tools/deployment-groups.md +152 -0
- package/docs/tools/plugins.md +94 -0
- package/docs/tools/records.md +97 -0
- package/docs/workflows.md +195 -0
- package/env.example +16 -0
- package/package.json +43 -0
- package/readme.md +144 -0
- package/src/clients/api-keys.js +10 -0
- package/src/clients/apps.js +13 -0
- package/src/clients/base-client.js +78 -0
- package/src/clients/bots.js +10 -0
- package/src/clients/connectors.js +30 -0
- package/src/clients/data-formats.js +40 -0
- package/src/clients/data-pipes.js +33 -0
- package/src/clients/deployment-groups.js +59 -0
- package/src/clients/formulas.js +10 -0
- package/src/clients/functions.js +10 -0
- package/src/clients/plugins.js +39 -0
- package/src/clients/records.js +51 -0
- package/src/clients/services.js +63 -0
- package/src/clients/user-groups.js +10 -0
- package/src/clients/users.js +10 -0
- package/src/examples/ai-sdk-client.js +165 -0
- package/src/examples/claude_desktop_config.json +34 -0
- package/src/examples/express-integration.js +181 -0
- package/src/index.js +283 -0
- package/src/schemas/schema-converter.js +179 -0
- package/src/services/auth-manager.js +277 -0
- package/src/services/dnio-client.js +40 -0
- package/src/services/service-registry.js +150 -0
- package/src/services/session-manager.js +161 -0
- package/src/stdio-bridge.js +185 -0
- package/src/tools/_helpers.js +32 -0
- package/src/tools/api-keys.js +5 -0
- package/src/tools/apps.js +185 -0
- package/src/tools/bots.js +5 -0
- package/src/tools/connectors.js +165 -0
- package/src/tools/data-formats.js +806 -0
- package/src/tools/data-pipes.js +1305 -0
- package/src/tools/data-service-registry.js +500 -0
- package/src/tools/deployment-groups.js +511 -0
- package/src/tools/formulas.js +5 -0
- package/src/tools/functions.js +5 -0
- package/src/tools/mcp-tools-registry.js +38 -0
- package/src/tools/plugins.js +250 -0
- package/src/tools/records.js +217 -0
- package/src/tools/services.js +476 -0
- package/src/tools/user-groups.js +5 -0
- package/src/tools/users.js +5 -0
- package/src/utils/constants.js +135 -0
- package/src/utils/logger.js +63 -0
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {z} = require('zod');
|
|
4
|
+
const {toolError} = require('./_helpers');
|
|
5
|
+
|
|
6
|
+
// ─── Domain helpers ─────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const TRIGGER_PLUGIN_SELECT = '-code';
|
|
9
|
+
const PROCESS_PLUGIN_SELECT = '-code';
|
|
10
|
+
|
|
11
|
+
// Reused in every tool that lets the LLM author a CODEBLOCK, a mapping expression,
|
|
12
|
+
// or a branch condition. These globals are provided by the DNIO runtime — keep this
|
|
13
|
+
// description in lockstep with what the platform actually exposes.
|
|
14
|
+
const EXPRESSION_GLOBALS = `
|
|
15
|
+
|
|
16
|
+
RUNTIME GLOBALS (available in CODEBLOCK code, mapping 'expression.value', and branch onSuccess.condition):
|
|
17
|
+
• Every node in the flow is reachable by its _id. Outputs always live under '.data'.
|
|
18
|
+
{{fetch_transactions['data']}} — full output of node 'fetch_transactions'
|
|
19
|
+
{{route['data']['outputFormat']['_id']}} — nested path inside another node's output
|
|
20
|
+
node['parse_body'].data.records.length — same access from inside CODEBLOCK code
|
|
21
|
+
Top-level bare variables also work in {{ }} expressions: {{<nodeId>['data']...}}.
|
|
22
|
+
• CONSTANTS — flow-level constants configured on the flow document (e.g. {{CONSTANTS.TOKEN}}).
|
|
23
|
+
• ENV — deployment environment variables (e.g. {{ENV.SOME_VAR}}).
|
|
24
|
+
|
|
25
|
+
Templating uses lodash-style {{ }} interpolation. Examples that are valid wherever expressions are accepted:
|
|
26
|
+
• {{CONSTANTS.TOKEN}}
|
|
27
|
+
• {{fetch_transactions['data']}}
|
|
28
|
+
• JSON.stringify({"fileFormatCode._id": {{route['data']['outputFormat']['_id']}}})
|
|
29
|
+
• _.isNull({{prev.data.content}}) — branch condition
|
|
30
|
+
• {{fetch_transactions['data']['status']}} == "SUCCESS" — branch condition with equality
|
|
31
|
+
|
|
32
|
+
CODEBLOCK RETURN CONTRACT:
|
|
33
|
+
V1_CODEBLOCK MUST return { data: <whatever you want downstream> }. Returning a bare object, undefined, or anything not wrapped in .data loses the output — the platform reads outputSchema.data only.`;
|
|
34
|
+
|
|
35
|
+
const PLUGIN_SUMMARY_KEYS = [
|
|
36
|
+
'_id', 'nodeId', 'type', 'label', 'group', 'category',
|
|
37
|
+
'icon', 'connectorType', 'inputSchema', 'outputSchema', 'errorSchema', 'version'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function pickPluginSummary(p) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of PLUGIN_SUMMARY_KEYS) if (k in p) out[k] = p[k];
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slugify(name) {
|
|
47
|
+
return String(name)
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
50
|
+
.replace(/^_|_$/g, '') || 'node';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dedupeId(slug, existingIds) {
|
|
54
|
+
if (!existingIds.has(slug)) return slug;
|
|
55
|
+
let n = 2;
|
|
56
|
+
while (existingIds.has(`${slug}_${n}`)) n++;
|
|
57
|
+
return `${slug}_${n}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function collectFlowIds(flow) {
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
if (flow.inputNode?._id) ids.add(flow.inputNode._id);
|
|
63
|
+
for (const n of flow.nodes || []) if (n._id) ids.add(n._id);
|
|
64
|
+
return ids;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findNodeRef(flow, nodeId) {
|
|
68
|
+
if (flow.inputNode?._id === nodeId) return {node: flow.inputNode, isInput: true};
|
|
69
|
+
const idx = (flow.nodes || []).findIndex(n => n._id === nodeId);
|
|
70
|
+
if (idx >= 0) return {node: flow.nodes[idx], isInput: false, idx};
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function maxX(flow) {
|
|
75
|
+
let m = flow.inputNode?.coordinates?.x ?? 0;
|
|
76
|
+
for (const n of flow.nodes || []) {
|
|
77
|
+
const x = n.coordinates?.x;
|
|
78
|
+
if (typeof x === 'number' && x > m) m = x;
|
|
79
|
+
}
|
|
80
|
+
return m;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultNodeOptions(plugin) {
|
|
84
|
+
if (plugin?.type === 'V1_CODEBLOCK') {
|
|
85
|
+
// CODEBLOCK has its own (smaller) options shape — no nodeId, no values, no retry.
|
|
86
|
+
return {
|
|
87
|
+
rejectUnauthorized: true,
|
|
88
|
+
conditionType: 'ifElse',
|
|
89
|
+
contentType: 'application/json',
|
|
90
|
+
code: DEFAULT_CODEBLOCK_CODE
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
nodeId: plugin.nodeId,
|
|
95
|
+
values: {},
|
|
96
|
+
rejectUnauthorized: true,
|
|
97
|
+
retry: {},
|
|
98
|
+
conditionType: 'ifElse',
|
|
99
|
+
uniqueRemoteTransactionOptions: {},
|
|
100
|
+
contentType: 'application/json'
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build a process-node entry from a plugin object. Caller wires _id, onSuccess, mappings.
|
|
105
|
+
function buildProcessNode(plugin, name, {options, mappings, connector, coordinates, app}) {
|
|
106
|
+
const node = {
|
|
107
|
+
_id: name,
|
|
108
|
+
name,
|
|
109
|
+
onSuccess: [],
|
|
110
|
+
onError: [],
|
|
111
|
+
dataStructure: {outgoing: {}},
|
|
112
|
+
options: {...defaultNodeOptions(plugin), ...(options || {})},
|
|
113
|
+
app,
|
|
114
|
+
type: plugin.type,
|
|
115
|
+
category: plugin.category,
|
|
116
|
+
group: plugin.group,
|
|
117
|
+
icon: plugin.icon,
|
|
118
|
+
label: plugin.label,
|
|
119
|
+
version: plugin.version,
|
|
120
|
+
inputSchema: plugin.inputSchema || [],
|
|
121
|
+
outputSchema: plugin.outputSchema || [],
|
|
122
|
+
errorSchema: plugin.errorSchema || [],
|
|
123
|
+
connectorType: plugin.connectorType || 'NONE',
|
|
124
|
+
coordinates: coordinates || {x: 0, y: 0},
|
|
125
|
+
nodeType: 'customNewNode'
|
|
126
|
+
};
|
|
127
|
+
if (Array.isArray(mappings) && mappings.length > 0) node.mappings = mappings;
|
|
128
|
+
if (connector?._id) node.options.connector = {_id: connector._id};
|
|
129
|
+
return node;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build a trigger inputNode from a plugin object. Caller wires _id and onSuccess.
|
|
133
|
+
function buildInputNode(plugin, options) {
|
|
134
|
+
return {
|
|
135
|
+
type: plugin.type,
|
|
136
|
+
options: {...(options || {}), nodeId: plugin.nodeId},
|
|
137
|
+
_id: slugify(plugin.label || plugin.type),
|
|
138
|
+
name: slugify(plugin.label || plugin.type),
|
|
139
|
+
icon: plugin.icon,
|
|
140
|
+
inputSchema: plugin.inputSchema || [],
|
|
141
|
+
outputSchema: plugin.outputSchema || [],
|
|
142
|
+
errorSchema: plugin.errorSchema || [],
|
|
143
|
+
connectorType: plugin.connectorType || 'NONE',
|
|
144
|
+
onSuccess: []
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateConnectorRequirement(plugin, connector) {
|
|
149
|
+
if ((plugin.connectorType || 'NONE') !== 'NONE' && !connector?._id) {
|
|
150
|
+
throw new Error(`Plugin '${plugin.label || plugin.type}' requires a connector (connectorType=${plugin.connectorType}) — pass connector: { _id: '<connectorId>' }.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// V1_CODEBLOCK is a platform built-in — it does NOT live in /my-node, can't be
|
|
155
|
+
// installed from the marketplace, and has no nodeId. Use this baseline whenever
|
|
156
|
+
// the LLM asks for V1_CODEBLOCK.
|
|
157
|
+
const BUILTIN_CODEBLOCK = {
|
|
158
|
+
type: 'V1_CODEBLOCK',
|
|
159
|
+
category: 'PROCESS',
|
|
160
|
+
group: 'Misc',
|
|
161
|
+
icon: 'ni ni-console',
|
|
162
|
+
label: 'Code Block',
|
|
163
|
+
version: 1,
|
|
164
|
+
inputSchema: [{key: 'data', type: 'Schema'}],
|
|
165
|
+
outputSchema: [{key: 'data', type: 'Schema'}],
|
|
166
|
+
errorSchema: [
|
|
167
|
+
{key: 'code', type: 'Number'},
|
|
168
|
+
{key: 'message', type: 'String'},
|
|
169
|
+
{key: 'stackTrace', type: 'String'}
|
|
170
|
+
],
|
|
171
|
+
connectorType: 'NONE'
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const DEFAULT_CODEBLOCK_CODE = `//use logger for logging
|
|
175
|
+
async function executeCode(inputData, node, connectorConfig) {
|
|
176
|
+
try {
|
|
177
|
+
let data = {};
|
|
178
|
+
//Write Your code here
|
|
179
|
+
|
|
180
|
+
return data;
|
|
181
|
+
} catch(err) {
|
|
182
|
+
logger.error(err);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}`;
|
|
186
|
+
|
|
187
|
+
function _isCodeblock(plugin) {
|
|
188
|
+
return plugin?.type === 'V1_CODEBLOCK' || plugin?._id === 'V1_CODEBLOCK';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build the PUBLIC invocation URL for an HTTP-triggered flow.
|
|
192
|
+
// Pattern: {DNIO_BASE_URL}/b2b/pipes/{appName}/{path}
|
|
193
|
+
// IMPORTANT: this is the only URL the LLM should give the user. The flow document
|
|
194
|
+
// itself contains internal Kubernetes references (e.g. 'gateway.<ns>.svc/api/b2b/...',
|
|
195
|
+
// 'host', 'port', 'deploymentName', 'yaml', or any 'url' field on the flow). Those
|
|
196
|
+
// resolve only inside the cluster and are NOT publicly callable.
|
|
197
|
+
// Returns null for non-HTTP triggers (Timer, Timer Multiple) where there is no URL.
|
|
198
|
+
function _flowInvocationUrl(dnioClient, appName, flow) {
|
|
199
|
+
const inputNode = flow?.inputNode;
|
|
200
|
+
if (!inputNode || inputNode.type !== 'V1_HTTP_SERVER') return null;
|
|
201
|
+
const rawPath = inputNode.options?.path;
|
|
202
|
+
if (!rawPath) return null;
|
|
203
|
+
const base = (dnioClient.baseUrl || '').replace(/\/$/, '');
|
|
204
|
+
const cleanPath = String(rawPath).replace(/^\/+/, '');
|
|
205
|
+
return {
|
|
206
|
+
method: inputNode.options?.method || 'POST',
|
|
207
|
+
url: `${base}/b2b/pipes/${appName}/${cleanPath}`,
|
|
208
|
+
warning: "Use this URL for HTTP calls. IGNORE any other url / host / gateway / deploymentName / yaml fields elsewhere in the flow document — those are internal Kubernetes service references (e.g. 'gateway.<namespace>.svc/api/...') and are NOT reachable from outside the cluster."
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Plugin objects shuttled through MCP tool calls often arrive missing inputSchema /
|
|
213
|
+
// outputSchema / errorSchema / nodeId — the LLM trims them. Re-fetch from /my-node
|
|
214
|
+
// whenever any of those critical fields is missing so the persisted node always
|
|
215
|
+
// carries the canonical schemas. Idempotent: returns the input as-is when complete.
|
|
216
|
+
async function _resolvePlugin(dnioClient, appName, plugin) {
|
|
217
|
+
// V1_CODEBLOCK is built-in — short-circuit; never look it up in /my-node.
|
|
218
|
+
if (_isCodeblock(plugin)) return BUILTIN_CODEBLOCK;
|
|
219
|
+
|
|
220
|
+
const isComplete = (p) =>
|
|
221
|
+
p && p.type && p.nodeId &&
|
|
222
|
+
Array.isArray(p.inputSchema) &&
|
|
223
|
+
Array.isArray(p.outputSchema) &&
|
|
224
|
+
Array.isArray(p.errorSchema);
|
|
225
|
+
|
|
226
|
+
if (isComplete(plugin)) return plugin;
|
|
227
|
+
|
|
228
|
+
const filter = plugin?._id ? {_id: plugin._id}
|
|
229
|
+
: plugin?.type ? {type: plugin.type}
|
|
230
|
+
: null;
|
|
231
|
+
if (!filter) {
|
|
232
|
+
throw new Error('plugin must include either _id or type so the tool can resolve schemas from /my-node');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const result = await dnioClient.plugins.listInstalled(appName, {
|
|
236
|
+
filter,
|
|
237
|
+
select: '_id,app,nodeId,version,category,group,type,label,icon,connectorType,inputSchema,outputSchema,errorSchema'
|
|
238
|
+
});
|
|
239
|
+
const list = Array.isArray(result) ? result : [];
|
|
240
|
+
if (list.length === 0) {
|
|
241
|
+
const ref = plugin?._id || plugin?.type || JSON.stringify(plugin);
|
|
242
|
+
throw new Error(`Plugin not installed in app '${appName}': ${ref}. Use list_marketplace_plugins → install_plugins first.`);
|
|
243
|
+
}
|
|
244
|
+
return list[0];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Walk a DNIO schema array (each entry: {key, type, subType?, schema?}) by dot-path segments.
|
|
248
|
+
function _walkSchema(schema, segments) {
|
|
249
|
+
if (!Array.isArray(schema) || segments.length === 0) return null;
|
|
250
|
+
let cur = schema;
|
|
251
|
+
let field = null;
|
|
252
|
+
for (const seg of segments) {
|
|
253
|
+
if (!Array.isArray(cur)) return null;
|
|
254
|
+
field = cur.find(f => f.key === seg);
|
|
255
|
+
if (!field) return null;
|
|
256
|
+
cur = field.schema || [];
|
|
257
|
+
}
|
|
258
|
+
return field;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _expressionFor(nodeId, segments) {
|
|
262
|
+
const path = segments.map(s => `['${s}']`).join('');
|
|
263
|
+
return ` {{${nodeId}${path}}}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// True when the entry already has the full DNIO mapping shape (target as object + source array).
|
|
267
|
+
function _isFullMapping(m) {
|
|
268
|
+
return !!m && typeof m.target === 'object' && m.target !== null && Array.isArray(m.source);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Convert a simple {from, to, expression?} mapping to the full DNIO shape, walking
|
|
272
|
+
// the flow's existing nodes and the current node's inputSchema for type info.
|
|
273
|
+
// Full-shape entries pass through untouched.
|
|
274
|
+
function _buildMappingEntry(flow, currentInputSchema, simple) {
|
|
275
|
+
if (_isFullMapping(simple)) return simple;
|
|
276
|
+
const {from, to, expression: customExpr} = simple || {};
|
|
277
|
+
if (!to) {
|
|
278
|
+
throw new Error('mapping entry needs "to" (the target field name on the current node)');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const targetField = _walkSchema(currentInputSchema, [to]);
|
|
282
|
+
const rawTargetType = targetField?.type;
|
|
283
|
+
// Schema / SchemaFree / unknown → Object (matches platform's canonical target.type).
|
|
284
|
+
const targetType = (!rawTargetType || rawTargetType === 'Schema' || rawTargetType === 'SchemaFree')
|
|
285
|
+
? 'Object'
|
|
286
|
+
: rawTargetType;
|
|
287
|
+
|
|
288
|
+
const target = {_id: to, type: targetType, dataPath: to, dataPathSegs: [to]};
|
|
289
|
+
|
|
290
|
+
// Hardcoded expression with no source (e.g. {{CONSTANTS.TOKEN}}).
|
|
291
|
+
if (!from && customExpr) {
|
|
292
|
+
return {
|
|
293
|
+
target,
|
|
294
|
+
source: [],
|
|
295
|
+
expression: {type: 'simple', value: customExpr},
|
|
296
|
+
children: [],
|
|
297
|
+
key: to,
|
|
298
|
+
name: to,
|
|
299
|
+
derivedFrom: null
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (!from) {
|
|
303
|
+
throw new Error(`mapping for "${to}" needs either "from" (e.g. "fetch_students.data") or "expression"`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const [sourceNodeId, ...path] = String(from).split('.');
|
|
307
|
+
if (path.length === 0) {
|
|
308
|
+
throw new Error(`mapping "from" must be "<nodeId>.<field>[.<nested>...]" — got "${from}"`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sourceRef = findNodeRef(flow, sourceNodeId);
|
|
312
|
+
if (!sourceRef) {
|
|
313
|
+
throw new Error(`mapping source node "${sourceNodeId}" not found in flow (check inputNode._id and nodes[]._id)`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const sourceField = _walkSchema(sourceRef.node.outputSchema, path);
|
|
317
|
+
const sourceType = sourceField?.type || 'Schema';
|
|
318
|
+
const sourceSubType = sourceField?.subType;
|
|
319
|
+
const lastSeg = path[path.length - 1];
|
|
320
|
+
|
|
321
|
+
const sourceEntry = {
|
|
322
|
+
key: lastSeg,
|
|
323
|
+
type: sourceType,
|
|
324
|
+
nodeId: sourceNodeId,
|
|
325
|
+
name: lastSeg,
|
|
326
|
+
dataPath: path.join('.'),
|
|
327
|
+
dataPathSegs: path,
|
|
328
|
+
_id: `${sourceNodeId}.${path.join('.')}`
|
|
329
|
+
};
|
|
330
|
+
if (sourceSubType) sourceEntry.subType = sourceSubType;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
target,
|
|
334
|
+
source: [sourceEntry],
|
|
335
|
+
expression: {
|
|
336
|
+
type: 'simple',
|
|
337
|
+
value: customExpr || _expressionFor(sourceNodeId, path)
|
|
338
|
+
},
|
|
339
|
+
children: [],
|
|
340
|
+
key: to,
|
|
341
|
+
name: to,
|
|
342
|
+
derivedFrom: null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function _buildMappings(flow, currentInputSchema, mappings) {
|
|
347
|
+
if (!Array.isArray(mappings)) return undefined;
|
|
348
|
+
return mappings.map(m => _buildMappingEntry(flow, currentInputSchema, m));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Auto-derive simple mappings by matching field keys between the upstream node's
|
|
352
|
+
// outputSchema and the current node's inputSchema. Only top-level keys are matched —
|
|
353
|
+
// nested fields and renames must be specified explicitly.
|
|
354
|
+
function _autoMappings(flow, sourceNodeId, currentInputSchema) {
|
|
355
|
+
const sourceRef = findNodeRef(flow, sourceNodeId);
|
|
356
|
+
if (!sourceRef) return [];
|
|
357
|
+
const sourceOutputs = sourceRef.node.outputSchema || [];
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const targetField of (currentInputSchema || [])) {
|
|
360
|
+
if (!targetField?.key) continue;
|
|
361
|
+
const match = sourceOutputs.find(s => s?.key === targetField.key);
|
|
362
|
+
if (match) {
|
|
363
|
+
out.push({from: `${sourceNodeId}.${match.key}`, to: targetField.key});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Return the list of inputSchema field keys that aren't covered by the given mappings.
|
|
370
|
+
// Used to nudge the LLM to ask the user (or pass explicit mappings) for the remainder.
|
|
371
|
+
function _unmappedFields(currentInputSchema, mappings) {
|
|
372
|
+
const mapped = new Set();
|
|
373
|
+
for (const m of (mappings || [])) {
|
|
374
|
+
const k = m?.target?._id || m?.target?.dataPath || m?.to || m?.key;
|
|
375
|
+
if (k) mapped.add(k);
|
|
376
|
+
}
|
|
377
|
+
return (currentInputSchema || [])
|
|
378
|
+
.filter(f => f?.key && !mapped.has(f.key))
|
|
379
|
+
.map(f => ({key: f.key, type: f.type, subType: f.subType}));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Helper used by add_node_to_flow + create_flow.initialNodes[] to decide mappings.
|
|
383
|
+
// Returns {mappings, autoMapped: boolean}.
|
|
384
|
+
// - If userMappings is undefined → auto-derive from the upstream node by key-match.
|
|
385
|
+
// - If userMappings === [] → explicit "no mappings" (returns undefined).
|
|
386
|
+
// - Otherwise → use userMappings as-is (still goes through _buildMappings
|
|
387
|
+
// so simple {from,to} entries get expanded).
|
|
388
|
+
function _resolveMappings(flow, sourceNodeId, currentInputSchema, userMappings) {
|
|
389
|
+
if (Array.isArray(userMappings)) {
|
|
390
|
+
if (userMappings.length === 0) return {mappings: undefined, autoMapped: false};
|
|
391
|
+
return {mappings: _buildMappings(flow, currentInputSchema, userMappings), autoMapped: false};
|
|
392
|
+
}
|
|
393
|
+
const auto = _autoMappings(flow, sourceNodeId, currentInputSchema);
|
|
394
|
+
if (auto.length === 0) return {mappings: undefined, autoMapped: false};
|
|
395
|
+
return {mappings: _buildMappings(flow, currentInputSchema, auto), autoMapped: true};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Schema overlay (refFormatType / refFormatId / refData) ─────────────────
|
|
399
|
+
// A node's inputSchema or outputSchema field can be pinned to a specific data
|
|
400
|
+
// format (currently: a data service) so the platform UI shows typed fields
|
|
401
|
+
// instead of the plugin's generic 'Schema'. The overlay lives on a single
|
|
402
|
+
// field by `key`; other fields keep the plugin's default shape.
|
|
403
|
+
|
|
404
|
+
async function _fetchRefData(dnioClient, appName, refFormatType, refFormatId) {
|
|
405
|
+
if (refFormatType === 'service') {
|
|
406
|
+
const svc = await dnioClient.services.get(appName, refFormatId, {
|
|
407
|
+
select: '_id,name,definition,attributeCount'
|
|
408
|
+
});
|
|
409
|
+
if (!svc || !svc._id) {
|
|
410
|
+
throw new Error(`Data service '${refFormatId}' not found in app '${appName}'`);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
_id: svc._id,
|
|
414
|
+
name: svc.name,
|
|
415
|
+
definition: svc.definition || [],
|
|
416
|
+
attributeCount: svc.attributeCount ?? (Array.isArray(svc.definition) ? svc.definition.length : 0),
|
|
417
|
+
type: 'Object',
|
|
418
|
+
formatType: 'JSON',
|
|
419
|
+
_selected: true,
|
|
420
|
+
dataType: 'Object'
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`Unsupported refFormatType '${refFormatType}'. Currently supported: 'service'.`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Apply per-field schema overrides to a plugin schema array, returning a new array.
|
|
427
|
+
// Each override: { key, refFormatType, refFormatId } — the matching field's
|
|
428
|
+
// existing properties are preserved; refFormatType/refFormatId/refData are merged in.
|
|
429
|
+
async function _applySchemaOverrides(dnioClient, appName, schema, overrides) {
|
|
430
|
+
if (!Array.isArray(overrides) || overrides.length === 0) return schema;
|
|
431
|
+
const result = (schema || []).map(f => ({...f}));
|
|
432
|
+
for (const o of overrides) {
|
|
433
|
+
if (!o?.key) throw new Error('schemaOverrides entry missing "key"');
|
|
434
|
+
if (!o.refFormatType) throw new Error(`schemaOverrides["${o.key}"] missing "refFormatType"`);
|
|
435
|
+
if (!o.refFormatId) throw new Error(`schemaOverrides["${o.key}"] missing "refFormatId"`);
|
|
436
|
+
const idx = result.findIndex(f => f.key === o.key);
|
|
437
|
+
if (idx < 0) {
|
|
438
|
+
const present = result.map(f => f.key).join(', ') || '(empty)';
|
|
439
|
+
throw new Error(`schemaOverrides references unknown field "${o.key}" — plugin schema has: ${present}`);
|
|
440
|
+
}
|
|
441
|
+
const refData = await _fetchRefData(dnioClient, appName, o.refFormatType, o.refFormatId);
|
|
442
|
+
result[idx] = {
|
|
443
|
+
...result[idx],
|
|
444
|
+
refFormatType: o.refFormatType,
|
|
445
|
+
refFormatId: o.refFormatId,
|
|
446
|
+
refData
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Apply both inputSchema and outputSchema overrides onto a plugin (mutates a clone).
|
|
453
|
+
async function _applyAllSchemaOverrides(dnioClient, appName, plugin, schemaOverrides) {
|
|
454
|
+
if (!schemaOverrides) return plugin;
|
|
455
|
+
const out = {...plugin};
|
|
456
|
+
if (schemaOverrides.inputSchema) {
|
|
457
|
+
out.inputSchema = await _applySchemaOverrides(dnioClient, appName, plugin.inputSchema, schemaOverrides.inputSchema);
|
|
458
|
+
}
|
|
459
|
+
if (schemaOverrides.outputSchema) {
|
|
460
|
+
out.outputSchema = await _applySchemaOverrides(dnioClient, appName, plugin.outputSchema, schemaOverrides.outputSchema);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Tool registrations ─────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
module.exports = function registerDataPipesTools({server, dnioClient, registry, userContext}) {
|
|
468
|
+
const requireApp = () => {
|
|
469
|
+
if (!registry.selectedApp) {
|
|
470
|
+
return {content: [{type: 'text', text: 'No app selected. Use list_apps → select_app first.'}], isError: true};
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// ─── Plugin discovery ───────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
server.registerTool(
|
|
478
|
+
'list_trigger_plugins',
|
|
479
|
+
{
|
|
480
|
+
title: 'List Trigger Plugins (Flow Starters)',
|
|
481
|
+
description: `List installed plugins with category=TRIGGER for the selected app. These are the starter nodes for a new flow (HTTP server, Timer, Timer Multiple, etc.).
|
|
482
|
+
|
|
483
|
+
Call this BEFORE create_flow — pass the chosen plugin object as 'triggerPlugin'.`,
|
|
484
|
+
inputSchema: {},
|
|
485
|
+
annotations: {readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false}
|
|
486
|
+
},
|
|
487
|
+
async () => {
|
|
488
|
+
const guard = requireApp();
|
|
489
|
+
if (guard) return guard;
|
|
490
|
+
try {
|
|
491
|
+
dnioClient.setToken(userContext.token);
|
|
492
|
+
const result = await dnioClient.plugins.listInstalled(registry.selectedApp, {
|
|
493
|
+
filter: {category: 'TRIGGER'},
|
|
494
|
+
select: TRIGGER_PLUGIN_SELECT
|
|
495
|
+
});
|
|
496
|
+
const items = (Array.isArray(result) ? result : []).map(pickPluginSummary);
|
|
497
|
+
return {
|
|
498
|
+
content: [{
|
|
499
|
+
type: 'text',
|
|
500
|
+
text: JSON.stringify({
|
|
501
|
+
app: registry.selectedApp,
|
|
502
|
+
count: items.length,
|
|
503
|
+
plugins: items,
|
|
504
|
+
usage: 'Pass the chosen plugin object as triggerPlugin to create_flow.'
|
|
505
|
+
}, null, 2)
|
|
506
|
+
}]
|
|
507
|
+
};
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return toolError('Failed to list trigger plugins', error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
server.registerTool(
|
|
515
|
+
'list_process_plugins',
|
|
516
|
+
{
|
|
517
|
+
title: 'List Process Plugins (Downstream Nodes)',
|
|
518
|
+
description: `List installed plugins with category=PROCESS for the selected app. These are the downstream nodes used after the trigger (parsing, data service calls, file ops, code blocks, V1_RESPONSE, etc.).
|
|
519
|
+
|
|
520
|
+
Call this BEFORE add_node_to_flow — pass the chosen plugin object as 'pluginObject'.
|
|
521
|
+
|
|
522
|
+
PLUGIN SELECTION STRATEGY (when building a flow from a user requirement):
|
|
523
|
+
1. Search for an installed first-class plugin that matches the task (e.g. 'Save File' → V1_SAVE_FILE, 'Parse JSON' → V1_PARSE_JSON, a data service call → V1_DATASERVICE_*).
|
|
524
|
+
2. If nothing matches but the marketplace has it, call list_marketplace_plugins → install_plugins → re-list.
|
|
525
|
+
3. ONLY if no built-in covers the task and the logic is trivial / glue / one-off transformation, fall back to V1_CODEBLOCK with custom code in options.code (see add_node_to_flow for the code function signature).
|
|
526
|
+
Prefer first-class plugins over CODEBLOCK whenever possible — they get UI affordances, schema introspection, and version upgrades automatically.
|
|
527
|
+
|
|
528
|
+
V1_CODEBLOCK NOTE: V1_CODEBLOCK is a PLATFORM BUILT-IN — it does NOT appear in this list and cannot be installed. To use it, skip list_*_plugins / install_plugins entirely and call add_node_to_flow directly with pluginObject: { type: 'V1_CODEBLOCK' }. The tool resolves the schemas and seeds a default executeCode template; pass your real code via options.code.
|
|
529
|
+
|
|
530
|
+
V1_RESPONSE NOTE: For ANY flow triggered by V1_HTTP_Sokay,ERVER that needs to return data to the HTTP caller, the LAST node in the chain MUST be V1_RESPONSE. Unlike V1_CODEBLOCK, V1_RESPONSE is a regular marketplace plugin — search for it here, install_plugins if missing, then add it. Its inputSchema has three fields: 'data' (Buffer — the response body, normally mapped from the previous node's data), 'statusCode' (Number — typically the literal expression "200"), and 'headers' (KeyValPair — usually left unmapped). Flows without V1_RESPONSE simply don't return a body, which is fine for fire-and-forget endpoints but breaks anything the caller waits on.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
- group (optional): Filter by group, e.g. 'DataService', 'HTTP', 'Misc', 'File'.
|
|
534
|
+
- search (optional): Substring match against label or type.`,
|
|
535
|
+
inputSchema: {
|
|
536
|
+
group: z.string().optional().describe("Filter by group, e.g. 'DataService', 'HTTP', 'Misc'."),
|
|
537
|
+
search: z.string().optional().describe('Substring match against label or type.')
|
|
538
|
+
},
|
|
539
|
+
annotations: {readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false}
|
|
540
|
+
},
|
|
541
|
+
async (params) => {
|
|
542
|
+
const guard = requireApp();
|
|
543
|
+
if (guard) return guard;
|
|
544
|
+
try {
|
|
545
|
+
dnioClient.setToken(userContext.token);
|
|
546
|
+
const result = await dnioClient.plugins.listInstalled(registry.selectedApp, {
|
|
547
|
+
filter: {category: 'PROCESS'},
|
|
548
|
+
select: PROCESS_PLUGIN_SELECT
|
|
549
|
+
});
|
|
550
|
+
const installed = Array.isArray(result) ? result : [];
|
|
551
|
+
|
|
552
|
+
// Prepend platform built-ins (e.g. V1_CODEBLOCK) so the LLM's natural
|
|
553
|
+
// search/filter flow surfaces them — they don't live in /my-node.
|
|
554
|
+
const codeblockEntry = {
|
|
555
|
+
...BUILTIN_CODEBLOCK,
|
|
556
|
+
_id: 'V1_CODEBLOCK',
|
|
557
|
+
nodeId: null,
|
|
558
|
+
builtIn: true
|
|
559
|
+
};
|
|
560
|
+
let items = [codeblockEntry, ...installed];
|
|
561
|
+
|
|
562
|
+
if (params.group) {
|
|
563
|
+
items = items.filter(i => (i.group || '') === params.group);
|
|
564
|
+
}
|
|
565
|
+
if (params.search) {
|
|
566
|
+
const q = params.search.toLowerCase();
|
|
567
|
+
items = items.filter(i =>
|
|
568
|
+
(i.label || '').toLowerCase().includes(q) ||
|
|
569
|
+
(i.type || '').toLowerCase().includes(q)
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
// Slim each plugin, but preserve the built-in flag.
|
|
573
|
+
items = items.map(p => {
|
|
574
|
+
const summary = pickPluginSummary(p);
|
|
575
|
+
if (p.builtIn) summary.builtIn = true;
|
|
576
|
+
return summary;
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
content: [{
|
|
580
|
+
type: 'text',
|
|
581
|
+
text: JSON.stringify({
|
|
582
|
+
app: registry.selectedApp,
|
|
583
|
+
count: items.length,
|
|
584
|
+
plugins: items,
|
|
585
|
+
usage: "Pass the chosen plugin object as pluginObject to add_node_to_flow. For built-in plugins (builtIn: true), pass just { type: '<type>' } — they don't need to be installed and don't have a real _id.",
|
|
586
|
+
builtIns: items.filter(i => i.builtIn).map(i => i.type)
|
|
587
|
+
}, null, 2)
|
|
588
|
+
}]
|
|
589
|
+
};
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return toolError('Failed to list process plugins', error);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// ─── Flow CRUD ──────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
server.registerTool(
|
|
599
|
+
'list_flows',
|
|
600
|
+
{
|
|
601
|
+
title: 'List Flows in App',
|
|
602
|
+
description: `List flows (data pipes) in the selected app. Useful for finding a flow by name before mutating it.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
- name (optional): Substring filter on flow name.
|
|
606
|
+
- status (optional): Filter by status, e.g. 'Draft', 'Active'.`,
|
|
607
|
+
inputSchema: {
|
|
608
|
+
name: z.string().optional().describe('Substring filter on flow name'),
|
|
609
|
+
status: z.string().optional().describe("Status filter, e.g. 'Draft' or 'Active'")
|
|
610
|
+
},
|
|
611
|
+
annotations: {readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false}
|
|
612
|
+
},
|
|
613
|
+
async (params) => {
|
|
614
|
+
const guard = requireApp();
|
|
615
|
+
if (guard) return guard;
|
|
616
|
+
try {
|
|
617
|
+
dnioClient.setToken(userContext.token);
|
|
618
|
+
const filter = {};
|
|
619
|
+
if (params.status) filter.status = params.status;
|
|
620
|
+
const result = await dnioClient.dataPipes.list(registry.selectedApp, {
|
|
621
|
+
filter,
|
|
622
|
+
select: '_id,name,status,version,inputNode,description'
|
|
623
|
+
});
|
|
624
|
+
let items = Array.isArray(result) ? result : [];
|
|
625
|
+
if (params.name) {
|
|
626
|
+
const q = params.name.toLowerCase();
|
|
627
|
+
items = items.filter(f => (f.name || '').toLowerCase().includes(q));
|
|
628
|
+
}
|
|
629
|
+
const summary = items.map(f => {
|
|
630
|
+
const invocation = _flowInvocationUrl(dnioClient, registry.selectedApp, f);
|
|
631
|
+
return {
|
|
632
|
+
flowId: f._id,
|
|
633
|
+
name: f.name,
|
|
634
|
+
status: f.status,
|
|
635
|
+
version: f.version,
|
|
636
|
+
triggerType: f.inputNode?.type,
|
|
637
|
+
...(invocation ? {invocation} : {}),
|
|
638
|
+
description: f.description
|
|
639
|
+
};
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
content: [{
|
|
643
|
+
type: 'text',
|
|
644
|
+
text: JSON.stringify({app: registry.selectedApp, count: summary.length, flows: summary}, null, 2)
|
|
645
|
+
}]
|
|
646
|
+
};
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return toolError('Failed to list flows', error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
server.registerTool(
|
|
654
|
+
'get_flow',
|
|
655
|
+
{
|
|
656
|
+
title: 'Get Flow (Full Document)',
|
|
657
|
+
description: `Fetch the full flow document by flowId. Always returns the latest server state — use before any mutation if you want to inspect first. (The mutating tools all fetch internally, so calling get_flow is only needed for read/inspection.)
|
|
658
|
+
|
|
659
|
+
For HTTP-triggered flows, the response is augmented with 'invocation: { method, url, warning }' at the top level — the public URL the user should call.
|
|
660
|
+
|
|
661
|
+
⚠️ The flow document itself contains internal Kubernetes references — fields like 'url', 'host', 'port', 'namespace', 'deploymentName', or a generated 'yaml' whose URLs look like 'gateway.<namespace>.svc/api/b2b/...'. Those are cluster-internal and cannot be reached from outside the K8s network. NEVER show them to the user as the invocation URL — always use 'invocation.url'.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
- flowId (required): The flow _id (e.g. 'FLOW6156').`,
|
|
665
|
+
inputSchema: {
|
|
666
|
+
flowId: z.string().min(1).describe("Flow _id (e.g. 'FLOW6156')")
|
|
667
|
+
},
|
|
668
|
+
annotations: {readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false}
|
|
669
|
+
},
|
|
670
|
+
async (params) => {
|
|
671
|
+
const guard = requireApp();
|
|
672
|
+
if (guard) return guard;
|
|
673
|
+
try {
|
|
674
|
+
dnioClient.setToken(userContext.token);
|
|
675
|
+
const result = await dnioClient.dataPipes.get(registry.selectedApp, params.flowId);
|
|
676
|
+
const invocation = _flowInvocationUrl(dnioClient, registry.selectedApp, result);
|
|
677
|
+
const response = invocation ? {invocation, ...result} : result;
|
|
678
|
+
return {content: [{type: 'text', text: JSON.stringify(response, null, 2)}]};
|
|
679
|
+
} catch (error) {
|
|
680
|
+
return toolError(`Failed to get flow ${params.flowId}`, error);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
server.registerTool(
|
|
686
|
+
'create_flow',
|
|
687
|
+
{
|
|
688
|
+
title: 'Create Flow (with trigger + initial nodes)',
|
|
689
|
+
description: `Create a new flow with one trigger node as the inputNode and zero or more initial process nodes wired sequentially.
|
|
690
|
+
|
|
691
|
+
Workflow: list_trigger_plugins → list_process_plugins → create_flow → add_node_to_flow (more) → publish_flow.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
- name (required): Flow name.
|
|
695
|
+
- description (optional): Free-form description.
|
|
696
|
+
- skipAuth (optional, default true): For HTTP triggers, whether the flow's HTTP endpoint skips auth.
|
|
697
|
+
- triggerPlugin (required): The full plugin object from list_trigger_plugins.
|
|
698
|
+
- triggerOptions (required): Options for the trigger. Shape depends on trigger type:
|
|
699
|
+
• V1_HTTP_SERVER: { method: 'POST', path: '/upload' }
|
|
700
|
+
• V1_TIMER: { cron: '0 9 * * *', timezone: 'Asia/Kolkata', holidayList?: '...' }
|
|
701
|
+
• V1_TIMER_MULTIPLE: { cronList: [{ cron, timezone }, ...] }
|
|
702
|
+
- initialNodes (optional): Array of { pluginObject, name, options?, mappings?, connector?, coordinates? } seeded into the flow. Wired sequentially: trigger → initialNodes[0] → initialNodes[1] → ... Each initialNode entry's pluginObject must be category=PROCESS.
|
|
703
|
+
|
|
704
|
+
Returns the full flow JSON including the new flowId. For HTTP-triggered flows (V1_HTTP_SERVER), the response also includes 'invocation: { method, url, warning }' — the URL pattern is {DNIO_BASE_URL}/b2b/pipes/{app}/{httpServerPath}.
|
|
705
|
+
|
|
706
|
+
⚠️ The flow JSON itself may contain INTERNAL fields like 'url', 'host', 'deploymentName', 'namespace', 'port', or a generated 'yaml' that reference Kubernetes service hosts (e.g. 'gateway.<namespace>.svc/api/b2b/...'). Those are NOT publicly callable — they resolve only inside the K8s cluster. ONLY use 'invocation.url' from the response when telling the user how to call the flow.`,
|
|
707
|
+
inputSchema: {
|
|
708
|
+
name: z.string().min(1),
|
|
709
|
+
description: z.string().optional(),
|
|
710
|
+
skipAuth: z.boolean().optional(),
|
|
711
|
+
triggerPlugin: z.record(z.any()).describe('Full plugin object from list_trigger_plugins'),
|
|
712
|
+
triggerOptions: z.record(z.any()).describe('Trigger-specific options'),
|
|
713
|
+
initialNodes: z.array(z.record(z.any())).optional()
|
|
714
|
+
},
|
|
715
|
+
annotations: {destructiveHint: false, idempotentHint: false}
|
|
716
|
+
},
|
|
717
|
+
async (params) => {
|
|
718
|
+
const guard = requireApp();
|
|
719
|
+
if (guard) return guard;
|
|
720
|
+
try {
|
|
721
|
+
const app = registry.selectedApp;
|
|
722
|
+
dnioClient.setToken(userContext.token);
|
|
723
|
+
|
|
724
|
+
// Always re-resolve the trigger plugin from the platform to guarantee
|
|
725
|
+
// inputSchema/outputSchema/errorSchema/nodeId are present.
|
|
726
|
+
const trigger = await _resolvePlugin(dnioClient, app, params.triggerPlugin);
|
|
727
|
+
if ((trigger?.category || '').toUpperCase() !== 'TRIGGER') {
|
|
728
|
+
return toolError('triggerPlugin must have category=TRIGGER', new Error(`got category=${trigger?.category}`));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const inputNode = buildInputNode(trigger, params.triggerOptions);
|
|
732
|
+
inputNode.coordinates = {x: 0, y: 0};
|
|
733
|
+
|
|
734
|
+
const ids = new Set([inputNode._id]);
|
|
735
|
+
const builtNodes = [];
|
|
736
|
+
// Temp flow grows as we build initialNodes so each node's mappings can
|
|
737
|
+
// resolve against the previously-built node's outputSchema.
|
|
738
|
+
const tempFlow = {inputNode, nodes: builtNodes};
|
|
739
|
+
const initial = Array.isArray(params.initialNodes) ? params.initialNodes : [];
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < initial.length; i++) {
|
|
742
|
+
const spec = initial[i] || {};
|
|
743
|
+
let plugin;
|
|
744
|
+
try {
|
|
745
|
+
plugin = await _resolvePlugin(dnioClient, app, spec.pluginObject);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return toolError(`initialNodes[${i}] plugin resolution failed`, err);
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
plugin = await _applyAllSchemaOverrides(dnioClient, app, plugin, spec.schemaOverrides);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
return toolError(`initialNodes[${i}] schema override failed`, err);
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
validateConnectorRequirement(plugin, spec.connector);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return toolError(`initialNodes[${i}] connector check failed`, err);
|
|
758
|
+
}
|
|
759
|
+
const slug = dedupeId(slugify(spec.name || plugin.label || plugin.type), ids);
|
|
760
|
+
ids.add(slug);
|
|
761
|
+
|
|
762
|
+
// Source for auto-mapping is the previous node in the chain (or the trigger).
|
|
763
|
+
const prevNodeId = i === 0 ? inputNode._id : builtNodes[i - 1]._id;
|
|
764
|
+
let mappings;
|
|
765
|
+
try {
|
|
766
|
+
const resolved = _resolveMappings(tempFlow, prevNodeId, plugin.inputSchema || [], spec.mappings);
|
|
767
|
+
mappings = resolved.mappings;
|
|
768
|
+
} catch (err) {
|
|
769
|
+
return toolError(`initialNodes[${i}] mapping build failed`, err);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const node = buildProcessNode(plugin, slug, {
|
|
773
|
+
options: spec.options,
|
|
774
|
+
mappings,
|
|
775
|
+
connector: spec.connector,
|
|
776
|
+
coordinates: spec.coordinates || {x: (i + 1) * 200, y: 0},
|
|
777
|
+
app
|
|
778
|
+
});
|
|
779
|
+
builtNodes.push(node);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Wire sequentially: inputNode → builtNodes[0] → builtNodes[1] → ...
|
|
783
|
+
if (builtNodes.length > 0) {
|
|
784
|
+
inputNode.onSuccess = [{_id: builtNodes[0]._id}];
|
|
785
|
+
for (let i = 0; i < builtNodes.length - 1; i++) {
|
|
786
|
+
builtNodes[i].onSuccess = [{_id: builtNodes[i + 1]._id}];
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const payload = {
|
|
791
|
+
name: params.name,
|
|
792
|
+
description: params.description ?? null,
|
|
793
|
+
type: trigger.type,
|
|
794
|
+
app,
|
|
795
|
+
skipAuth: params.skipAuth !== undefined ? params.skipAuth : true,
|
|
796
|
+
inputNode,
|
|
797
|
+
nodes: builtNodes
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
dnioClient.setToken(userContext.token);
|
|
801
|
+
const result = await dnioClient.dataPipes.create(app, payload);
|
|
802
|
+
const invocation = _flowInvocationUrl(dnioClient, app, result);
|
|
803
|
+
const response = {flowId: result._id, status: result.status, flow: result};
|
|
804
|
+
if (invocation) {
|
|
805
|
+
response.invocation = invocation;
|
|
806
|
+
response.note = `Flow created in '${result.status}' state. Run publish_flow(flowId='${result._id}') to make it live. PUBLIC INVOCATION URL: ${invocation.method} ${invocation.url} — use exactly this URL when telling the user how to call the flow. Do NOT use any url/host/gateway field elsewhere in the flow document; those are internal Kubernetes references.`;
|
|
807
|
+
}
|
|
808
|
+
return {content: [{type: 'text', text: JSON.stringify(response, null, 2)}]};
|
|
809
|
+
} catch (error) {
|
|
810
|
+
return toolError('Failed to create flow', error);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
// ─── Node mutation ──────────────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
server.registerTool(
|
|
818
|
+
'add_node_to_flow',
|
|
819
|
+
{
|
|
820
|
+
title: 'Add Node to Flow',
|
|
821
|
+
description: `Append a process node to an existing flow and wire it after another node. Always fetches the current flow, mutates it, and PUTs the full document.
|
|
822
|
+
|
|
823
|
+
Wiring rules:
|
|
824
|
+
- Default: replace afterNodeId.onSuccess with [{ _id: <new node> }].
|
|
825
|
+
- If branchCondition is set: APPEND to afterNodeId.onSuccess so multiple branches can coexist (each entry has its own condition).
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
- flowId (required)
|
|
829
|
+
- pluginObject (required): Full plugin object from list_process_plugins.
|
|
830
|
+
- name (required): Readable name; slug becomes the node _id.
|
|
831
|
+
- afterNodeId (required): _id of the node whose onSuccess should now point at this new node. Use the inputNode _id (e.g. 'http_server') to attach right after the trigger.
|
|
832
|
+
- options (optional): Node-specific options merged on top of platform defaults.
|
|
833
|
+
- mappings (required): Array of mapping entries. Two accepted forms (mix freely):
|
|
834
|
+
• SIMPLE (preferred): { from: '<sourceNodeId>.<field>[.<nested>...]', to: '<targetField>', expression?: '<custom lodash template>' }
|
|
835
|
+
The tool walks the source node's outputSchema + the current node's inputSchema and builds the full DNIO mapping object (target / source / expression / children / dataPath / dataPathSegs / type / subType / etc.) for you. Pass only 'expression' (no 'from') for hardcoded values like '{{CONSTANTS.TOKEN}}'.
|
|
836
|
+
• FULL: pass an entry that already has 'target' as an object and 'source' as an array — used as-is.
|
|
837
|
+
- connector (optional): { _id: '<CONNECTOR_ID>' } — required when pluginObject.connectorType !== 'NONE'.
|
|
838
|
+
|
|
839
|
+
PLUGIN OBJECT: pass either the full plugin from list_process_plugins, or just '{ _id: <installed plugin _id> }' or '{ type: 'V1_PARSE_JSON' }' — the tool re-fetches the plugin from /my-node and copies the canonical inputSchema / outputSchema / errorSchema / nodeId onto the persisted node. You don't need to round-trip the schemas through this call.
|
|
840
|
+
|
|
841
|
+
SCHEMA OVERRIDES (optional): pin a node's inputSchema or outputSchema field to a specific Data Service so the platform UI shows typed fields instead of generic 'Schema'. Default behaviour is to use the plugin's marketplace schemas as-is — only pass this if the user explicitly asks to bind a field to a particular data format.
|
|
842
|
+
|
|
843
|
+
schemaOverrides: {
|
|
844
|
+
inputSchema: [
|
|
845
|
+
{ key: 'data', refFormatType: 'service', refFormatId: 'SRVC13342' }
|
|
846
|
+
],
|
|
847
|
+
outputSchema: [ /* same shape, optional */ ]
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
The tool fetches the data service definition by refFormatId and embeds it as 'refData' on the matching field. Currently only refFormatType: 'service' is supported.
|
|
851
|
+
- coordinates (optional): { x, y }. Defaults to right of last node.
|
|
852
|
+
- branchCondition (optional): { condition: '<lodash expr>', name: '<branch label>', color?: '<hex>' } — adds as a conditional branch instead of replacing onSuccess.
|
|
853
|
+
|
|
854
|
+
CODEBLOCK FALLBACK:
|
|
855
|
+
When no installed plugin covers the task and the logic is trivial, use V1_CODEBLOCK as the pluginObject and pass the JavaScript in options.code.
|
|
856
|
+
|
|
857
|
+
V1_CODEBLOCK is a PLATFORM BUILT-IN — DO NOT call list_marketplace_plugins or install_plugins for it (it isn't there). Pass it directly here:
|
|
858
|
+
add_node_to_flow({ pluginObject: { type: 'V1_CODEBLOCK' }, options: { code: '<your JS>' }, ... })
|
|
859
|
+
The tool seeds a working default for options.code if you don't pass one — replace it via update_node_in_flow.options.code or pass the real code at add time.
|
|
860
|
+
|
|
861
|
+
The function signature is fixed:
|
|
862
|
+
|
|
863
|
+
async function executeCode(inputData, node, connectorConfig) {
|
|
864
|
+
try {
|
|
865
|
+
let data = {};
|
|
866
|
+
// your logic here, reading from inputData and previous-node mappings
|
|
867
|
+
return data; // becomes outputSchema.data downstream
|
|
868
|
+
} catch (err) {
|
|
869
|
+
logger.error(err); // 'logger' is provided by the platform
|
|
870
|
+
throw err; // routes to onError
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
Notes:
|
|
875
|
+
- Only the function body matters; the platform invokes executeCode for you.
|
|
876
|
+
- inputData is a plain object built from the node's mappings.
|
|
877
|
+
- Use \`logger.info/debug/warn/error\` for runtime logs.
|
|
878
|
+
- V1_CODEBLOCK has connectorType=NONE; do not pass a connector.
|
|
879
|
+
- Always prefer a first-class plugin over a CODEBLOCK when one exists.
|
|
880
|
+
|
|
881
|
+
HTTP RESPONSE NODE (V1_RESPONSE):
|
|
882
|
+
For HTTP-triggered flows (V1_HTTP_SERVER) where the caller expects data back, the LAST node MUST be V1_RESPONSE. It IS a marketplace plugin — install_plugins if not already installed.
|
|
883
|
+
|
|
884
|
+
Its inputSchema has three fields, and the typical mapping pattern is:
|
|
885
|
+
• data (Buffer) — the response body. Auto-maps from the previous node's 'data' (e.g. V1_RENDER_JSON, V1_PARSE_JSON, V1_CODEBLOCK all output 'data') because key names match.
|
|
886
|
+
• statusCode (Number) — HTTP status. Pass an explicit hardcoded mapping: { to: 'statusCode', expression: '200' }
|
|
887
|
+
• headers (KeyValPair) — optional response headers. Leave unmapped unless the user asks for specific headers.
|
|
888
|
+
|
|
889
|
+
Example call after a V1_RENDER_JSON node named 'render_response':
|
|
890
|
+
add_node_to_flow({
|
|
891
|
+
pluginObject: { type: 'V1_RESPONSE' },
|
|
892
|
+
name: 'send_response',
|
|
893
|
+
afterNodeId: 'render_response',
|
|
894
|
+
mappings: [
|
|
895
|
+
{ from: 'render_response.data', to: 'data' },
|
|
896
|
+
{ to: 'statusCode', expression: '200' }
|
|
897
|
+
]
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
Flows without V1_RESPONSE simply terminate without returning a body — fine for fire-and-forget endpoints (timer-triggered jobs, async processing), but breaks anything the HTTP caller waits on.
|
|
901
|
+
${EXPRESSION_GLOBALS}`,
|
|
902
|
+
inputSchema: {
|
|
903
|
+
flowId: z.string().min(1),
|
|
904
|
+
pluginObject: z.record(z.any()).describe('Full plugin object from list_process_plugins'),
|
|
905
|
+
name: z.string().min(1),
|
|
906
|
+
afterNodeId: z.string().min(1),
|
|
907
|
+
options: z.record(z.any()).optional(),
|
|
908
|
+
mappings: z.array(z.record(z.any())).optional(),
|
|
909
|
+
connector: z.object({_id: z.string()}).optional(),
|
|
910
|
+
coordinates: z.object({x: z.number(), y: z.number()}).optional(),
|
|
911
|
+
branchCondition: z.object({
|
|
912
|
+
condition: z.string(),
|
|
913
|
+
name: z.string(),
|
|
914
|
+
color: z.string().optional()
|
|
915
|
+
}).optional(),
|
|
916
|
+
schemaOverrides: z.object({
|
|
917
|
+
inputSchema: z.array(z.object({
|
|
918
|
+
key: z.string(),
|
|
919
|
+
refFormatType: z.enum(['service']),
|
|
920
|
+
refFormatId: z.string()
|
|
921
|
+
})).optional(),
|
|
922
|
+
outputSchema: z.array(z.object({
|
|
923
|
+
key: z.string(),
|
|
924
|
+
refFormatType: z.enum(['service']),
|
|
925
|
+
refFormatId: z.string()
|
|
926
|
+
})).optional()
|
|
927
|
+
}).optional()
|
|
928
|
+
},
|
|
929
|
+
annotations: {destructiveHint: false, idempotentHint: false}
|
|
930
|
+
},
|
|
931
|
+
async (params) => {
|
|
932
|
+
const guard = requireApp();
|
|
933
|
+
if (guard) return guard;
|
|
934
|
+
try {
|
|
935
|
+
const app = registry.selectedApp;
|
|
936
|
+
dnioClient.setToken(userContext.token);
|
|
937
|
+
|
|
938
|
+
// Always re-resolve the plugin so inputSchema / outputSchema / errorSchema
|
|
939
|
+
// / nodeId end up on the persisted node, even if the LLM dropped them.
|
|
940
|
+
let plugin;
|
|
941
|
+
try {
|
|
942
|
+
plugin = await _resolvePlugin(dnioClient, app, params.pluginObject);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
return toolError('Plugin resolution failed', err);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Optional per-field schema overlays (e.g. pin V1_RENDER_JSON's `data` field
|
|
948
|
+
// to a specific data service). Default keeps the plugin's marketplace schemas.
|
|
949
|
+
try {
|
|
950
|
+
plugin = await _applyAllSchemaOverrides(dnioClient, app, plugin, params.schemaOverrides);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
return toolError('Schema override failed', err);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
validateConnectorRequirement(plugin, params.connector);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
return toolError('Connector validation failed', err);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const flow = await dnioClient.dataPipes.get(app, params.flowId);
|
|
962
|
+
|
|
963
|
+
const after = findNodeRef(flow, params.afterNodeId);
|
|
964
|
+
if (!after) {
|
|
965
|
+
return toolError(`afterNodeId '${params.afterNodeId}' not found in flow`, new Error('check inputNode._id and nodes[]._id'));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const ids = collectFlowIds(flow);
|
|
969
|
+
const newId = dedupeId(slugify(params.name), ids);
|
|
970
|
+
const coords = params.coordinates || {x: maxX(flow) + 200, y: 0};
|
|
971
|
+
|
|
972
|
+
let mappings, autoMapped;
|
|
973
|
+
try {
|
|
974
|
+
const resolved = _resolveMappings(flow, params.afterNodeId, plugin.inputSchema || [], params.mappings);
|
|
975
|
+
mappings = resolved.mappings;
|
|
976
|
+
autoMapped = resolved.autoMapped;
|
|
977
|
+
} catch (err) {
|
|
978
|
+
return toolError('Mapping build failed', err);
|
|
979
|
+
}
|
|
980
|
+
const unmapped = _unmappedFields(plugin.inputSchema || [], mappings);
|
|
981
|
+
|
|
982
|
+
const newNode = buildProcessNode(plugin, newId, {
|
|
983
|
+
options: params.options,
|
|
984
|
+
mappings,
|
|
985
|
+
connector: params.connector,
|
|
986
|
+
coordinates: coords,
|
|
987
|
+
app
|
|
988
|
+
});
|
|
989
|
+
newNode.name = params.name;
|
|
990
|
+
|
|
991
|
+
flow.nodes = flow.nodes || [];
|
|
992
|
+
flow.nodes.push(newNode);
|
|
993
|
+
|
|
994
|
+
const edgeEntry = params.branchCondition
|
|
995
|
+
? {
|
|
996
|
+
_id: newId,
|
|
997
|
+
condition: params.branchCondition.condition,
|
|
998
|
+
name: params.branchCondition.name,
|
|
999
|
+
...(params.branchCondition.color ? {color: params.branchCondition.color} : {})
|
|
1000
|
+
}
|
|
1001
|
+
: {_id: newId};
|
|
1002
|
+
|
|
1003
|
+
after.node.onSuccess = after.node.onSuccess || [];
|
|
1004
|
+
if (params.branchCondition) {
|
|
1005
|
+
after.node.onSuccess.push(edgeEntry);
|
|
1006
|
+
} else {
|
|
1007
|
+
after.node.onSuccess = [edgeEntry];
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const result = await dnioClient.dataPipes.update(app, params.flowId, flow);
|
|
1011
|
+
const response = {
|
|
1012
|
+
addedNodeId: newId,
|
|
1013
|
+
autoMapped,
|
|
1014
|
+
appliedMappings: mappings || [],
|
|
1015
|
+
unmappedInputFields: unmapped,
|
|
1016
|
+
flow: result
|
|
1017
|
+
};
|
|
1018
|
+
if (unmapped.length > 0) {
|
|
1019
|
+
response.note = `Node added, but ${unmapped.length} input field(s) [${unmapped.map(f => f.key).join(', ')}] are NOT mapped. If the right source is obvious from the upstream nodes' outputSchemas, call update_node_in_flow with explicit mappings. If not, ask the user how to populate these fields before publishing.`;
|
|
1020
|
+
}
|
|
1021
|
+
return {content: [{type: 'text', text: JSON.stringify(response, null, 2)}]};
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
return toolError(`Failed to add node to flow ${params.flowId}`, error);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
server.registerTool(
|
|
1029
|
+
'update_node_in_flow',
|
|
1030
|
+
{
|
|
1031
|
+
title: 'Update Node in Flow',
|
|
1032
|
+
description: `Edit an existing node's options, mappings, coordinates, name, or connector. Fetches the full flow, mutates the matching node, PUTs the whole document.
|
|
1033
|
+
|
|
1034
|
+
Only the fields you pass are merged in — others are left untouched. Pass options as a partial; it merges onto the existing options object (not the platform defaults).
|
|
1035
|
+
|
|
1036
|
+
Args:
|
|
1037
|
+
- flowId (required)
|
|
1038
|
+
- nodeId (required): _id of the node to edit (or 'inputNode' to edit the trigger).
|
|
1039
|
+
- options (optional): Partial options; merged onto existing.
|
|
1040
|
+
- mappings (optional): Replaces the existing mappings array entirely. Same two accepted forms as add_node_to_flow:
|
|
1041
|
+
• SIMPLE: { from: '<nodeId>.<field>', to: '<targetField>', expression?: '<custom>' } — the tool walks schemas to build the full DNIO mapping shape.
|
|
1042
|
+
• FULL: pass entries with 'target' as an object and 'source' as an array — used as-is.
|
|
1043
|
+
- coordinates (optional): { x, y }.
|
|
1044
|
+
- name (optional): New name (does NOT change the _id).
|
|
1045
|
+
- connector (optional): { _id }; sets options.connector.
|
|
1046
|
+
- schemaOverrides (optional): Same shape as add_node_to_flow — pin a field's inputSchema or outputSchema to a specific data service. Only the listed fields are modified; the rest of the schema is left intact.
|
|
1047
|
+
${EXPRESSION_GLOBALS}`,
|
|
1048
|
+
inputSchema: {
|
|
1049
|
+
flowId: z.string().min(1),
|
|
1050
|
+
nodeId: z.string().min(1),
|
|
1051
|
+
options: z.record(z.any()).optional(),
|
|
1052
|
+
mappings: z.array(z.record(z.any())).optional(),
|
|
1053
|
+
coordinates: z.object({x: z.number(), y: z.number()}).optional(),
|
|
1054
|
+
name: z.string().optional(),
|
|
1055
|
+
connector: z.object({_id: z.string()}).optional(),
|
|
1056
|
+
schemaOverrides: z.object({
|
|
1057
|
+
inputSchema: z.array(z.object({
|
|
1058
|
+
key: z.string(),
|
|
1059
|
+
refFormatType: z.enum(['service']),
|
|
1060
|
+
refFormatId: z.string()
|
|
1061
|
+
})).optional(),
|
|
1062
|
+
outputSchema: z.array(z.object({
|
|
1063
|
+
key: z.string(),
|
|
1064
|
+
refFormatType: z.enum(['service']),
|
|
1065
|
+
refFormatId: z.string()
|
|
1066
|
+
})).optional()
|
|
1067
|
+
}).optional()
|
|
1068
|
+
},
|
|
1069
|
+
annotations: {destructiveHint: false, idempotentHint: true}
|
|
1070
|
+
},
|
|
1071
|
+
async (params) => {
|
|
1072
|
+
const guard = requireApp();
|
|
1073
|
+
if (guard) return guard;
|
|
1074
|
+
try {
|
|
1075
|
+
const app = registry.selectedApp;
|
|
1076
|
+
dnioClient.setToken(userContext.token);
|
|
1077
|
+
const flow = await dnioClient.dataPipes.get(app, params.flowId);
|
|
1078
|
+
|
|
1079
|
+
const ref = findNodeRef(flow, params.nodeId);
|
|
1080
|
+
if (!ref) {
|
|
1081
|
+
return toolError(`Node '${params.nodeId}' not found in flow`, new Error('check inputNode._id and nodes[]._id'));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const node = ref.node;
|
|
1085
|
+
if (params.options) node.options = {...(node.options || {}), ...params.options};
|
|
1086
|
+
if (params.schemaOverrides) {
|
|
1087
|
+
try {
|
|
1088
|
+
if (params.schemaOverrides.inputSchema) {
|
|
1089
|
+
node.inputSchema = await _applySchemaOverrides(dnioClient, app, node.inputSchema, params.schemaOverrides.inputSchema);
|
|
1090
|
+
}
|
|
1091
|
+
if (params.schemaOverrides.outputSchema) {
|
|
1092
|
+
node.outputSchema = await _applySchemaOverrides(dnioClient, app, node.outputSchema, params.schemaOverrides.outputSchema);
|
|
1093
|
+
}
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
return toolError('Schema override failed', err);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (params.mappings) {
|
|
1099
|
+
try {
|
|
1100
|
+
node.mappings = _buildMappings(flow, node.inputSchema || [], params.mappings);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
return toolError('Mapping build failed', err);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (params.coordinates) node.coordinates = params.coordinates;
|
|
1106
|
+
if (params.name) node.name = params.name;
|
|
1107
|
+
if (params.connector?._id) {
|
|
1108
|
+
node.options = node.options || {};
|
|
1109
|
+
node.options.connector = {_id: params.connector._id};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const result = await dnioClient.dataPipes.update(app, params.flowId, flow);
|
|
1113
|
+
return {content: [{type: 'text', text: JSON.stringify({updatedNodeId: params.nodeId, flow: result}, null, 2)}]};
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
return toolError(`Failed to update node ${params.nodeId} in flow ${params.flowId}`, error);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
server.registerTool(
|
|
1121
|
+
'connect_nodes',
|
|
1122
|
+
{
|
|
1123
|
+
title: 'Connect / Re-route Nodes',
|
|
1124
|
+
description: `Wire onSuccess or onError between existing nodes. Use to re-route flow paths or add branches after the fact.
|
|
1125
|
+
|
|
1126
|
+
Mode:
|
|
1127
|
+
- condition unset → replaces the source node's chosen edge with [{ _id: toNodeId }] (replace mode).
|
|
1128
|
+
- condition set → APPENDS { _id: toNodeId, condition, name, color? } to the edge (branch mode).
|
|
1129
|
+
- mode arg explicitly overrides this default.
|
|
1130
|
+
|
|
1131
|
+
Args:
|
|
1132
|
+
- flowId (required)
|
|
1133
|
+
- fromNodeId (required): Source node _id (can be the inputNode).
|
|
1134
|
+
- toNodeId (required): Target node _id.
|
|
1135
|
+
- edge (optional, default 'onSuccess'): 'onSuccess' or 'onError'.
|
|
1136
|
+
- condition (optional): Lodash-style branch expression (e.g. "_.isNull({{prev.data.content}})").
|
|
1137
|
+
- branchName (optional): Branch label (required if condition is set).
|
|
1138
|
+
- branchColor (optional): Hex color without '#'.
|
|
1139
|
+
- mode (optional): 'append' or 'replace' to override the default behaviour.
|
|
1140
|
+
${EXPRESSION_GLOBALS}`,
|
|
1141
|
+
inputSchema: {
|
|
1142
|
+
flowId: z.string().min(1),
|
|
1143
|
+
fromNodeId: z.string().min(1),
|
|
1144
|
+
toNodeId: z.string().min(1),
|
|
1145
|
+
edge: z.enum(['onSuccess', 'onError']).optional(),
|
|
1146
|
+
condition: z.string().optional(),
|
|
1147
|
+
branchName: z.string().optional(),
|
|
1148
|
+
branchColor: z.string().optional(),
|
|
1149
|
+
mode: z.enum(['append', 'replace']).optional()
|
|
1150
|
+
},
|
|
1151
|
+
annotations: {destructiveHint: false, idempotentHint: false}
|
|
1152
|
+
},
|
|
1153
|
+
async (params) => {
|
|
1154
|
+
const guard = requireApp();
|
|
1155
|
+
if (guard) return guard;
|
|
1156
|
+
try {
|
|
1157
|
+
const app = registry.selectedApp;
|
|
1158
|
+
dnioClient.setToken(userContext.token);
|
|
1159
|
+
const flow = await dnioClient.dataPipes.get(app, params.flowId);
|
|
1160
|
+
|
|
1161
|
+
const fromRef = findNodeRef(flow, params.fromNodeId);
|
|
1162
|
+
if (!fromRef) {
|
|
1163
|
+
return toolError(`fromNodeId '${params.fromNodeId}' not found`, new Error(''));
|
|
1164
|
+
}
|
|
1165
|
+
const toRef = findNodeRef(flow, params.toNodeId);
|
|
1166
|
+
if (!toRef) {
|
|
1167
|
+
return toolError(`toNodeId '${params.toNodeId}' not found`, new Error(''));
|
|
1168
|
+
}
|
|
1169
|
+
if (params.condition && !params.branchName) {
|
|
1170
|
+
return toolError('branchName is required when condition is set', new Error(''));
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const edge = params.edge || 'onSuccess';
|
|
1174
|
+
const mode = params.mode || (params.condition ? 'append' : 'replace');
|
|
1175
|
+
const entry = params.condition
|
|
1176
|
+
? {
|
|
1177
|
+
_id: params.toNodeId,
|
|
1178
|
+
condition: params.condition,
|
|
1179
|
+
name: params.branchName,
|
|
1180
|
+
...(params.branchColor ? {color: params.branchColor} : {})
|
|
1181
|
+
}
|
|
1182
|
+
: {_id: params.toNodeId};
|
|
1183
|
+
|
|
1184
|
+
fromRef.node[edge] = fromRef.node[edge] || [];
|
|
1185
|
+
if (mode === 'replace') {
|
|
1186
|
+
fromRef.node[edge] = [entry];
|
|
1187
|
+
} else {
|
|
1188
|
+
fromRef.node[edge].push(entry);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const result = await dnioClient.dataPipes.update(app, params.flowId, flow);
|
|
1192
|
+
return {content: [{type: 'text', text: JSON.stringify({from: params.fromNodeId, to: params.toNodeId, edge, mode, flow: result}, null, 2)}]};
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
return toolError(`Failed to connect nodes in flow ${params.flowId}`, error);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
server.registerTool(
|
|
1200
|
+
'remove_node_from_flow',
|
|
1201
|
+
{
|
|
1202
|
+
title: 'Remove Node From Flow',
|
|
1203
|
+
description: `⚠️ Destructive. Remove a node from a flow and clean up dangling references in every other node's onSuccess and onError arrays.
|
|
1204
|
+
|
|
1205
|
+
Refuses to remove the inputNode (cannot remove the trigger). Refuses if the inputNode's only successor is being removed and downstream nodes still exist, unless force=true.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
- flowId (required)
|
|
1209
|
+
- nodeId (required): The _id of the node to remove.
|
|
1210
|
+
- force (optional, default false): Remove even if it would orphan downstream nodes.`,
|
|
1211
|
+
inputSchema: {
|
|
1212
|
+
flowId: z.string().min(1),
|
|
1213
|
+
nodeId: z.string().min(1),
|
|
1214
|
+
force: z.boolean().optional()
|
|
1215
|
+
},
|
|
1216
|
+
annotations: {destructiveHint: true, idempotentHint: false}
|
|
1217
|
+
},
|
|
1218
|
+
async (params) => {
|
|
1219
|
+
const guard = requireApp();
|
|
1220
|
+
if (guard) return guard;
|
|
1221
|
+
try {
|
|
1222
|
+
const app = registry.selectedApp;
|
|
1223
|
+
dnioClient.setToken(userContext.token);
|
|
1224
|
+
const flow = await dnioClient.dataPipes.get(app, params.flowId);
|
|
1225
|
+
|
|
1226
|
+
if (flow.inputNode?._id === params.nodeId) {
|
|
1227
|
+
return toolError('Cannot remove the inputNode (trigger).', new Error(''));
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const idx = (flow.nodes || []).findIndex(n => n._id === params.nodeId);
|
|
1231
|
+
if (idx < 0) {
|
|
1232
|
+
return toolError(`Node '${params.nodeId}' not found in flow`, new Error(''));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Orphan check: would removing this leave inputNode pointing to nothing while others exist?
|
|
1236
|
+
if (!params.force) {
|
|
1237
|
+
const inSuccs = flow.inputNode?.onSuccess || [];
|
|
1238
|
+
const onlyEdgeIsThis = inSuccs.length === 1 && inSuccs[0]._id === params.nodeId;
|
|
1239
|
+
const moreNodesRemain = (flow.nodes || []).length > 1;
|
|
1240
|
+
if (onlyEdgeIsThis && moreNodesRemain) {
|
|
1241
|
+
return toolError('Removing this node would orphan downstream nodes. Pass force=true to remove anyway.', new Error(''));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
flow.nodes.splice(idx, 1);
|
|
1246
|
+
|
|
1247
|
+
const stripRef = (arr) => (arr || []).filter(e => e._id !== params.nodeId);
|
|
1248
|
+
if (flow.inputNode) {
|
|
1249
|
+
flow.inputNode.onSuccess = stripRef(flow.inputNode.onSuccess);
|
|
1250
|
+
flow.inputNode.onError = stripRef(flow.inputNode.onError);
|
|
1251
|
+
}
|
|
1252
|
+
for (const n of flow.nodes) {
|
|
1253
|
+
n.onSuccess = stripRef(n.onSuccess);
|
|
1254
|
+
n.onError = stripRef(n.onError);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const result = await dnioClient.dataPipes.update(app, params.flowId, flow);
|
|
1258
|
+
return {content: [{type: 'text', text: JSON.stringify({removedNodeId: params.nodeId, flow: result}, null, 2)}]};
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
return toolError(`Failed to remove node ${params.nodeId} from flow ${params.flowId}`, error);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
);
|
|
1264
|
+
|
|
1265
|
+
server.registerTool(
|
|
1266
|
+
'publish_flow',
|
|
1267
|
+
{
|
|
1268
|
+
title: 'Publish Flow',
|
|
1269
|
+
description: `Publish a flow to move it out of Draft state. The platform validates the flow during publish — fix errors via the other flow tools and retry if it fails.
|
|
1270
|
+
|
|
1271
|
+
For HTTP-triggered flows the response includes 'invocation: { method, url, warning }'. The URL pattern is {DNIO_BASE_URL}/b2b/pipes/{app}/{httpServerPath} (e.g. https://qa.datanimbus.io/b2b/pipes/dx-mcp/enrichUsersWithDob).
|
|
1272
|
+
|
|
1273
|
+
⚠️ ONLY use 'invocation.url' when telling the user how to call the flow. The platform's publish response and the flow document itself contain internal Kubernetes service URLs (fields like 'url', 'host', 'deploymentName', 'yaml', often shaped like 'gateway.<namespace>.svc/api/b2b/<app>/<path>' or '/api/...' paths). Those are cluster-internal and NOT callable from outside — never paste them into a curl for the user.
|
|
1274
|
+
|
|
1275
|
+
Args:
|
|
1276
|
+
- flowId (required)`,
|
|
1277
|
+
inputSchema: {
|
|
1278
|
+
flowId: z.string().min(1)
|
|
1279
|
+
},
|
|
1280
|
+
annotations: {destructiveHint: false, idempotentHint: true}
|
|
1281
|
+
},
|
|
1282
|
+
async (params) => {
|
|
1283
|
+
const guard = requireApp();
|
|
1284
|
+
if (guard) return guard;
|
|
1285
|
+
try {
|
|
1286
|
+
dnioClient.setToken(userContext.token);
|
|
1287
|
+
const result = await dnioClient.dataPipes.publish(registry.selectedApp, params.flowId);
|
|
1288
|
+
// Fetch the flow afterwards to build the invocation URL for HTTP triggers.
|
|
1289
|
+
let invocation = null;
|
|
1290
|
+
try {
|
|
1291
|
+
const flow = await dnioClient.dataPipes.get(registry.selectedApp, params.flowId);
|
|
1292
|
+
invocation = _flowInvocationUrl(dnioClient, registry.selectedApp, flow);
|
|
1293
|
+
} catch (_) { /* non-fatal — publish already succeeded */ }
|
|
1294
|
+
const response = {flowId: params.flowId, result};
|
|
1295
|
+
if (invocation) {
|
|
1296
|
+
response.invocation = invocation;
|
|
1297
|
+
response.note = `Published. Invoke with: ${invocation.method} ${invocation.url}`;
|
|
1298
|
+
}
|
|
1299
|
+
return {content: [{type: 'text', text: JSON.stringify(response, null, 2)}]};
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
return toolError(`Failed to publish flow ${params.flowId}`, error);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
);
|
|
1305
|
+
};
|