@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/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 written = await this.setState(id, value, options?.ack ?? false);
1042
- return ok({ ok: true, data: { id, value: written } });
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"), or from `from_ts` (ms). When a level filter is set, ' +
1084
- 'a larger window of the log is scanned so errors/warnings are not hidden behind debug spam. ' +
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
- const filtering = !!((params.level && params.level.length) || params.adapter || params.from_ts !== undefined);
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' ? last : undefined;
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 = result.success && adapterName && params.restart_instances !== false
3465
- ? await this.restartAdapterInstances(adapterName)
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
- ...(restarted.length && startTimeout
3473
- ? { instances: await this.waitForStart(restarted, restartTs, startTimeout) }
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
- /** Like admin after an installation: touching the instance objects makes the controller restart them. */
3596
- async restartAdapterInstances(adapterName) {
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
  }