@thinger-io/cli-core 0.1.1 → 0.2.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/CHANGELOG.md +93 -0
- package/README.md +37 -5
- package/lib/api.js +3 -0
- package/lib/auth.js +29 -9
- package/lib/bucket.js +3 -0
- package/lib/cli.js +15 -2
- package/lib/config.js +55 -5
- package/lib/constants.js +8 -0
- package/lib/dashboard.js +605 -0
- package/lib/docs.js +182 -0
- package/lib/errors.js +29 -4
- package/lib/mcp/catalog.js +1235 -0
- package/lib/mcp/helpers.js +1 -1
- package/lib/mcp/registry.js +72 -30
- package/lib/mcp/resource-hooks.js +145 -0
- package/lib/mcp/server.js +46 -5
- package/lib/mcp/tools-buckets.js +79 -80
- package/lib/mcp/tools-dashboards.js +123 -0
- package/lib/mcp/tools-devices.js +88 -234
- package/lib/mcp/tools-misc.js +48 -0
- package/lib/mcp/tools-plugins.js +63 -0
- package/lib/mcp/tools-products.js +32 -385
- package/lib/mcp/tools-resource.js +760 -0
- package/lib/mcp/tools-storage.js +173 -0
- package/lib/openapi.js +169 -0
- package/lib/paths.js +8 -0
- package/lib/resource.js +2 -0
- package/lib/storage.js +2 -1
- package/openapi/swagger.json +1 -0
- package/package.json +2 -1
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -84
- package/lib/mcp/tools-resources.js +0 -83
- package/lib/mcp/tools-tokens.js +0 -370
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import api from '../api.js';
|
|
3
|
+
import { TIMEOUTS } from '../constants.js';
|
|
4
|
+
import { getActiveProfile } from '../config.js';
|
|
5
|
+
import { apiError, inputError } from '../errors.js';
|
|
6
|
+
import { readDoc } from '../docs.js';
|
|
7
|
+
import { findOperation, resolveRefs, serverSpec } from '../openapi.js';
|
|
8
|
+
import {
|
|
9
|
+
CATALOG,
|
|
10
|
+
actionRequest,
|
|
11
|
+
actionTemplate,
|
|
12
|
+
ancestry,
|
|
13
|
+
idHint,
|
|
14
|
+
idNames,
|
|
15
|
+
parentHint,
|
|
16
|
+
requestFor,
|
|
17
|
+
templateFor,
|
|
18
|
+
typeNames,
|
|
19
|
+
} from './catalog.js';
|
|
20
|
+
import { toolName } from './naming.js';
|
|
21
|
+
import { mergeHooks } from './resource-hooks.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The generic `resource_*` tools: one tool per verb over every type of the
|
|
25
|
+
* catalog, instead of one tool per type and verb. What a type looks like
|
|
26
|
+
* (its fields, the examples the server documents) is not in the tool list
|
|
27
|
+
* but behind `resource_describe`, read from the server's own OpenAPI
|
|
28
|
+
* document when the model needs it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** `{tool:x}` in catalog notes → full tool names (tests check they exist). */
|
|
32
|
+
const expandNotes = (notes) => notes.replace(/\{tool:([a-z0-9_]+)\}/g, (_, base) => toolName(base));
|
|
33
|
+
|
|
34
|
+
/** Verbs whose request carries a body. */
|
|
35
|
+
const BODY_VERBS = new Set(['create', 'update', 'patch', 'clone']);
|
|
36
|
+
|
|
37
|
+
/** Examples named like this document one field each: noise in a description. */
|
|
38
|
+
const FIELD_EXAMPLE = /^Update '/;
|
|
39
|
+
|
|
40
|
+
const text = (t) => ({ content: [{ type: 'text', text: t }], isError: false });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A type and the ids that locate it, for messages: `device_property thermostat/setpoint`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} type
|
|
46
|
+
* @param {{ parent?: string, id?: string }} [where]
|
|
47
|
+
*/
|
|
48
|
+
function label(type, { parent, id } = {}) {
|
|
49
|
+
const where = [parent, id].filter(Boolean).join('/');
|
|
50
|
+
return where ? `${type} ${where}` : type;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The id a create or clone body names, when it carries every id field. */
|
|
54
|
+
function bodyIdOf(t, body) {
|
|
55
|
+
const fields = t.bodyId ?? idNames(t);
|
|
56
|
+
if (!fields.length || !body || typeof body !== 'object' || Array.isArray(body))
|
|
57
|
+
return undefined;
|
|
58
|
+
const ids = fields.map((f) => body[f]);
|
|
59
|
+
return ids.every((v) => v !== undefined && v !== null && v !== '') ? ids.join('/') : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} type
|
|
64
|
+
* @param {import('./catalog.js').Verb} verb
|
|
65
|
+
* @param {{ parent?: string, id?: string, user?: string | null }} args
|
|
66
|
+
* @param {{ data?: any, params?: Record<string, any> }} [request]
|
|
67
|
+
*/
|
|
68
|
+
async function send(type, verb, args, { data, params } = {}) {
|
|
69
|
+
const { method, url } = requestFor(type, verb, args);
|
|
70
|
+
try {
|
|
71
|
+
const res = await api.request({
|
|
72
|
+
method,
|
|
73
|
+
url,
|
|
74
|
+
data,
|
|
75
|
+
params,
|
|
76
|
+
// Repeated keys (`state=1&state=2`), as the server expects them.
|
|
77
|
+
paramsSerializer: { indexes: null },
|
|
78
|
+
});
|
|
79
|
+
return res.data;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw apiError(e, { notFound: `Not found: ${label(type, args)}` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Lists kept for a while per profile and request (`listCache` of the
|
|
87
|
+
* catalog): the plugin catalog is a proxy of a remote one, slow and long,
|
|
88
|
+
* and is searched many times in a row.
|
|
89
|
+
* @type {Map<string, { at: number, data: any }>}
|
|
90
|
+
*/
|
|
91
|
+
const listCache = new Map();
|
|
92
|
+
|
|
93
|
+
/** `send` of a list, cached when the type's `listCache` matches the query. */
|
|
94
|
+
async function sendList(type, args, params) {
|
|
95
|
+
const rule = CATALOG[type].listCache;
|
|
96
|
+
const cached =
|
|
97
|
+
rule && Object.entries(rule.params).every(([k, v]) => String(params[k]) === String(v));
|
|
98
|
+
if (!cached) return send(type, 'list', args, { params });
|
|
99
|
+
// Answered whole: paging is done here, so it is not part of the key.
|
|
100
|
+
const { count: _count, index: _index, ...query } = params;
|
|
101
|
+
const key = JSON.stringify([getActiveProfile(), requestFor(type, 'list', args).url, query]);
|
|
102
|
+
const hit = listCache.get(key);
|
|
103
|
+
if (hit && Date.now() - hit.at < rule.minutes * 60_000) return hit.data;
|
|
104
|
+
const data = await send(type, 'list', args, { params: query });
|
|
105
|
+
listCache.set(key, { at: Date.now(), data });
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The given dot paths of a record, for lists whose records are too big. */
|
|
110
|
+
export function pick(record, fields) {
|
|
111
|
+
if (!record || typeof record !== 'object') return record;
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const field of fields) {
|
|
114
|
+
const keys = field.split('.');
|
|
115
|
+
const value = keys.reduce(
|
|
116
|
+
(v, k) => (v && typeof v === 'object' ? v[k] : undefined),
|
|
117
|
+
record,
|
|
118
|
+
);
|
|
119
|
+
if (value === undefined) continue;
|
|
120
|
+
let node = out;
|
|
121
|
+
keys.slice(0, -1).forEach((k) => (node = node[k] ??= {}));
|
|
122
|
+
node[keys[keys.length - 1]] = value;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One record per line: lists stay readable without paying for indentation. */
|
|
128
|
+
function formatList(type, data, pageSize) {
|
|
129
|
+
if (!Array.isArray(data)) return JSON.stringify(data, null, 2);
|
|
130
|
+
if (!data.length) return `No ${type} found.`;
|
|
131
|
+
const lines = data.map((item) => JSON.stringify(item));
|
|
132
|
+
const full =
|
|
133
|
+
data.length === pageSize
|
|
134
|
+
? `\n(page full: there may be more; pass index: ${data.length} for the next page)`
|
|
135
|
+
: '';
|
|
136
|
+
return `${data.length} ${type}:\n${lines.join('\n')}${full}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Request body schema and examples of an operation, `$ref`s resolved. */
|
|
140
|
+
function bodyOf(doc, operation) {
|
|
141
|
+
const content = operation?.requestBody?.content?.['application/json'];
|
|
142
|
+
if (!content) return undefined;
|
|
143
|
+
const out = {};
|
|
144
|
+
if (content.schema) out.schema = resolveRefs(doc, content.schema);
|
|
145
|
+
const examples = Object.entries(content.examples || {})
|
|
146
|
+
.filter(([name]) => !FIELD_EXAMPLE.test(name))
|
|
147
|
+
.slice(0, 4);
|
|
148
|
+
if (examples.length) {
|
|
149
|
+
out.examples = Object.fromEntries(examples.map(([name, ex]) => [name, ex.value]));
|
|
150
|
+
} else if (content.example !== undefined) {
|
|
151
|
+
out.examples = { example: content.example };
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** First record of the documented response, to show the shape of the data. */
|
|
157
|
+
/**
|
|
158
|
+
* The description with each request schema written once. Profile entries,
|
|
159
|
+
* for one, take the same 16 KB schema to create, to update and, as the
|
|
160
|
+
* values of a map, to replace the whole category: the copies become a
|
|
161
|
+
* pointer to the first. Actions move after the schemas they refer to.
|
|
162
|
+
*
|
|
163
|
+
* @param {Record<string, any>} out
|
|
164
|
+
*/
|
|
165
|
+
export function shareSchemas(out) {
|
|
166
|
+
const seen = new Map();
|
|
167
|
+
const visit = (node) => {
|
|
168
|
+
if (!node || typeof node !== 'object') return node;
|
|
169
|
+
const json = JSON.stringify(node);
|
|
170
|
+
if (json.length > 400 && seen.has(json)) return `(same as the ${seen.get(json)} schema)`;
|
|
171
|
+
return Array.isArray(node)
|
|
172
|
+
? node.map(visit)
|
|
173
|
+
: Object.fromEntries(Object.entries(node).map(([k, v]) => [k, visit(v)]));
|
|
174
|
+
};
|
|
175
|
+
const take = (body, label) => {
|
|
176
|
+
if (!body?.schema) return body;
|
|
177
|
+
const shared = { ...body, schema: visit(body.schema) };
|
|
178
|
+
const json = JSON.stringify(body.schema);
|
|
179
|
+
if (!seen.has(json)) seen.set(json, label);
|
|
180
|
+
return shared;
|
|
181
|
+
};
|
|
182
|
+
const { actions, ...rest } = out;
|
|
183
|
+
for (const verb of ['create', 'update', 'clone']) {
|
|
184
|
+
if (rest[verb]) rest[verb] = take(rest[verb], verb);
|
|
185
|
+
}
|
|
186
|
+
if (!actions) return rest;
|
|
187
|
+
const shared = Object.fromEntries(
|
|
188
|
+
Object.entries(actions).map(([a, spec]) => [
|
|
189
|
+
a,
|
|
190
|
+
spec.body ? { ...spec, body: take(spec.body, `${a} body`) } : spec,
|
|
191
|
+
]),
|
|
192
|
+
);
|
|
193
|
+
const { schema_source, record_example, permissions, ...head } = rest;
|
|
194
|
+
return Object.fromEntries(
|
|
195
|
+
Object.entries({
|
|
196
|
+
...head,
|
|
197
|
+
actions: shared,
|
|
198
|
+
permissions,
|
|
199
|
+
record_example,
|
|
200
|
+
schema_source,
|
|
201
|
+
}).filter(([, value]) => value !== undefined),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function responseExample(operation) {
|
|
206
|
+
const ok = operation?.responses?.['200']?.content?.['application/json'];
|
|
207
|
+
const example = ok?.example ?? Object.values(ok?.examples || {})[0]?.value;
|
|
208
|
+
return Array.isArray(example) ? example[0] : example;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Which tool runs each verb of a type. */
|
|
212
|
+
function operationsOf(type) {
|
|
213
|
+
const tools = {
|
|
214
|
+
list: toolName('resource_list'),
|
|
215
|
+
get: toolName('resource_get'),
|
|
216
|
+
create: toolName('resource_create'),
|
|
217
|
+
update: toolName('resource_update'),
|
|
218
|
+
patch: `${toolName('resource_update')} with merge: true`,
|
|
219
|
+
delete: toolName('resource_delete'),
|
|
220
|
+
clone: toolName('resource_clone'),
|
|
221
|
+
projects: `${toolName('resource_update')} with {projects: [...]}`,
|
|
222
|
+
};
|
|
223
|
+
return Object.fromEntries(Object.keys(type.ops).map((verb) => [verb, tools[verb]]));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function catalogSummary(names) {
|
|
227
|
+
const lines = names.map((name) => {
|
|
228
|
+
const t = CATALOG[name];
|
|
229
|
+
const parent = t.parent ? ` (parent ${parentHint(name)})` : '';
|
|
230
|
+
const id = idNames(t).length ? '' : ' (single, no id)';
|
|
231
|
+
const actions = Object.keys(t.actions || {});
|
|
232
|
+
const ops =
|
|
233
|
+
Object.keys(t.ops).join(' ') + (actions.length ? ` | ${actions.join(' ')}` : '');
|
|
234
|
+
return `${name}${parent}${id}: ${expandNotes(t.description)} [${ops}]`;
|
|
235
|
+
});
|
|
236
|
+
return `Resource types, with their operations and, after |, their actions (run with ${toolName('resource_call')}). ${toolName('resource_describe')} with a type gives its schemas and examples.\n\n${lines.join('\n')}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The tools, for the types this server mode exposes.
|
|
241
|
+
*
|
|
242
|
+
* @param {{ hooks?: Record<string, import('./resource-hooks.js').ResourceHooks> }} [opts]
|
|
243
|
+
*/
|
|
244
|
+
export function resourceTools({ hooks: extraHooks } = {}) {
|
|
245
|
+
const hooks = mergeHooks(extraHooks);
|
|
246
|
+
const served = new Set(typeNames());
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate the type for a verb and build the hook context.
|
|
250
|
+
*
|
|
251
|
+
* @param {Record<string, any>} args
|
|
252
|
+
* @param {import('./catalog.js').Verb} verb
|
|
253
|
+
*/
|
|
254
|
+
function prepare(args, verb) {
|
|
255
|
+
const type = args.type;
|
|
256
|
+
if (!type || !served.has(type)) {
|
|
257
|
+
throw inputError(
|
|
258
|
+
`Unknown type "${type ?? ''}". ${toolName('resource_describe')} lists them.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (!(verb in CATALOG[type].ops)) {
|
|
262
|
+
const allowed = Object.keys(CATALOG[type].ops).join(', ');
|
|
263
|
+
throw inputError(`Type "${type}" does not support ${verb} (it supports: ${allowed})`);
|
|
264
|
+
}
|
|
265
|
+
const ctx = {
|
|
266
|
+
type,
|
|
267
|
+
parent: args.parent,
|
|
268
|
+
id: args.id,
|
|
269
|
+
user: args.user,
|
|
270
|
+
full: args.full === true,
|
|
271
|
+
state: {},
|
|
272
|
+
};
|
|
273
|
+
return { type, ctx, hook: hooks[type] || {} };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function bodyArg(args, verb) {
|
|
277
|
+
const body = args.body ?? {};
|
|
278
|
+
if (typeof body !== 'object' || body === null) {
|
|
279
|
+
throw inputError('body must be a JSON object (or array where the type takes one)');
|
|
280
|
+
}
|
|
281
|
+
if (BODY_VERBS.has(verb) && verb !== 'create' && !Object.keys(body).length) {
|
|
282
|
+
throw inputError('body is required');
|
|
283
|
+
}
|
|
284
|
+
return body;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function toolDescribe(args) {
|
|
288
|
+
if (!args.type) {
|
|
289
|
+
// The overview call: the types, plus the server's guide to its
|
|
290
|
+
// concepts when it publishes one (older servers do not).
|
|
291
|
+
const concepts = await readDoc('concepts').then(
|
|
292
|
+
(d) => d.text,
|
|
293
|
+
() => '',
|
|
294
|
+
);
|
|
295
|
+
return text(
|
|
296
|
+
concepts
|
|
297
|
+
? `${catalogSummary([...served])}\n\n---\n\n${concepts}`
|
|
298
|
+
: catalogSummary([...served]),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (!served.has(args.type)) {
|
|
302
|
+
throw inputError(`Unknown type "${args.type}".\n\n${catalogSummary([...served])}`);
|
|
303
|
+
}
|
|
304
|
+
const name = args.type;
|
|
305
|
+
const type = CATALOG[name];
|
|
306
|
+
const out = { type: name, description: expandNotes(type.description) };
|
|
307
|
+
const chain = ancestry(name);
|
|
308
|
+
if (chain.length) out.parent = `${chain.join(' > ')} (write it as "${parentHint(name)}")`;
|
|
309
|
+
out.id = idNames(type).length ? idHint(name) : 'none: a single resource of its parent';
|
|
310
|
+
out.operations = operationsOf(type);
|
|
311
|
+
if (type.actions) {
|
|
312
|
+
out.actions = Object.fromEntries(
|
|
313
|
+
Object.entries(type.actions).map(([a, spec]) => [
|
|
314
|
+
a,
|
|
315
|
+
{
|
|
316
|
+
run: `${toolName('resource_call')} action "${a}"`,
|
|
317
|
+
does: expandNotes(spec.description),
|
|
318
|
+
},
|
|
319
|
+
]),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (type.notes) out.notes = expandNotes(type.notes);
|
|
323
|
+
if (type.sdk) {
|
|
324
|
+
out.sdk = await readDoc('dashboard-sdk').then(
|
|
325
|
+
(d) => d.text,
|
|
326
|
+
(e) => `unavailable: ${e.message}`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const spec = await serverSpec();
|
|
331
|
+
if (spec.doc) {
|
|
332
|
+
for (const verb of /** @type {const} */ (['create', 'update', 'clone'])) {
|
|
333
|
+
if (!(verb in type.ops)) continue;
|
|
334
|
+
const { method, path } = templateFor(name, verb);
|
|
335
|
+
const body = bodyOf(spec.doc, findOperation(spec.doc, method, path)?.operation);
|
|
336
|
+
if (body) out[verb] = body;
|
|
337
|
+
}
|
|
338
|
+
// What a token needs to allow each verb: the server names the
|
|
339
|
+
// action in each operation's summary, its ResourceType in the
|
|
340
|
+
// operationId prefix (Device_ReadStatus → Device:ReadDeviceStatus).
|
|
341
|
+
const actions = {};
|
|
342
|
+
let resourceType;
|
|
343
|
+
for (const verb of Object.keys(type.ops)) {
|
|
344
|
+
const { method, path } = templateFor(name, /** @type {any} */ (verb));
|
|
345
|
+
const op = findOperation(spec.doc, method, path)?.operation;
|
|
346
|
+
if (!op?.summary) continue;
|
|
347
|
+
resourceType ??= String(op.operationId || '').split('_')[0];
|
|
348
|
+
actions[verb] = op.summary;
|
|
349
|
+
}
|
|
350
|
+
for (const [a] of Object.entries(type.actions || {})) {
|
|
351
|
+
const { method, path } = actionTemplate(name, a);
|
|
352
|
+
const op = findOperation(spec.doc, method, path)?.operation;
|
|
353
|
+
if (op?.summary) {
|
|
354
|
+
resourceType ??= String(op.operationId || '').split('_')[0];
|
|
355
|
+
actions[a] = op.summary;
|
|
356
|
+
}
|
|
357
|
+
const body = bodyOf(spec.doc, op);
|
|
358
|
+
if (body && body.schema && JSON.stringify(body.schema) !== '{"type":"object"}') {
|
|
359
|
+
out.actions[a].body = body;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (resourceType) out.permissions = { resource_type: resourceType, actions };
|
|
363
|
+
const read = 'get' in type.ops ? 'get' : /** @type {const} */ ('list');
|
|
364
|
+
if (read in type.ops) {
|
|
365
|
+
const { method, path } = templateFor(name, read);
|
|
366
|
+
const example = responseExample(findOperation(spec.doc, method, path)?.operation);
|
|
367
|
+
if (example !== undefined) out.record_example = example;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
out.schema_source = spec.source;
|
|
371
|
+
// One line per key, schemas without indentation: half the size of
|
|
372
|
+
// pretty-printed JSON, and still easy to read.
|
|
373
|
+
return text(
|
|
374
|
+
Object.entries(shareSchemas(out))
|
|
375
|
+
.map(
|
|
376
|
+
([key, value]) =>
|
|
377
|
+
`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
|
|
378
|
+
)
|
|
379
|
+
.join('\n'),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function toolList(args) {
|
|
384
|
+
const { type } = prepare(args, 'list');
|
|
385
|
+
const params = { ...(CATALOG[type].listQuery || {}), ...(args.filter || {}) };
|
|
386
|
+
// Search: the server matches `name` against ids and names (partial,
|
|
387
|
+
// case-insensitive, as a regular expression: escape it). Lists the
|
|
388
|
+
// server answers whole are searched here instead, over every field.
|
|
389
|
+
const whole = CATALOG[type].listWhole;
|
|
390
|
+
if (args.search && !whole && params.name === undefined) {
|
|
391
|
+
params.name = String(args.search).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
392
|
+
}
|
|
393
|
+
if (args.count !== undefined) params.count = args.count;
|
|
394
|
+
if (args.index !== undefined) params.index = args.index;
|
|
395
|
+
let data = await sendList(type, args, params);
|
|
396
|
+
const fields = CATALOG[type].listFields;
|
|
397
|
+
if (fields && Array.isArray(data)) data = data.map((record) => pick(record, fields));
|
|
398
|
+
// Lists answered whole: every word must appear in what a record shows.
|
|
399
|
+
if (args.search && whole && Array.isArray(data)) {
|
|
400
|
+
const words = String(args.search).toLowerCase().split(/\s+/).filter(Boolean);
|
|
401
|
+
data = data.filter((record) => {
|
|
402
|
+
const haystack = JSON.stringify(record).toLowerCase();
|
|
403
|
+
return words.every((w) => haystack.includes(w));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Some collections ignore count/index and answer everything: page
|
|
407
|
+
// them here so one call cannot flood the conversation.
|
|
408
|
+
const pageSize = args.count ?? 50;
|
|
409
|
+
if (Array.isArray(data) && data.length > pageSize) {
|
|
410
|
+
const from = args.index ?? 0;
|
|
411
|
+
const page = data.slice(from, from + pageSize);
|
|
412
|
+
const next =
|
|
413
|
+
from + page.length < data.length
|
|
414
|
+
? `; pass index: ${from + page.length} for more, or narrow it with filter`
|
|
415
|
+
: '';
|
|
416
|
+
return text(
|
|
417
|
+
`${formatList(type, page, Infinity)}\n(${data.length} in total, showing ${from}–${from + page.length - 1}${next})`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return text(formatList(type, data, whole ? Infinity : pageSize));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function toolGet(args) {
|
|
424
|
+
const { type, ctx, hook } = prepare(args, 'get');
|
|
425
|
+
let record = await send(type, 'get', args);
|
|
426
|
+
if (hook.afterGet) record = await hook.afterGet(record, ctx);
|
|
427
|
+
return text(JSON.stringify(record, null, 2));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function toolCreate(args) {
|
|
431
|
+
const { type, ctx, hook } = prepare(args, 'create');
|
|
432
|
+
const t = CATALOG[type];
|
|
433
|
+
let body = bodyArg(args, 'create');
|
|
434
|
+
// `projects` is not a field the server takes on create: set it right
|
|
435
|
+
// after, as update does, so one call leaves the resource in place.
|
|
436
|
+
let projects;
|
|
437
|
+
if (body && typeof body === 'object' && !Array.isArray(body) && 'projects' in body) {
|
|
438
|
+
({ projects, ...body } = body);
|
|
439
|
+
if (!Array.isArray(projects)) {
|
|
440
|
+
throw inputError('projects must be an array of "<owner>@<project>"');
|
|
441
|
+
}
|
|
442
|
+
if (!('projects' in t.ops)) throw inputError(`${type} cannot belong to projects`);
|
|
443
|
+
}
|
|
444
|
+
// The id can come apart from the body: copy it into the body's id
|
|
445
|
+
// fields when the server takes it there (POST), unless already set.
|
|
446
|
+
if (!t.upsert && args.id !== undefined && !Array.isArray(body)) {
|
|
447
|
+
const fields = t.bodyId ?? idNames(t);
|
|
448
|
+
if (!fields.length) throw inputError(`${type} ids are generated by the server`);
|
|
449
|
+
const ids = String(args.id).split('/');
|
|
450
|
+
if (ids.length !== fields.length) throw inputError(`id must be ${idHint(type)}`);
|
|
451
|
+
body = { ...Object.fromEntries(fields.map((f, i) => [f, ids[i]])), ...body };
|
|
452
|
+
}
|
|
453
|
+
if (hook.beforeCreate) body = await hook.beforeCreate(body, ctx);
|
|
454
|
+
const createdId = args.id ?? bodyIdOf(t, body);
|
|
455
|
+
if (projects && createdId === undefined) {
|
|
456
|
+
const where = (t.bodyId ?? idNames(t)).map((f) => `"${f}"`).join(' and ');
|
|
457
|
+
throw inputError(
|
|
458
|
+
`give the ${type} id to set its projects on create: \`id\`, or the body field ${where}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const result = await send(type, 'create', args, { data: body });
|
|
462
|
+
const lines = [`Created ${label(type, { parent: args.parent, id: createdId })}.`];
|
|
463
|
+
if (projects) {
|
|
464
|
+
await send(type, 'projects', { ...args, id: createdId }, { data: projects });
|
|
465
|
+
lines[0] = lines[0].replace(/\.$/, ` in projects ${JSON.stringify(projects)}.`);
|
|
466
|
+
}
|
|
467
|
+
if (result && typeof result === 'object' && Object.keys(result).length) {
|
|
468
|
+
lines.push(JSON.stringify(result, null, 2));
|
|
469
|
+
}
|
|
470
|
+
const note = hook.afterCreate && (await hook.afterCreate(result, ctx));
|
|
471
|
+
if (note) lines.push('', note);
|
|
472
|
+
return text(lines.join('\n'));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function toolUpdate(args) {
|
|
476
|
+
/** @type {import('./catalog.js').Verb} */
|
|
477
|
+
const verb = args.merge ? 'patch' : 'update';
|
|
478
|
+
const { projects, ...fields } = bodyArg(args, 'update');
|
|
479
|
+
const { type, ctx, hook } = prepare(args, projects !== undefined ? 'projects' : verb);
|
|
480
|
+
const done = [];
|
|
481
|
+
if (projects !== undefined) {
|
|
482
|
+
if (!Array.isArray(projects)) {
|
|
483
|
+
throw inputError('projects must be an array of "<owner>@<project>"');
|
|
484
|
+
}
|
|
485
|
+
await send(type, 'projects', args, { data: projects });
|
|
486
|
+
done.push(`projects set to ${JSON.stringify(projects)}`);
|
|
487
|
+
}
|
|
488
|
+
if (Object.keys(fields).length) {
|
|
489
|
+
prepare(args, verb);
|
|
490
|
+
let body = fields;
|
|
491
|
+
// Profile entries are written whole (the same PUT creates them):
|
|
492
|
+
// keep the fields left out by sending them from the saved entry.
|
|
493
|
+
const t = CATALOG[type];
|
|
494
|
+
if (verb === 'update' && t.upsert && t.ops.get !== t.ops.list) {
|
|
495
|
+
const saved = await send(type, 'get', args);
|
|
496
|
+
if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
497
|
+
body = { ...saved, ...fields };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (hook.beforeUpdate) body = await hook.beforeUpdate(body, ctx);
|
|
501
|
+
await send(type, verb, args, { data: body });
|
|
502
|
+
done.push(`${args.merge ? 'merged' : 'updated'} ${Object.keys(fields).join(', ')}`);
|
|
503
|
+
}
|
|
504
|
+
return text(`Updated ${label(type, args)}: ${done.join('; ')}.`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function toolDelete(args) {
|
|
508
|
+
const { type, ctx, hook } = prepare(args, 'delete');
|
|
509
|
+
await send(type, 'delete', args);
|
|
510
|
+
const lines = [`Deleted ${label(type, args)}.`];
|
|
511
|
+
const note = hook.afterDelete && (await hook.afterDelete(ctx));
|
|
512
|
+
if (note) lines.push(note);
|
|
513
|
+
return text(lines.join('\n'));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function toolClone(args) {
|
|
517
|
+
const { type } = prepare(args, 'clone');
|
|
518
|
+
const body = bodyArg(args, 'clone');
|
|
519
|
+
await send(type, 'clone', args, { data: body });
|
|
520
|
+
const copy = bodyIdOf(CATALOG[type], body);
|
|
521
|
+
return text(
|
|
522
|
+
`Cloned ${label(type, args)} as ${copy ? label(type, { parent: args.parent, id: copy }) : JSON.stringify(body)}.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A streamed answer of progress lines ({"progress": N} per line, as a
|
|
528
|
+
* bucket import sends) shortened to its last line: the rest only fills
|
|
529
|
+
* the context. Anything else is left as is (undefined).
|
|
530
|
+
*/
|
|
531
|
+
const lastProgress = (body, ms) => {
|
|
532
|
+
const lines = body.split('\n').filter((l) => l.trim());
|
|
533
|
+
if (lines.length < 2) return undefined;
|
|
534
|
+
let parsed;
|
|
535
|
+
try {
|
|
536
|
+
parsed = lines.map((l) => JSON.parse(l));
|
|
537
|
+
} catch {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
if (!parsed.every((p) => p && typeof p === 'object' && 'progress' in p)) return undefined;
|
|
541
|
+
return `${lines[lines.length - 1]}\n(${lines.length} progress updates, ${(ms / 1000).toFixed(1)} s)`;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
async function toolCall(args) {
|
|
545
|
+
const type = args.type;
|
|
546
|
+
if (!type || !served.has(type) || !CATALOG[type].actions) {
|
|
547
|
+
throw inputError(
|
|
548
|
+
`Type "${type ?? ''}" has no actions. ${toolName('resource_describe')} lists them after |.`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const { method, url, query } = actionRequest(type, args.action, args);
|
|
552
|
+
const spec = CATALOG[type].actions?.[args.action];
|
|
553
|
+
const started = Date.now();
|
|
554
|
+
try {
|
|
555
|
+
// Actions that evaluate a resource (test_rule) take the saved one
|
|
556
|
+
// when no body is given.
|
|
557
|
+
const body =
|
|
558
|
+
args.body === undefined && spec?.bodyFromRecord
|
|
559
|
+
? pick(await send(type, 'get', args), spec.bodyFromRecord)
|
|
560
|
+
: args.body;
|
|
561
|
+
const res = await api.request({
|
|
562
|
+
method,
|
|
563
|
+
url,
|
|
564
|
+
data: method === 'get' ? undefined : (body ?? {}),
|
|
565
|
+
params: { ...(args.query || {}), ...(query || {}) },
|
|
566
|
+
paramsSerializer: { indexes: null },
|
|
567
|
+
timeout: TIMEOUTS.API_LONG_MS,
|
|
568
|
+
});
|
|
569
|
+
const data = res.data;
|
|
570
|
+
if (data === undefined || data === null || data === '') {
|
|
571
|
+
return text(`Ran ${args.action} on ${label(type, args)}.`);
|
|
572
|
+
}
|
|
573
|
+
if (typeof data !== 'string') return text(JSON.stringify(data, null, 2));
|
|
574
|
+
return text(lastProgress(data, Date.now() - started) ?? data);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
const err = apiError(e, { notFound: `Not found: ${label(type, args)}` });
|
|
577
|
+
// A 400 on an action called without a body is nearly always the
|
|
578
|
+
// missing body: say what it takes instead of leaving the guess.
|
|
579
|
+
if (e?.response?.status === 400 && args.body === undefined && method !== 'get') {
|
|
580
|
+
err.message += `. ${args.action} takes a body: ${expandNotes(spec?.description || '')} (${toolName('resource_describe')} ${type} shows its schema)`;
|
|
581
|
+
}
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const typeParam = (verb) => ({
|
|
587
|
+
type: 'string',
|
|
588
|
+
enum: typeNames({ verb }),
|
|
589
|
+
description: 'Resource type (see {tool:resource_describe}).',
|
|
590
|
+
});
|
|
591
|
+
const common = {
|
|
592
|
+
parent: {
|
|
593
|
+
type: 'string',
|
|
594
|
+
description:
|
|
595
|
+
'Parent id, for child types: the device of a device_property, "<device>/<bucket>" for a device_bucket_tag.',
|
|
596
|
+
},
|
|
597
|
+
user: {
|
|
598
|
+
type: 'string',
|
|
599
|
+
description: 'Account to act on (admin only). Omit for your own.',
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
const id = { type: 'string', description: 'Resource id.' };
|
|
603
|
+
const body = (description) => ({ type: 'object', description });
|
|
604
|
+
|
|
605
|
+
return [
|
|
606
|
+
{
|
|
607
|
+
name: 'resource_describe',
|
|
608
|
+
description:
|
|
609
|
+
"Describe a resource type: its parent and id, the operations it supports and, from this server's API, the request schemas and examples for create, update and clone. Call it before writing a type for the first time. Without `type`: all types and a guide to the platform's concepts (start here).",
|
|
610
|
+
inputSchema: {
|
|
611
|
+
type: 'object',
|
|
612
|
+
properties: { type: { ...typeParam(), enum: [...served] } },
|
|
613
|
+
required: [],
|
|
614
|
+
},
|
|
615
|
+
handler: toolDescribe,
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: 'resource_list',
|
|
619
|
+
description:
|
|
620
|
+
'List resources of a type, one JSON record per line, in pages of 50 (`count`, `index`). `search` finds by id or name. `filter` narrows by product, type, asset_type, asset_group, project (its short id, "plant_a", not "<owner>@plant_a") or labels (key present), e.g. {"product": "boiler"}; alarm instances also take state and severity. A field of the listed records filters by exact value, nested ones with dots ({"alarm.rule": "r1"}); an array matches any of its values.',
|
|
621
|
+
inputSchema: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
properties: {
|
|
624
|
+
type: typeParam('list'),
|
|
625
|
+
parent: common.parent,
|
|
626
|
+
filter: { type: 'object', description: 'Query filters (see above).' },
|
|
627
|
+
search: {
|
|
628
|
+
type: 'string',
|
|
629
|
+
description:
|
|
630
|
+
'Text to look for in ids and names (partial, any case; descriptions are not searched). On the plugin catalog, words matched against every field shown.',
|
|
631
|
+
},
|
|
632
|
+
count: { type: 'number', description: 'Page size (default 50).' },
|
|
633
|
+
index: { type: 'number', description: 'Offset of the first record.' },
|
|
634
|
+
user: common.user,
|
|
635
|
+
},
|
|
636
|
+
required: ['type'],
|
|
637
|
+
},
|
|
638
|
+
handler: toolList,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: 'resource_get',
|
|
642
|
+
description:
|
|
643
|
+
'Read one resource as JSON. Single resources (device_callback, product_profile_code) take only `parent`.',
|
|
644
|
+
inputSchema: {
|
|
645
|
+
type: 'object',
|
|
646
|
+
properties: {
|
|
647
|
+
type: typeParam('get'),
|
|
648
|
+
parent: common.parent,
|
|
649
|
+
id,
|
|
650
|
+
full: {
|
|
651
|
+
type: 'boolean',
|
|
652
|
+
description:
|
|
653
|
+
'Plugin catalog manifests only: also include the product code and the console dashboard (large).',
|
|
654
|
+
},
|
|
655
|
+
user: common.user,
|
|
656
|
+
},
|
|
657
|
+
required: ['type'],
|
|
658
|
+
},
|
|
659
|
+
handler: toolGet,
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: 'resource_create',
|
|
663
|
+
description:
|
|
664
|
+
'Create a resource from `body`, shaped as {tool:resource_describe} shows. `id` goes into the body\'s id field when the body lacks it; product_profile_* and product_service entries are stored under `id`, replacing any entry of that name. `projects` in the body (["<owner>@<project>"]) also puts the new resource in those projects.',
|
|
665
|
+
inputSchema: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: {
|
|
668
|
+
type: typeParam('create'),
|
|
669
|
+
parent: common.parent,
|
|
670
|
+
id,
|
|
671
|
+
body: {
|
|
672
|
+
type: ['object', 'array'],
|
|
673
|
+
description:
|
|
674
|
+
'Fields of the new resource; an array of them creates several at once where the type allows it (properties).',
|
|
675
|
+
},
|
|
676
|
+
user: common.user,
|
|
677
|
+
},
|
|
678
|
+
required: ['type'],
|
|
679
|
+
},
|
|
680
|
+
handler: toolCreate,
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: 'resource_update',
|
|
684
|
+
description:
|
|
685
|
+
'Update a resource with the fields in `body`; fields left out are kept, except for property values, which are replaced unless `merge` is true. `projects` in the body (["<owner>@<project>"]) sets the projects the resource belongs to.',
|
|
686
|
+
inputSchema: {
|
|
687
|
+
type: 'object',
|
|
688
|
+
properties: {
|
|
689
|
+
type: typeParam('update'),
|
|
690
|
+
parent: common.parent,
|
|
691
|
+
id,
|
|
692
|
+
body: body('Fields to change.'),
|
|
693
|
+
merge: {
|
|
694
|
+
type: 'boolean',
|
|
695
|
+
description:
|
|
696
|
+
'Patch (JSON merge) instead of replacing: property types and brands.',
|
|
697
|
+
},
|
|
698
|
+
user: common.user,
|
|
699
|
+
},
|
|
700
|
+
required: ['type', 'body'],
|
|
701
|
+
},
|
|
702
|
+
handler: toolUpdate,
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: 'resource_delete',
|
|
706
|
+
description: 'Delete a resource. Immediate and permanent: confirm with the user first.',
|
|
707
|
+
inputSchema: {
|
|
708
|
+
type: 'object',
|
|
709
|
+
properties: {
|
|
710
|
+
type: typeParam('delete'),
|
|
711
|
+
parent: common.parent,
|
|
712
|
+
id,
|
|
713
|
+
user: common.user,
|
|
714
|
+
},
|
|
715
|
+
required: ['type'],
|
|
716
|
+
},
|
|
717
|
+
handler: toolDelete,
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
name: 'resource_clone',
|
|
721
|
+
description:
|
|
722
|
+
'Copy a resource under a new id. `body` holds the new id in the type\'s id field (e.g. {"device": "copy_1"}) and optionally name, description and enabled.',
|
|
723
|
+
inputSchema: {
|
|
724
|
+
type: 'object',
|
|
725
|
+
properties: {
|
|
726
|
+
type: typeParam('clone'),
|
|
727
|
+
parent: common.parent,
|
|
728
|
+
id,
|
|
729
|
+
body: body('New id and overrides.'),
|
|
730
|
+
user: common.user,
|
|
731
|
+
},
|
|
732
|
+
required: ['type', 'id', 'body'],
|
|
733
|
+
},
|
|
734
|
+
handler: toolClone,
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: 'resource_call',
|
|
738
|
+
description:
|
|
739
|
+
"Run an action of a resource that is not create/read/update/delete: verify a domain, restart a host, call an endpoint, export a product, test an alarm rule… {tool:resource_describe} lists each type's actions (after |) and their bodies.",
|
|
740
|
+
inputSchema: {
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties: {
|
|
743
|
+
type: {
|
|
744
|
+
type: 'string',
|
|
745
|
+
enum: Object.keys(CATALOG).filter((n) => CATALOG[n].actions),
|
|
746
|
+
description: 'Resource type.',
|
|
747
|
+
},
|
|
748
|
+
action: { type: 'string', description: 'Action name, e.g. "verify".' },
|
|
749
|
+
parent: common.parent,
|
|
750
|
+
id,
|
|
751
|
+
body: { description: 'Action input, when it takes one.' },
|
|
752
|
+
query: { type: 'object', description: 'Query parameters, when it takes them.' },
|
|
753
|
+
user: common.user,
|
|
754
|
+
},
|
|
755
|
+
required: ['type', 'action'],
|
|
756
|
+
},
|
|
757
|
+
handler: toolCall,
|
|
758
|
+
},
|
|
759
|
+
];
|
|
760
|
+
}
|