@iobroker/mcp-server 1.7.0 → 1.8.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/README.md +41 -4
- package/build/inProcessClient.d.ts +1 -1
- package/build/inProcessClient.js +1 -0
- package/build/inProcessClient.js.map +1 -1
- package/build/index.d.ts +72 -2
- package/build/index.js +454 -21
- package/build/index.js.map +1 -1
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -40,6 +40,57 @@ const AGG_MAP = {
|
|
|
40
40
|
integral: 'integral',
|
|
41
41
|
};
|
|
42
42
|
const WEB_EXTENSION_PREFIX = 'mcp/';
|
|
43
|
+
/** Arguments of tool calls whose values are not written to the log, besides file contents (may hold secrets). */
|
|
44
|
+
const LOG_HIDDEN_ARGS = {
|
|
45
|
+
send_to: ['message'],
|
|
46
|
+
set_script: ['source'],
|
|
47
|
+
run_script: ['source'],
|
|
48
|
+
};
|
|
49
|
+
/** Values of tool arguments are cut after this many characters in the log. */
|
|
50
|
+
const LOG_ARG_LENGTH = 200;
|
|
51
|
+
const objectWrite = (rights) => !!rights.object?.write;
|
|
52
|
+
const fileWrite = (rights) => !!rights.file?.write;
|
|
53
|
+
const execute = (rights) => !!rights.other?.execute;
|
|
54
|
+
const sendTo = (rights) => !!rights.other?.sendto;
|
|
55
|
+
/**
|
|
56
|
+
* The general rights (of the groups of the ioBroker user) without which a tool cannot succeed: such tools are not
|
|
57
|
+
* offered to the sessions of the user. Tools not listed here are offered to everyone.
|
|
58
|
+
*/
|
|
59
|
+
const TOOL_RIGHTS = {
|
|
60
|
+
set_state: rights => !!rights.state?.write,
|
|
61
|
+
set_states: rights => !!rights.state?.write,
|
|
62
|
+
set_object: objectWrite,
|
|
63
|
+
create_state: objectWrite,
|
|
64
|
+
create_scene: objectWrite,
|
|
65
|
+
create_alias: objectWrite,
|
|
66
|
+
delete_object: rights => !!rights.object?.delete,
|
|
67
|
+
write_file: fileWrite,
|
|
68
|
+
rename_file: fileWrite,
|
|
69
|
+
mkdir: fileWrite,
|
|
70
|
+
delete_file: rights => !!rights.file?.delete,
|
|
71
|
+
start_instance: objectWrite,
|
|
72
|
+
stop_instance: objectWrite,
|
|
73
|
+
restart_instance: objectWrite,
|
|
74
|
+
// persistently in the instance object, or temporarily in its logLevel state
|
|
75
|
+
set_log_level: rights => objectWrite(rights) || !!rights.state?.write,
|
|
76
|
+
add_instance: execute,
|
|
77
|
+
delete_instance: execute,
|
|
78
|
+
upgrade_adapter: execute,
|
|
79
|
+
install_package: execute,
|
|
80
|
+
// for write_file or install_package
|
|
81
|
+
prepare_upload: rights => fileWrite(rights) || execute(rights),
|
|
82
|
+
set_script: objectWrite,
|
|
83
|
+
enable_script: objectWrite,
|
|
84
|
+
delete_script: rights => !!rights.object?.delete,
|
|
85
|
+
run_script: sendTo,
|
|
86
|
+
send_to: sendTo,
|
|
87
|
+
};
|
|
88
|
+
/** Default time `wait_for_state` waits (seconds). */
|
|
89
|
+
const DEFAULT_WAIT_TIMEOUT = 30;
|
|
90
|
+
/** Longest wait of `wait_for_state` and `set_state` (seconds): many MCP clients give up on a call after 60 s. */
|
|
91
|
+
const MAX_WAIT_TIMEOUT = 50;
|
|
92
|
+
/** How often a state is read while waiting for it (ms). */
|
|
93
|
+
const STATE_POLL_INTERVAL = 250;
|
|
43
94
|
const LOOPBACK = new node_net_1.default.BlockList();
|
|
44
95
|
LOOPBACK.addSubnet('127.0.0.0', 8, 'ipv4');
|
|
45
96
|
LOOPBACK.addAddress('::1', 'ipv6');
|
|
@@ -181,6 +232,8 @@ class McpServer {
|
|
|
181
232
|
subscriptionCounts = {};
|
|
182
233
|
/** Recent log lines kept for `ioblog://` resource reads (filled while there are log subscribers). */
|
|
183
234
|
logBuffer = [];
|
|
235
|
+
/** The tools of every server created by {@link createServer}, for {@link hideToolsWithoutRights}. */
|
|
236
|
+
registeredTools = new WeakMap();
|
|
184
237
|
config;
|
|
185
238
|
extension;
|
|
186
239
|
routerPrefix;
|
|
@@ -320,11 +373,50 @@ class McpServer {
|
|
|
320
373
|
* Build a fresh MCP SDK server with all ioBroker tools registered, ready to be connected to an
|
|
321
374
|
* arbitrary transport. Used for in-process embedding (e.g. by ioBroker.admin) over an
|
|
322
375
|
* InMemoryTransport, where there is no HTTP server/session layer. The caller owns the lifecycle
|
|
323
|
-
* of the returned server (connect it to a transport, and `close()` it when done).
|
|
376
|
+
* of the returned server (connect it to a transport, and `close()` it when done). Call
|
|
377
|
+
* {@link hideToolsWithoutRights} before connecting it to offer only the tools the user may use.
|
|
324
378
|
*/
|
|
325
379
|
createInProcessServer() {
|
|
326
380
|
return this.createServer(new Set());
|
|
327
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Do not offer the tools the user cannot use, because its groups lack a general right the tool needs (e.g.
|
|
384
|
+
* `install_package` without "execute"), so the client does not try them in vain. Administrators get all
|
|
385
|
+
* tools. The rights are read once, before the server is connected: changed rights apply to new sessions,
|
|
386
|
+
* and every call is checked anyway.
|
|
387
|
+
*
|
|
388
|
+
* @param server a server returned by {@link createInProcessServer}
|
|
389
|
+
* @param user the ioBroker user of the session (default: the configured default user)
|
|
390
|
+
* @returns the names of the hidden tools
|
|
391
|
+
*/
|
|
392
|
+
async hideToolsWithoutRights(server, user = this.defaultUser) {
|
|
393
|
+
const tools = this.registeredTools.get(server);
|
|
394
|
+
if (!tools || user === 'system.user.admin') {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
let rights;
|
|
398
|
+
try {
|
|
399
|
+
rights = await this.adapter.calculatePermissionsAsync(user, []);
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
this.adapter.log.warn(`Cannot read the rights of ${user}, all tools are offered: ${e instanceof Error ? e.message : e}`);
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
if (rights.groups?.includes('system.group.administrator')) {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
const hidden = [];
|
|
409
|
+
for (const [name, tool] of tools) {
|
|
410
|
+
if (TOOL_RIGHTS[name] && !TOOL_RIGHTS[name](rights)) {
|
|
411
|
+
tool.disable();
|
|
412
|
+
hidden.push(name);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (hidden.length) {
|
|
416
|
+
this.adapter.log.debug(`MCP tools not offered to ${user} for lack of rights: ${hidden.join(', ')}`);
|
|
417
|
+
}
|
|
418
|
+
return hidden;
|
|
419
|
+
}
|
|
328
420
|
/**
|
|
329
421
|
* Convenience factory for embedding the MCP server inside another adapter's process (no HTTP).
|
|
330
422
|
* The returned instance is not wired to any Express app; obtain a tool server via
|
|
@@ -733,6 +825,7 @@ class McpServer {
|
|
|
733
825
|
this.adapter.log.debug(`MCP session closed: ${sid}`);
|
|
734
826
|
}
|
|
735
827
|
};
|
|
828
|
+
await this.hideToolsWithoutRights(server, user);
|
|
736
829
|
await server.connect(transport);
|
|
737
830
|
}
|
|
738
831
|
const sessionTransport = transport;
|
|
@@ -894,6 +987,76 @@ class McpServer {
|
|
|
894
987
|
}
|
|
895
988
|
session.subscriptions.clear();
|
|
896
989
|
}
|
|
990
|
+
/**
|
|
991
|
+
* Remember every tool registered on `server` afterwards (for {@link hideToolsWithoutRights}) and log its
|
|
992
|
+
* calls: a failed call as a warning with the tool and its arguments, a successful call of a writing tool
|
|
993
|
+
* (not `readOnlyHint`) at info level, so the log shows who changed what.
|
|
994
|
+
*
|
|
995
|
+
* @param server the server whose `registerTool` is wrapped
|
|
996
|
+
*/
|
|
997
|
+
trackTools(server) {
|
|
998
|
+
const register = server.registerTool.bind(server);
|
|
999
|
+
const tools = new Map();
|
|
1000
|
+
this.registeredTools.set(server, tools);
|
|
1001
|
+
server.registerTool = (name, config, handler) => {
|
|
1002
|
+
const tool = register(name, config, async (...params) => {
|
|
1003
|
+
// Tools with an input schema get (args, extra), the others only (extra)
|
|
1004
|
+
const call = `${name}(${McpServer.describeArgs(name, params.length > 1 ? params[0] : undefined)})`;
|
|
1005
|
+
let result;
|
|
1006
|
+
try {
|
|
1007
|
+
result = await handler(...params);
|
|
1008
|
+
}
|
|
1009
|
+
catch (e) {
|
|
1010
|
+
this.adapter.log.warn(`MCP tool error in ${call} by ${this.user}: ${e instanceof Error ? e.message : e}`);
|
|
1011
|
+
throw e;
|
|
1012
|
+
}
|
|
1013
|
+
if (result?.isError) {
|
|
1014
|
+
let error = result.content?.[0]?.text;
|
|
1015
|
+
try {
|
|
1016
|
+
error = JSON.parse(error).error ?? error;
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
// not JSON: log the text as it is
|
|
1020
|
+
}
|
|
1021
|
+
this.adapter.log.warn(`MCP tool error in ${call} by ${this.user}: ${error}`);
|
|
1022
|
+
}
|
|
1023
|
+
else if (config?.annotations?.readOnlyHint === false) {
|
|
1024
|
+
this.adapter.log.info(`MCP ${this.user}: ${call}`);
|
|
1025
|
+
}
|
|
1026
|
+
return result;
|
|
1027
|
+
});
|
|
1028
|
+
tools.set(name, tool);
|
|
1029
|
+
return tool;
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* The arguments of a tool call for the log: long values are shortened, and contents that may hold secrets
|
|
1034
|
+
* or large data (file contents, script code, message payloads) are replaced by their size.
|
|
1035
|
+
*
|
|
1036
|
+
* @param tool name of the tool
|
|
1037
|
+
* @param args arguments of the call
|
|
1038
|
+
*/
|
|
1039
|
+
static describeArgs(tool, args) {
|
|
1040
|
+
if (!args || typeof args !== 'object') {
|
|
1041
|
+
return '';
|
|
1042
|
+
}
|
|
1043
|
+
const hidden = ['content', 'file_content', ...(LOG_HIDDEN_ARGS[tool] || [])];
|
|
1044
|
+
const parts = [];
|
|
1045
|
+
for (const [key, value] of Object.entries(args)) {
|
|
1046
|
+
if (value === undefined) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
let text = JSON.stringify(value) ?? String(value);
|
|
1050
|
+
if (hidden.includes(key)) {
|
|
1051
|
+
text = `<${text.length} chars>`;
|
|
1052
|
+
}
|
|
1053
|
+
else if (text.length > LOG_ARG_LENGTH) {
|
|
1054
|
+
text = `${text.slice(0, LOG_ARG_LENGTH)}...`;
|
|
1055
|
+
}
|
|
1056
|
+
parts.push(`${key}=${text}`);
|
|
1057
|
+
}
|
|
1058
|
+
return parts.join(', ');
|
|
1059
|
+
}
|
|
897
1060
|
/**
|
|
898
1061
|
* Create a new MCP SDK server with all ioBroker tools registered.
|
|
899
1062
|
*
|
|
@@ -902,9 +1065,10 @@ class McpServer {
|
|
|
902
1065
|
*/
|
|
903
1066
|
createServer(subscriptions, baseUrl) {
|
|
904
1067
|
const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: { subscribe: true } } });
|
|
1068
|
+
this.trackTools(server);
|
|
1069
|
+
// Logged by trackTools, together with the tool and its arguments
|
|
905
1070
|
const fail = (error) => {
|
|
906
1071
|
const message = error instanceof Error ? error.message : String(error);
|
|
907
|
-
this.adapter.log.warn(`MCP tool error: ${message}`);
|
|
908
1072
|
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
|
|
909
1073
|
};
|
|
910
1074
|
const canLink = this.linksAvailable && !!baseUrl;
|
|
@@ -1014,6 +1178,93 @@ class McpServer {
|
|
|
1014
1178
|
return fail(e);
|
|
1015
1179
|
}
|
|
1016
1180
|
});
|
|
1181
|
+
server.registerTool('wait_for_state', {
|
|
1182
|
+
title: 'Wait for a state value',
|
|
1183
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1184
|
+
description: 'Wait until a state has the given `value` - or, without `value`, until it is updated - and ' +
|
|
1185
|
+
'return the state, e.g. to check that a device really switched or a process finished. ' +
|
|
1186
|
+
`Gives up after \`timeout\` seconds (default ${DEFAULT_WAIT_TIMEOUT}) with reached=false. ` +
|
|
1187
|
+
`The state is read every ${STATE_POLL_INTERVAL} ms, so a value that lasts shorter can be missed.`,
|
|
1188
|
+
inputSchema: {
|
|
1189
|
+
id: zod_1.z.string().describe('State ID'),
|
|
1190
|
+
value: zod_1.z
|
|
1191
|
+
.any()
|
|
1192
|
+
.optional()
|
|
1193
|
+
.describe('Expected value (coerced to the state type); omit it to wait for the next update'),
|
|
1194
|
+
ack: zod_1.z
|
|
1195
|
+
.boolean()
|
|
1196
|
+
.optional()
|
|
1197
|
+
.describe('Only accept a state with this flag: true = confirmed by the device, false = command'),
|
|
1198
|
+
timeout: zod_1.z
|
|
1199
|
+
.number()
|
|
1200
|
+
.min(0)
|
|
1201
|
+
.max(MAX_WAIT_TIMEOUT)
|
|
1202
|
+
.optional()
|
|
1203
|
+
.describe(`Seconds to wait at most (default ${DEFAULT_WAIT_TIMEOUT})`),
|
|
1204
|
+
},
|
|
1205
|
+
}, async ({ id, value, ack, timeout }, extra) => {
|
|
1206
|
+
try {
|
|
1207
|
+
const seconds = timeout ?? DEFAULT_WAIT_TIMEOUT;
|
|
1208
|
+
const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
|
|
1209
|
+
const expected = McpServer.coerceValue(value, obj?.common?.type);
|
|
1210
|
+
const initial = await this.adapter.getForeignStateAsync(id, { user: this.user });
|
|
1211
|
+
const result = await this.waitForState(id, state => !!state &&
|
|
1212
|
+
(ack === undefined || state.ack === ack) &&
|
|
1213
|
+
(value === undefined
|
|
1214
|
+
? !initial || state.ts !== initial.ts
|
|
1215
|
+
: McpServer.sameValue(state.val, expected)), seconds, extra.signal);
|
|
1216
|
+
return ok({
|
|
1217
|
+
ok: true,
|
|
1218
|
+
data: {
|
|
1219
|
+
id,
|
|
1220
|
+
reached: result.reached,
|
|
1221
|
+
state: result.state ?? null,
|
|
1222
|
+
waited_ms: result.waited,
|
|
1223
|
+
...(result.reached
|
|
1224
|
+
? {}
|
|
1225
|
+
: {
|
|
1226
|
+
hint: `${value === undefined ? 'Not updated' : 'The value was not reached'} within ${seconds} s`,
|
|
1227
|
+
}),
|
|
1228
|
+
},
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
catch (e) {
|
|
1232
|
+
return fail(e);
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
server.registerTool('find_orphan_states', {
|
|
1236
|
+
title: 'Find states without object',
|
|
1237
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1238
|
+
description: 'Find states that have no object, e.g. "system.adapter.vis.0.alive" left over from a deleted ' +
|
|
1239
|
+
'instance. The other tools and admin do not show them, but scripts and visualizations may still ' +
|
|
1240
|
+
'read their old values. `pattern` selects the states to check, e.g. "system.adapter.*" or ' +
|
|
1241
|
+
'"hue.0.*"; "*" checks all states and objects, which takes a while on large installations. ' +
|
|
1242
|
+
'Found states can be deleted on the host with `iobroker state del <id>`.',
|
|
1243
|
+
inputSchema: {
|
|
1244
|
+
pattern: zod_1.z
|
|
1245
|
+
.string()
|
|
1246
|
+
.min(1)
|
|
1247
|
+
.describe('IDs of the states to check, wildcards "*" allowed, e.g. "system.adapter.*"'),
|
|
1248
|
+
offset: offsetSchema,
|
|
1249
|
+
},
|
|
1250
|
+
}, async ({ pattern, offset }) => {
|
|
1251
|
+
try {
|
|
1252
|
+
const { checked, orphans } = await this.findOrphanStates(pattern);
|
|
1253
|
+
const page = fitItems(orphans, offset);
|
|
1254
|
+
return ok({
|
|
1255
|
+
ok: true,
|
|
1256
|
+
data: {
|
|
1257
|
+
pattern,
|
|
1258
|
+
checked,
|
|
1259
|
+
orphans: page.items,
|
|
1260
|
+
...pageInfo(page, orphans, 'orphans.json'),
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
catch (e) {
|
|
1265
|
+
return fail(e);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1017
1268
|
if (this.config.allowSetState) {
|
|
1018
1269
|
server.registerTool('set_state', {
|
|
1019
1270
|
title: 'Set a state value',
|
|
@@ -1023,7 +1274,10 @@ class McpServer {
|
|
|
1023
1274
|
idempotentHint: true,
|
|
1024
1275
|
openWorldHint: false,
|
|
1025
1276
|
},
|
|
1026
|
-
description: 'Set the value of a state. The value is coerced to the state type (boolean/number/string).'
|
|
1277
|
+
description: 'Set the value of a state. The value is coerced to the state type (boolean/number/string). ' +
|
|
1278
|
+
'With `wait_ack`, wait until the adapter of the device confirms the command (ack=true) and ' +
|
|
1279
|
+
'return the confirmed state - its value may differ from the written one, e.g. when the ' +
|
|
1280
|
+
'device limits it.',
|
|
1027
1281
|
inputSchema: {
|
|
1028
1282
|
id: zod_1.z.string().describe('State ID'),
|
|
1029
1283
|
value: zod_1.z.any().describe('New value (type depends on the state)'),
|
|
@@ -1035,11 +1289,41 @@ class McpServer {
|
|
|
1035
1289
|
// fields of the other tools, which pass their defaults in code instead.
|
|
1036
1290
|
.object({ ack: zod_1.z.boolean().default(false), expire: zod_1.z.number().int().nullable().optional() })
|
|
1037
1291
|
.optional(),
|
|
1292
|
+
wait_ack: zod_1.z
|
|
1293
|
+
.number()
|
|
1294
|
+
.min(0)
|
|
1295
|
+
.max(MAX_WAIT_TIMEOUT)
|
|
1296
|
+
.optional()
|
|
1297
|
+
.describe('Wait up to this many seconds for the confirmation (ack=true) by the adapter of the ' +
|
|
1298
|
+
'device (only for a command, i.e. ack=false)'),
|
|
1038
1299
|
},
|
|
1039
|
-
}, async ({ id, value, options }) => {
|
|
1300
|
+
}, async ({ id, value, options, wait_ack }, extra) => {
|
|
1040
1301
|
try {
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1302
|
+
const ack = options?.ack ?? false;
|
|
1303
|
+
const written = await this.setState(id, value, ack);
|
|
1304
|
+
const data = { id, value: written };
|
|
1305
|
+
if (wait_ack && !ack) {
|
|
1306
|
+
try {
|
|
1307
|
+
// Our command is stored with ack=false, so a state with ack=true is the answer
|
|
1308
|
+
const result = await this.waitForState(id, state => !!state?.ack, wait_ack, extra.signal);
|
|
1309
|
+
Object.assign(data, {
|
|
1310
|
+
acked: result.reached,
|
|
1311
|
+
state: result.state,
|
|
1312
|
+
waited_ms: result.waited,
|
|
1313
|
+
...(result.reached
|
|
1314
|
+
? {}
|
|
1315
|
+
: {
|
|
1316
|
+
hint: `The value was written, but not confirmed within ${wait_ack} s: the ` +
|
|
1317
|
+
'device may be unreachable, or no adapter processes this state',
|
|
1318
|
+
}),
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
catch (e) {
|
|
1322
|
+
data.acked = false;
|
|
1323
|
+
data.ackError = `The value was written, but the confirmation cannot be read: ${e instanceof Error ? e.message : e}`;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return ok({ ok: true, data });
|
|
1043
1327
|
}
|
|
1044
1328
|
catch (e) {
|
|
1045
1329
|
return fail(e);
|
|
@@ -1080,8 +1364,9 @@ class McpServer {
|
|
|
1080
1364
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1081
1365
|
description: 'Retrieve recent ioBroker log lines from the host log file. Each entry has {ts, level, ' +
|
|
1082
1366
|
'source, message}. Optionally filter by `level` (error/warn/info/debug), by `adapter` ' +
|
|
1083
|
-
'(source, e.g. "hm-rpc.0" or "hm-rpc"),
|
|
1084
|
-
'a larger window of the log is scanned so
|
|
1367
|
+
'(source, e.g. "hm-rpc.0" or "hm-rpc"), from `from_ts` (ms), or by `search` in the message. ' +
|
|
1368
|
+
'When a filter is set, a larger window of the log is scanned so the matching lines are not ' +
|
|
1369
|
+
'hidden behind debug spam. ' +
|
|
1085
1370
|
'`logFile` tells the time of the newest line in the file and its size: if they do not change ' +
|
|
1086
1371
|
'between calls, the host does not see new log lines.',
|
|
1087
1372
|
inputSchema: {
|
|
@@ -1089,6 +1374,11 @@ class McpServer {
|
|
|
1089
1374
|
from_ts: zod_1.z.number().int().optional(),
|
|
1090
1375
|
limit: zod_1.z.number().int().optional().describe('Maximum number of lines to return (default 200)'),
|
|
1091
1376
|
adapter: zod_1.z.string().optional(),
|
|
1377
|
+
search: zod_1.z
|
|
1378
|
+
.string()
|
|
1379
|
+
.optional()
|
|
1380
|
+
.describe('Only lines whose message contains this text (case-insensitive), or matches it as a ' +
|
|
1381
|
+
'regular expression when written as /pattern/flags, e.g. "/timeout|ECONNREFUSED/i"'),
|
|
1092
1382
|
},
|
|
1093
1383
|
}, async (args) => {
|
|
1094
1384
|
try {
|
|
@@ -2003,6 +2293,8 @@ class McpServer {
|
|
|
2003
2293
|
},
|
|
2004
2294
|
}, async (args) => {
|
|
2005
2295
|
try {
|
|
2296
|
+
// Before asking: the user should not confirm what is denied anyway
|
|
2297
|
+
await this.assertOtherPermission('execute', 'run commands on a host');
|
|
2006
2298
|
await confirm(`Delete the instance "${args.id}" with its configuration, objects and states? This cannot be undone.`);
|
|
2007
2299
|
return ok({ ok: true, data: await this.deleteInstance(args) });
|
|
2008
2300
|
}
|
|
@@ -2028,6 +2320,7 @@ class McpServer {
|
|
|
2028
2320
|
},
|
|
2029
2321
|
}, async (args) => {
|
|
2030
2322
|
try {
|
|
2323
|
+
await this.assertOtherPermission('execute', 'run commands on a host');
|
|
2031
2324
|
await confirm(`Upgrade the adapter "${args.adapter}"${args.version ? ` to ${args.version}` : ''}?`);
|
|
2032
2325
|
return ok({ ok: true, data: await this.upgradeAdapter(args) });
|
|
2033
2326
|
}
|
|
@@ -2133,6 +2426,7 @@ class McpServer {
|
|
|
2133
2426
|
},
|
|
2134
2427
|
}, async (args) => {
|
|
2135
2428
|
try {
|
|
2429
|
+
await this.assertOtherPermission('execute', 'run commands on a host');
|
|
2136
2430
|
await confirm(`Install the package "${args.source || args.file_path || args.file_name || args.upload_id}"? It runs with full rights on the host.`);
|
|
2137
2431
|
return ok({ ok: true, data: await this.installPackage(args) });
|
|
2138
2432
|
}
|
|
@@ -2241,6 +2535,7 @@ class McpServer {
|
|
|
2241
2535
|
},
|
|
2242
2536
|
}, async (args) => {
|
|
2243
2537
|
try {
|
|
2538
|
+
await this.assertOtherPermission('sendto', 'send messages to adapters');
|
|
2244
2539
|
await confirm('Run the code in the javascript adapter?');
|
|
2245
2540
|
return ok({ ok: true, data: await this.runScript(args) });
|
|
2246
2541
|
}
|
|
@@ -2276,6 +2571,7 @@ class McpServer {
|
|
|
2276
2571
|
},
|
|
2277
2572
|
}, async (args) => {
|
|
2278
2573
|
try {
|
|
2574
|
+
await this.assertOtherPermission('sendto', 'send messages to adapters');
|
|
2279
2575
|
await confirm(`Send "${args.command}" to "${args.instance}"?`);
|
|
2280
2576
|
return ok({ ok: true, data: await this.sendToInstance(args) });
|
|
2281
2577
|
}
|
|
@@ -2473,6 +2769,26 @@ class McpServer {
|
|
|
2473
2769
|
}
|
|
2474
2770
|
return results;
|
|
2475
2771
|
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Verify a right of the "other" category of the user, like admin does for `cmdExec` (`execute`) and
|
|
2774
|
+
* `sendTo` (`sendto`). js-controller does not check them for messages, so without this an MCP user
|
|
2775
|
+
* could run what the ioBroker user may not.
|
|
2776
|
+
*
|
|
2777
|
+
* @param operation `execute` for CLI commands on a host, `sendto` for messages to adapters
|
|
2778
|
+
* @param action what was denied, for the error message (e.g. `run "iobroker url ..." on host "x"`)
|
|
2779
|
+
* @throws {Error} a permissionError before anything was done
|
|
2780
|
+
*/
|
|
2781
|
+
async assertOtherPermission(operation, action) {
|
|
2782
|
+
if (this.user === 'system.user.admin') {
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const permissions = await this.adapter.calculatePermissionsAsync(this.user, []);
|
|
2786
|
+
if (permissions.groups?.includes('system.group.administrator') || permissions.other?.[operation]) {
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
throw new Error(`permissionError: user "${this.user}" may not ${action} (missing "${operation}" in the ` +
|
|
2790
|
+
'"other" permissions of its groups) - nothing was done');
|
|
2791
|
+
}
|
|
2476
2792
|
/**
|
|
2477
2793
|
* Verify that the user of the request may write every given state, mirroring the checks js-controller runs in
|
|
2478
2794
|
* `setForeignState`: the user must be able to read the object (needed for the type coercion in
|
|
@@ -2648,7 +2964,30 @@ class McpServer {
|
|
|
2648
2964
|
*/
|
|
2649
2965
|
getLogs(params) {
|
|
2650
2966
|
const limit = Math.max(1, params.limit || 200);
|
|
2651
|
-
|
|
2967
|
+
// `search` is a text (case-insensitive) or a regular expression written as /pattern/flags
|
|
2968
|
+
let matches;
|
|
2969
|
+
if (params.search) {
|
|
2970
|
+
const regExp = params.search.match(/^\/(.+)\/([a-z]*)$/);
|
|
2971
|
+
if (regExp) {
|
|
2972
|
+
let pattern;
|
|
2973
|
+
try {
|
|
2974
|
+
// without g/y: test() would continue from the last match
|
|
2975
|
+
pattern = new RegExp(regExp[1], regExp[2].replace(/[gy]/g, ''));
|
|
2976
|
+
}
|
|
2977
|
+
catch (e) {
|
|
2978
|
+
return Promise.reject(new Error(`Invalid regular expression ${params.search}: ${e instanceof Error ? e.message : e}`));
|
|
2979
|
+
}
|
|
2980
|
+
matches = message => pattern.test(message);
|
|
2981
|
+
}
|
|
2982
|
+
else {
|
|
2983
|
+
const text = params.search.toLowerCase();
|
|
2984
|
+
matches = message => message.toLowerCase().includes(text);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
const filtering = !!((params.level && params.level.length) ||
|
|
2988
|
+
params.adapter ||
|
|
2989
|
+
params.from_ts !== undefined ||
|
|
2990
|
+
matches);
|
|
2652
2991
|
// The host `getLogs` returns the tail of the log file (≈150 bytes per requested line). When a
|
|
2653
2992
|
// filter is active, scan a much larger window so error/warn lines are not pushed out by a flood
|
|
2654
2993
|
// of debug lines from a chatty adapter.
|
|
@@ -2669,9 +3008,14 @@ class McpServer {
|
|
|
2669
3008
|
}
|
|
2670
3009
|
let logs = this.parseLogLines(rawList);
|
|
2671
3010
|
const newestTs = logs.length ? logs[logs.length - 1].ts : undefined;
|
|
2672
|
-
// The host appends the size of the log file as the last element
|
|
3011
|
+
// The host appends the size of the log file as the last element: as a string after reading
|
|
3012
|
+
// the file, as a number only if reading failed
|
|
2673
3013
|
const last = rawList[rawList.length - 1];
|
|
2674
|
-
const fileSize = typeof last === 'number'
|
|
3014
|
+
const fileSize = typeof last === 'number'
|
|
3015
|
+
? last
|
|
3016
|
+
: typeof last === 'string' && /^\d+$/.test(last)
|
|
3017
|
+
? Number(last)
|
|
3018
|
+
: undefined;
|
|
2675
3019
|
if (params.level && params.level.length) {
|
|
2676
3020
|
logs = logs.filter(log => params.level.includes(log.level));
|
|
2677
3021
|
}
|
|
@@ -2682,6 +3026,9 @@ class McpServer {
|
|
|
2682
3026
|
const adapter = params.adapter;
|
|
2683
3027
|
logs = logs.filter(log => log.source === adapter || log.source.startsWith(`${adapter}.`));
|
|
2684
3028
|
}
|
|
3029
|
+
if (matches) {
|
|
3030
|
+
logs = logs.filter(log => matches(log.message));
|
|
3031
|
+
}
|
|
2685
3032
|
// Return the most recent `limit` matching lines.
|
|
2686
3033
|
if (logs.length > limit) {
|
|
2687
3034
|
logs = logs.slice(logs.length - limit);
|
|
@@ -3152,6 +3499,69 @@ class McpServer {
|
|
|
3152
3499
|
await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.user });
|
|
3153
3500
|
return coerced;
|
|
3154
3501
|
}
|
|
3502
|
+
/**
|
|
3503
|
+
* Read a state until `reached` accepts it, the time is up or the client cancels the call.
|
|
3504
|
+
*
|
|
3505
|
+
* @param id state ID
|
|
3506
|
+
* @param reached the condition
|
|
3507
|
+
* @param timeoutSeconds how long to wait at most
|
|
3508
|
+
* @param signal aborted when the client cancels the call
|
|
3509
|
+
* @returns whether the condition was met, the last read state and how long it took (ms)
|
|
3510
|
+
*/
|
|
3511
|
+
async waitForState(id, reached, timeoutSeconds, signal) {
|
|
3512
|
+
const start = Date.now();
|
|
3513
|
+
const deadline = start + timeoutSeconds * 1000;
|
|
3514
|
+
for (;;) {
|
|
3515
|
+
const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
|
|
3516
|
+
const done = reached(state);
|
|
3517
|
+
if (done || Date.now() >= deadline || signal?.aborted) {
|
|
3518
|
+
return { reached: done, state, waited: Date.now() - start };
|
|
3519
|
+
}
|
|
3520
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(STATE_POLL_INTERVAL, deadline - Date.now())));
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* Whether a state value is the expected one, also if one of them is stored as a string ("21.5" and 21.5,
|
|
3525
|
+
* '{"a":1}' and {a: 1}).
|
|
3526
|
+
*/
|
|
3527
|
+
static sameValue(actual, expected) {
|
|
3528
|
+
if (actual === null || actual === undefined || expected === null || expected === undefined) {
|
|
3529
|
+
return actual === expected;
|
|
3530
|
+
}
|
|
3531
|
+
const text = (value) => (typeof value === 'string' ? value : JSON.stringify(value));
|
|
3532
|
+
return text(actual) === text(expected);
|
|
3533
|
+
}
|
|
3534
|
+
/**
|
|
3535
|
+
* The states without object. For a pattern, js-controller returns only the states of existing state objects,
|
|
3536
|
+
* except in its maintenance mode (only for the admin user), which reads the keys of the states database. The
|
|
3537
|
+
* objects are read with the rights of the adapter, as an object the user may not see is still an object. A
|
|
3538
|
+
* state without object has no ACL, so js-controller lets every user read it anyway.
|
|
3539
|
+
*
|
|
3540
|
+
* @param pattern IDs of the states to check, wildcards allowed
|
|
3541
|
+
* @returns how many states were checked, and the states without object
|
|
3542
|
+
*/
|
|
3543
|
+
async findOrphanStates(pattern) {
|
|
3544
|
+
// not a literal argument: the types of the adapter do not know `maintenance`
|
|
3545
|
+
const maintenance = { user: 'system.user.admin', maintenance: true };
|
|
3546
|
+
const states = await this.adapter.getForeignStatesAsync(pattern, maintenance);
|
|
3547
|
+
// The state objects of all IDs the pattern may match
|
|
3548
|
+
const prefix = pattern.split('*')[0];
|
|
3549
|
+
const view = await this.adapter.getObjectViewAsync('system', 'state', {
|
|
3550
|
+
startkey: prefix,
|
|
3551
|
+
endkey: `${prefix}香`,
|
|
3552
|
+
});
|
|
3553
|
+
const objects = new Set(view.rows.map(row => row.id));
|
|
3554
|
+
const orphans = [];
|
|
3555
|
+
for (const id of Object.keys(states).sort()) {
|
|
3556
|
+
// the rare state of an object of another type is no orphan either
|
|
3557
|
+
if (objects.has(id) || (await this.adapter.getForeignObjectAsync(id))) {
|
|
3558
|
+
continue;
|
|
3559
|
+
}
|
|
3560
|
+
const state = states[id];
|
|
3561
|
+
orphans.push({ id, val: state?.val, ack: state?.ack, ts: state?.ts, from: state?.from });
|
|
3562
|
+
}
|
|
3563
|
+
return { checked: Object.keys(states).length, orphans };
|
|
3564
|
+
}
|
|
3155
3565
|
/** Coerce an arbitrary value to the given ioBroker state type. */
|
|
3156
3566
|
static coerceValue(value, type) {
|
|
3157
3567
|
if (value === null || value === undefined) {
|
|
@@ -3460,18 +3870,35 @@ class McpServer {
|
|
|
3460
3870
|
this.removeTransfer(params.upload_id);
|
|
3461
3871
|
}
|
|
3462
3872
|
const adapterName = extractAdapterName(params.file_path || params.file_name || upload?.name || params.source || '');
|
|
3873
|
+
// The package is installed at this point: errors from here on are reported, not thrown, so the
|
|
3874
|
+
// caller still learns that the installation happened
|
|
3463
3875
|
const restartTs = Date.now();
|
|
3464
|
-
const restarted =
|
|
3465
|
-
|
|
3466
|
-
|
|
3876
|
+
const restarted = [];
|
|
3877
|
+
let restartError;
|
|
3878
|
+
if (result.success && adapterName && params.restart_instances !== false) {
|
|
3879
|
+
try {
|
|
3880
|
+
await this.restartAdapterInstances(adapterName, restarted);
|
|
3881
|
+
}
|
|
3882
|
+
catch (e) {
|
|
3883
|
+
restartError = `Installed, but the instances could not be restarted: ${e instanceof Error ? e.message : e}`;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3467
3886
|
const startTimeout = params.start_timeout ?? DEFAULT_START_TIMEOUT;
|
|
3887
|
+
let instances;
|
|
3888
|
+
if (restarted.length && startTimeout) {
|
|
3889
|
+
try {
|
|
3890
|
+
instances = await this.waitForStart(restarted, restartTs, startTimeout);
|
|
3891
|
+
}
|
|
3892
|
+
catch (e) {
|
|
3893
|
+
restartError ??= `Installed, but the start of the instances could not be checked: ${e instanceof Error ? e.message : e}`;
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3468
3896
|
return {
|
|
3469
3897
|
...result,
|
|
3470
3898
|
adapter: adapterName,
|
|
3471
3899
|
restartedInstances: restarted,
|
|
3472
|
-
...(
|
|
3473
|
-
|
|
3474
|
-
: {}),
|
|
3900
|
+
...(restartError ? { restartError } : {}),
|
|
3901
|
+
...(instances ? { instances } : {}),
|
|
3475
3902
|
};
|
|
3476
3903
|
}
|
|
3477
3904
|
/**
|
|
@@ -3547,6 +3974,7 @@ class McpServer {
|
|
|
3547
3974
|
* @param files files sent along with the command (the command refers to them by name)
|
|
3548
3975
|
*/
|
|
3549
3976
|
async runHostCommand(host, command, timeoutSeconds, files) {
|
|
3977
|
+
await this.assertOtherPermission('execute', `run "iobroker ${command}" on host "${host}"`);
|
|
3550
3978
|
const id = `mcp-${(0, node_crypto_1.randomUUID)()}`;
|
|
3551
3979
|
const timeout = (timeoutSeconds ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
|
|
3552
3980
|
const output = [];
|
|
@@ -3592,19 +4020,22 @@ class McpServer {
|
|
|
3592
4020
|
});
|
|
3593
4021
|
});
|
|
3594
4022
|
}
|
|
3595
|
-
/**
|
|
3596
|
-
|
|
4023
|
+
/**
|
|
4024
|
+
* Like admin after an installation: touching the instance objects makes the controller restart them.
|
|
4025
|
+
*
|
|
4026
|
+
* @param adapterName adapter whose enabled instances are restarted
|
|
4027
|
+
* @param restarted receives the ids of the restarted instances, also those before an error
|
|
4028
|
+
*/
|
|
4029
|
+
async restartAdapterInstances(adapterName, restarted) {
|
|
3597
4030
|
const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
|
|
3598
4031
|
user: this.user,
|
|
3599
4032
|
});
|
|
3600
|
-
const restarted = [];
|
|
3601
4033
|
for (const [instanceId, instance] of Object.entries(instances || {})) {
|
|
3602
4034
|
if (instance?.common?.enabled) {
|
|
3603
4035
|
await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.user });
|
|
3604
4036
|
restarted.push(instanceId.replace('system.adapter.', ''));
|
|
3605
4037
|
}
|
|
3606
4038
|
}
|
|
3607
|
-
return restarted;
|
|
3608
4039
|
}
|
|
3609
4040
|
/** Read an instance object by its short id (`hue.0`) or full id (`system.adapter.hue.0`). */
|
|
3610
4041
|
async getInstance(id) {
|
|
@@ -3895,6 +4326,7 @@ class McpServer {
|
|
|
3895
4326
|
*/
|
|
3896
4327
|
async runScript(params) {
|
|
3897
4328
|
const instance = await this.getInstance(params.instance || 'javascript.0');
|
|
4329
|
+
await this.assertOtherPermission('sendto', `run code in "${instance.id}"`);
|
|
3898
4330
|
const timeout = params.timeout ?? 5000;
|
|
3899
4331
|
const result = await this.adapter.sendToAsync(instance.id, 'execute', { source: params.source, engineType: params.engineType, timeout, verbose: false, logLevel: 'info' }, { timeout: timeout + 15_000, user: this.user });
|
|
3900
4332
|
if (!result || typeof result !== 'object') {
|
|
@@ -3916,6 +4348,7 @@ class McpServer {
|
|
|
3916
4348
|
if (!this.isSendToAllowed(instance.id)) {
|
|
3917
4349
|
throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
|
|
3918
4350
|
}
|
|
4351
|
+
await this.assertOtherPermission('sendto', `send messages to "${instance.id}"`);
|
|
3919
4352
|
const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.user });
|
|
3920
4353
|
return { instance: instance.id, command: params.command, response: response ?? null };
|
|
3921
4354
|
}
|