@iobroker/mcp-server 1.7.1 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -40,6 +40,62 @@ 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;
94
+ /**
95
+ * The upload progress of an adapter, which js-controller writes without object when it uploads the admin files:
96
+ * no orphan.
97
+ */
98
+ const UPLOAD_STATE = /^system\.adapter\.[^.]+\.upload$/;
43
99
  const LOOPBACK = new node_net_1.default.BlockList();
44
100
  LOOPBACK.addSubnet('127.0.0.0', 8, 'ipv4');
45
101
  LOOPBACK.addAddress('::1', 'ipv6');
@@ -181,6 +237,8 @@ class McpServer {
181
237
  subscriptionCounts = {};
182
238
  /** Recent log lines kept for `ioblog://` resource reads (filled while there are log subscribers). */
183
239
  logBuffer = [];
240
+ /** The tools of every server created by {@link createServer}, for {@link hideToolsWithoutRights}. */
241
+ registeredTools = new WeakMap();
184
242
  config;
185
243
  extension;
186
244
  routerPrefix;
@@ -320,11 +378,50 @@ class McpServer {
320
378
  * Build a fresh MCP SDK server with all ioBroker tools registered, ready to be connected to an
321
379
  * arbitrary transport. Used for in-process embedding (e.g. by ioBroker.admin) over an
322
380
  * 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).
381
+ * of the returned server (connect it to a transport, and `close()` it when done). Call
382
+ * {@link hideToolsWithoutRights} before connecting it to offer only the tools the user may use.
324
383
  */
325
384
  createInProcessServer() {
326
385
  return this.createServer(new Set());
327
386
  }
387
+ /**
388
+ * Do not offer the tools the user cannot use, because its groups lack a general right the tool needs (e.g.
389
+ * `install_package` without "execute"), so the client does not try them in vain. Administrators get all
390
+ * tools. The rights are read once, before the server is connected: changed rights apply to new sessions,
391
+ * and every call is checked anyway.
392
+ *
393
+ * @param server a server returned by {@link createInProcessServer}
394
+ * @param user the ioBroker user of the session (default: the configured default user)
395
+ * @returns the names of the hidden tools
396
+ */
397
+ async hideToolsWithoutRights(server, user = this.defaultUser) {
398
+ const tools = this.registeredTools.get(server);
399
+ if (!tools || user === 'system.user.admin') {
400
+ return [];
401
+ }
402
+ let rights;
403
+ try {
404
+ rights = await this.adapter.calculatePermissionsAsync(user, []);
405
+ }
406
+ catch (e) {
407
+ this.adapter.log.warn(`Cannot read the rights of ${user}, all tools are offered: ${e instanceof Error ? e.message : e}`);
408
+ return [];
409
+ }
410
+ if (rights.groups?.includes('system.group.administrator')) {
411
+ return [];
412
+ }
413
+ const hidden = [];
414
+ for (const [name, tool] of tools) {
415
+ if (TOOL_RIGHTS[name] && !TOOL_RIGHTS[name](rights)) {
416
+ tool.disable();
417
+ hidden.push(name);
418
+ }
419
+ }
420
+ if (hidden.length) {
421
+ this.adapter.log.debug(`MCP tools not offered to ${user} for lack of rights: ${hidden.join(', ')}`);
422
+ }
423
+ return hidden;
424
+ }
328
425
  /**
329
426
  * Convenience factory for embedding the MCP server inside another adapter's process (no HTTP).
330
427
  * The returned instance is not wired to any Express app; obtain a tool server via
@@ -733,6 +830,7 @@ class McpServer {
733
830
  this.adapter.log.debug(`MCP session closed: ${sid}`);
734
831
  }
735
832
  };
833
+ await this.hideToolsWithoutRights(server, user);
736
834
  await server.connect(transport);
737
835
  }
738
836
  const sessionTransport = transport;
@@ -894,6 +992,76 @@ class McpServer {
894
992
  }
895
993
  session.subscriptions.clear();
896
994
  }
995
+ /**
996
+ * Remember every tool registered on `server` afterwards (for {@link hideToolsWithoutRights}) and log its
997
+ * calls: a failed call as a warning with the tool and its arguments, a successful call of a writing tool
998
+ * (not `readOnlyHint`) at info level, so the log shows who changed what.
999
+ *
1000
+ * @param server the server whose `registerTool` is wrapped
1001
+ */
1002
+ trackTools(server) {
1003
+ const register = server.registerTool.bind(server);
1004
+ const tools = new Map();
1005
+ this.registeredTools.set(server, tools);
1006
+ server.registerTool = (name, config, handler) => {
1007
+ const tool = register(name, config, async (...params) => {
1008
+ // Tools with an input schema get (args, extra), the others only (extra)
1009
+ const call = `${name}(${McpServer.describeArgs(name, params.length > 1 ? params[0] : undefined)})`;
1010
+ let result;
1011
+ try {
1012
+ result = await handler(...params);
1013
+ }
1014
+ catch (e) {
1015
+ this.adapter.log.warn(`MCP tool error in ${call} by ${this.user}: ${e instanceof Error ? e.message : e}`);
1016
+ throw e;
1017
+ }
1018
+ if (result?.isError) {
1019
+ let error = result.content?.[0]?.text;
1020
+ try {
1021
+ error = JSON.parse(error).error ?? error;
1022
+ }
1023
+ catch {
1024
+ // not JSON: log the text as it is
1025
+ }
1026
+ this.adapter.log.warn(`MCP tool error in ${call} by ${this.user}: ${error}`);
1027
+ }
1028
+ else if (config?.annotations?.readOnlyHint === false) {
1029
+ this.adapter.log.info(`MCP ${this.user}: ${call}`);
1030
+ }
1031
+ return result;
1032
+ });
1033
+ tools.set(name, tool);
1034
+ return tool;
1035
+ };
1036
+ }
1037
+ /**
1038
+ * The arguments of a tool call for the log: long values are shortened, and contents that may hold secrets
1039
+ * or large data (file contents, script code, message payloads) are replaced by their size.
1040
+ *
1041
+ * @param tool name of the tool
1042
+ * @param args arguments of the call
1043
+ */
1044
+ static describeArgs(tool, args) {
1045
+ if (!args || typeof args !== 'object') {
1046
+ return '';
1047
+ }
1048
+ const hidden = ['content', 'file_content', ...(LOG_HIDDEN_ARGS[tool] || [])];
1049
+ const parts = [];
1050
+ for (const [key, value] of Object.entries(args)) {
1051
+ if (value === undefined) {
1052
+ continue;
1053
+ }
1054
+ let text = JSON.stringify(value) ?? String(value);
1055
+ if (hidden.includes(key)) {
1056
+ text = `<${text.length} chars>`;
1057
+ }
1058
+ else if (text.length > LOG_ARG_LENGTH) {
1059
+ text = `${text.slice(0, LOG_ARG_LENGTH)}...`;
1060
+ }
1061
+ parts.push(`${key}=${text}`);
1062
+ }
1063
+ return parts.join(', ');
1064
+ }
897
1065
  /**
898
1066
  * Create a new MCP SDK server with all ioBroker tools registered.
899
1067
  *
@@ -902,9 +1070,10 @@ class McpServer {
902
1070
  */
903
1071
  createServer(subscriptions, baseUrl) {
904
1072
  const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: { subscribe: true } } });
1073
+ this.trackTools(server);
1074
+ // Logged by trackTools, together with the tool and its arguments
905
1075
  const fail = (error) => {
906
1076
  const message = error instanceof Error ? error.message : String(error);
907
- this.adapter.log.warn(`MCP tool error: ${message}`);
908
1077
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
909
1078
  };
910
1079
  const canLink = this.linksAvailable && !!baseUrl;
@@ -1014,6 +1183,95 @@ class McpServer {
1014
1183
  return fail(e);
1015
1184
  }
1016
1185
  });
1186
+ server.registerTool('wait_for_state', {
1187
+ title: 'Wait for a state value',
1188
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1189
+ description: 'Wait until a state has the given `value` - or, without `value`, until it is updated - and ' +
1190
+ 'return the state, e.g. to check that a device really switched or a process finished. ' +
1191
+ `Gives up after \`timeout\` seconds (default ${DEFAULT_WAIT_TIMEOUT}) with reached=false. ` +
1192
+ `The state is read every ${STATE_POLL_INTERVAL} ms, so a value that lasts shorter can be missed.`,
1193
+ inputSchema: {
1194
+ id: zod_1.z.string().describe('State ID'),
1195
+ value: zod_1.z
1196
+ .any()
1197
+ .optional()
1198
+ .describe('Expected value (coerced to the state type); omit it to wait for the next update'),
1199
+ ack: zod_1.z
1200
+ .boolean()
1201
+ .optional()
1202
+ .describe('Only accept a state with this flag: true = confirmed by the device, false = command'),
1203
+ timeout: zod_1.z
1204
+ .number()
1205
+ .min(0)
1206
+ .max(MAX_WAIT_TIMEOUT)
1207
+ .optional()
1208
+ .describe(`Seconds to wait at most (default ${DEFAULT_WAIT_TIMEOUT})`),
1209
+ },
1210
+ }, async ({ id, value, ack, timeout }, extra) => {
1211
+ try {
1212
+ const seconds = timeout ?? DEFAULT_WAIT_TIMEOUT;
1213
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
1214
+ const expected = McpServer.coerceValue(value, obj?.common?.type);
1215
+ const initial = await this.adapter.getForeignStateAsync(id, { user: this.user });
1216
+ const result = await this.waitForState(id, state => !!state &&
1217
+ (ack === undefined || state.ack === ack) &&
1218
+ (value === undefined
1219
+ ? !initial || state.ts !== initial.ts
1220
+ : McpServer.sameValue(state.val, expected)), seconds, extra.signal);
1221
+ return ok({
1222
+ ok: true,
1223
+ data: {
1224
+ id,
1225
+ reached: result.reached,
1226
+ state: result.state ?? null,
1227
+ waited_ms: result.waited,
1228
+ ...(result.reached
1229
+ ? {}
1230
+ : {
1231
+ hint: `${value === undefined ? 'Not updated' : 'The value was not reached'} within ${seconds} s`,
1232
+ }),
1233
+ },
1234
+ });
1235
+ }
1236
+ catch (e) {
1237
+ return fail(e);
1238
+ }
1239
+ });
1240
+ server.registerTool('find_orphan_states', {
1241
+ title: 'Find states without object',
1242
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1243
+ description: 'Find states that have no object, e.g. "system.adapter.vis.0.alive" left over from a deleted ' +
1244
+ 'instance. The other tools and admin do not show them, but scripts and visualizations may still ' +
1245
+ 'read their old values. `pattern` selects the states to check, e.g. "system.adapter.*" or ' +
1246
+ '"hue.0.*"; "*" checks all states and objects, which takes a while on large installations. ' +
1247
+ 'The upload progress of the adapters ("system.adapter.<adapter>.upload") is not reported: ' +
1248
+ 'js-controller writes it without object on purpose. ' +
1249
+ 'Found states can be deleted on the host with `iobroker state del <id>`.',
1250
+ inputSchema: {
1251
+ pattern: zod_1.z
1252
+ .string()
1253
+ .min(1)
1254
+ .describe('IDs of the states to check, wildcards "*" allowed, e.g. "system.adapter.*"'),
1255
+ offset: offsetSchema,
1256
+ },
1257
+ }, async ({ pattern, offset }) => {
1258
+ try {
1259
+ const { checked, orphans } = await this.findOrphanStates(pattern);
1260
+ const page = fitItems(orphans, offset);
1261
+ return ok({
1262
+ ok: true,
1263
+ data: {
1264
+ pattern,
1265
+ checked,
1266
+ orphans: page.items,
1267
+ ...pageInfo(page, orphans, 'orphans.json'),
1268
+ },
1269
+ });
1270
+ }
1271
+ catch (e) {
1272
+ return fail(e);
1273
+ }
1274
+ });
1017
1275
  if (this.config.allowSetState) {
1018
1276
  server.registerTool('set_state', {
1019
1277
  title: 'Set a state value',
@@ -1023,7 +1281,10 @@ class McpServer {
1023
1281
  idempotentHint: true,
1024
1282
  openWorldHint: false,
1025
1283
  },
1026
- description: 'Set the value of a state. The value is coerced to the state type (boolean/number/string).',
1284
+ description: 'Set the value of a state. The value is coerced to the state type (boolean/number/string). ' +
1285
+ 'With `wait_ack`, wait until the adapter of the device confirms the command (ack=true) and ' +
1286
+ 'return the confirmed state - its value may differ from the written one, e.g. when the ' +
1287
+ 'device limits it.',
1027
1288
  inputSchema: {
1028
1289
  id: zod_1.z.string().describe('State ID'),
1029
1290
  value: zod_1.z.any().describe('New value (type depends on the state)'),
@@ -1035,11 +1296,41 @@ class McpServer {
1035
1296
  // fields of the other tools, which pass their defaults in code instead.
1036
1297
  .object({ ack: zod_1.z.boolean().default(false), expire: zod_1.z.number().int().nullable().optional() })
1037
1298
  .optional(),
1299
+ wait_ack: zod_1.z
1300
+ .number()
1301
+ .min(0)
1302
+ .max(MAX_WAIT_TIMEOUT)
1303
+ .optional()
1304
+ .describe('Wait up to this many seconds for the confirmation (ack=true) by the adapter of the ' +
1305
+ 'device (only for a command, i.e. ack=false)'),
1038
1306
  },
1039
- }, async ({ id, value, options }) => {
1307
+ }, async ({ id, value, options, wait_ack }, extra) => {
1040
1308
  try {
1041
- const written = await this.setState(id, value, options?.ack ?? false);
1042
- return ok({ ok: true, data: { id, value: written } });
1309
+ const ack = options?.ack ?? false;
1310
+ const written = await this.setState(id, value, ack);
1311
+ const data = { id, value: written };
1312
+ if (wait_ack && !ack) {
1313
+ try {
1314
+ // Our command is stored with ack=false, so a state with ack=true is the answer
1315
+ const result = await this.waitForState(id, state => !!state?.ack, wait_ack, extra.signal);
1316
+ Object.assign(data, {
1317
+ acked: result.reached,
1318
+ state: result.state,
1319
+ waited_ms: result.waited,
1320
+ ...(result.reached
1321
+ ? {}
1322
+ : {
1323
+ hint: `The value was written, but not confirmed within ${wait_ack} s: the ` +
1324
+ 'device may be unreachable, or no adapter processes this state',
1325
+ }),
1326
+ });
1327
+ }
1328
+ catch (e) {
1329
+ data.acked = false;
1330
+ data.ackError = `The value was written, but the confirmation cannot be read: ${e instanceof Error ? e.message : e}`;
1331
+ }
1332
+ }
1333
+ return ok({ ok: true, data });
1043
1334
  }
1044
1335
  catch (e) {
1045
1336
  return fail(e);
@@ -1080,8 +1371,9 @@ class McpServer {
1080
1371
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1081
1372
  description: 'Retrieve recent ioBroker log lines from the host log file. Each entry has {ts, level, ' +
1082
1373
  '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. ' +
1374
+ '(source, e.g. "hm-rpc.0" or "hm-rpc"), from `from_ts` (ms), or by `search` in the message. ' +
1375
+ 'When a filter is set, a larger window of the log is scanned so the matching lines are not ' +
1376
+ 'hidden behind debug spam. ' +
1085
1377
  '`logFile` tells the time of the newest line in the file and its size: if they do not change ' +
1086
1378
  'between calls, the host does not see new log lines.',
1087
1379
  inputSchema: {
@@ -1089,6 +1381,11 @@ class McpServer {
1089
1381
  from_ts: zod_1.z.number().int().optional(),
1090
1382
  limit: zod_1.z.number().int().optional().describe('Maximum number of lines to return (default 200)'),
1091
1383
  adapter: zod_1.z.string().optional(),
1384
+ search: zod_1.z
1385
+ .string()
1386
+ .optional()
1387
+ .describe('Only lines whose message contains this text (case-insensitive), or matches it as a ' +
1388
+ 'regular expression when written as /pattern/flags, e.g. "/timeout|ECONNREFUSED/i"'),
1092
1389
  },
1093
1390
  }, async (args) => {
1094
1391
  try {
@@ -2003,6 +2300,8 @@ class McpServer {
2003
2300
  },
2004
2301
  }, async (args) => {
2005
2302
  try {
2303
+ // Before asking: the user should not confirm what is denied anyway
2304
+ await this.assertOtherPermission('execute', 'run commands on a host');
2006
2305
  await confirm(`Delete the instance "${args.id}" with its configuration, objects and states? This cannot be undone.`);
2007
2306
  return ok({ ok: true, data: await this.deleteInstance(args) });
2008
2307
  }
@@ -2028,6 +2327,7 @@ class McpServer {
2028
2327
  },
2029
2328
  }, async (args) => {
2030
2329
  try {
2330
+ await this.assertOtherPermission('execute', 'run commands on a host');
2031
2331
  await confirm(`Upgrade the adapter "${args.adapter}"${args.version ? ` to ${args.version}` : ''}?`);
2032
2332
  return ok({ ok: true, data: await this.upgradeAdapter(args) });
2033
2333
  }
@@ -2133,6 +2433,7 @@ class McpServer {
2133
2433
  },
2134
2434
  }, async (args) => {
2135
2435
  try {
2436
+ await this.assertOtherPermission('execute', 'run commands on a host');
2136
2437
  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
2438
  return ok({ ok: true, data: await this.installPackage(args) });
2138
2439
  }
@@ -2241,6 +2542,7 @@ class McpServer {
2241
2542
  },
2242
2543
  }, async (args) => {
2243
2544
  try {
2545
+ await this.assertOtherPermission('sendto', 'send messages to adapters');
2244
2546
  await confirm('Run the code in the javascript adapter?');
2245
2547
  return ok({ ok: true, data: await this.runScript(args) });
2246
2548
  }
@@ -2276,6 +2578,7 @@ class McpServer {
2276
2578
  },
2277
2579
  }, async (args) => {
2278
2580
  try {
2581
+ await this.assertOtherPermission('sendto', 'send messages to adapters');
2279
2582
  await confirm(`Send "${args.command}" to "${args.instance}"?`);
2280
2583
  return ok({ ok: true, data: await this.sendToInstance(args) });
2281
2584
  }
@@ -2473,6 +2776,26 @@ class McpServer {
2473
2776
  }
2474
2777
  return results;
2475
2778
  }
2779
+ /**
2780
+ * Verify a right of the "other" category of the user, like admin does for `cmdExec` (`execute`) and
2781
+ * `sendTo` (`sendto`). js-controller does not check them for messages, so without this an MCP user
2782
+ * could run what the ioBroker user may not.
2783
+ *
2784
+ * @param operation `execute` for CLI commands on a host, `sendto` for messages to adapters
2785
+ * @param action what was denied, for the error message (e.g. `run "iobroker url ..." on host "x"`)
2786
+ * @throws {Error} a permissionError before anything was done
2787
+ */
2788
+ async assertOtherPermission(operation, action) {
2789
+ if (this.user === 'system.user.admin') {
2790
+ return;
2791
+ }
2792
+ const permissions = await this.adapter.calculatePermissionsAsync(this.user, []);
2793
+ if (permissions.groups?.includes('system.group.administrator') || permissions.other?.[operation]) {
2794
+ return;
2795
+ }
2796
+ throw new Error(`permissionError: user "${this.user}" may not ${action} (missing "${operation}" in the ` +
2797
+ '"other" permissions of its groups) - nothing was done');
2798
+ }
2476
2799
  /**
2477
2800
  * Verify that the user of the request may write every given state, mirroring the checks js-controller runs in
2478
2801
  * `setForeignState`: the user must be able to read the object (needed for the type coercion in
@@ -2648,7 +2971,30 @@ class McpServer {
2648
2971
  */
2649
2972
  getLogs(params) {
2650
2973
  const limit = Math.max(1, params.limit || 200);
2651
- const filtering = !!((params.level && params.level.length) || params.adapter || params.from_ts !== undefined);
2974
+ // `search` is a text (case-insensitive) or a regular expression written as /pattern/flags
2975
+ let matches;
2976
+ if (params.search) {
2977
+ const regExp = params.search.match(/^\/(.+)\/([a-z]*)$/);
2978
+ if (regExp) {
2979
+ let pattern;
2980
+ try {
2981
+ // without g/y: test() would continue from the last match
2982
+ pattern = new RegExp(regExp[1], regExp[2].replace(/[gy]/g, ''));
2983
+ }
2984
+ catch (e) {
2985
+ return Promise.reject(new Error(`Invalid regular expression ${params.search}: ${e instanceof Error ? e.message : e}`));
2986
+ }
2987
+ matches = message => pattern.test(message);
2988
+ }
2989
+ else {
2990
+ const text = params.search.toLowerCase();
2991
+ matches = message => message.toLowerCase().includes(text);
2992
+ }
2993
+ }
2994
+ const filtering = !!((params.level && params.level.length) ||
2995
+ params.adapter ||
2996
+ params.from_ts !== undefined ||
2997
+ matches);
2652
2998
  // The host `getLogs` returns the tail of the log file (≈150 bytes per requested line). When a
2653
2999
  // filter is active, scan a much larger window so error/warn lines are not pushed out by a flood
2654
3000
  // of debug lines from a chatty adapter.
@@ -2687,6 +3033,9 @@ class McpServer {
2687
3033
  const adapter = params.adapter;
2688
3034
  logs = logs.filter(log => log.source === adapter || log.source.startsWith(`${adapter}.`));
2689
3035
  }
3036
+ if (matches) {
3037
+ logs = logs.filter(log => matches(log.message));
3038
+ }
2690
3039
  // Return the most recent `limit` matching lines.
2691
3040
  if (logs.length > limit) {
2692
3041
  logs = logs.slice(logs.length - limit);
@@ -3157,6 +3506,69 @@ class McpServer {
3157
3506
  await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.user });
3158
3507
  return coerced;
3159
3508
  }
3509
+ /**
3510
+ * Read a state until `reached` accepts it, the time is up or the client cancels the call.
3511
+ *
3512
+ * @param id state ID
3513
+ * @param reached the condition
3514
+ * @param timeoutSeconds how long to wait at most
3515
+ * @param signal aborted when the client cancels the call
3516
+ * @returns whether the condition was met, the last read state and how long it took (ms)
3517
+ */
3518
+ async waitForState(id, reached, timeoutSeconds, signal) {
3519
+ const start = Date.now();
3520
+ const deadline = start + timeoutSeconds * 1000;
3521
+ for (;;) {
3522
+ const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
3523
+ const done = reached(state);
3524
+ if (done || Date.now() >= deadline || signal?.aborted) {
3525
+ return { reached: done, state, waited: Date.now() - start };
3526
+ }
3527
+ await new Promise(resolve => setTimeout(resolve, Math.min(STATE_POLL_INTERVAL, deadline - Date.now())));
3528
+ }
3529
+ }
3530
+ /**
3531
+ * Whether a state value is the expected one, also if one of them is stored as a string ("21.5" and 21.5,
3532
+ * '{"a":1}' and {a: 1}).
3533
+ */
3534
+ static sameValue(actual, expected) {
3535
+ if (actual === null || actual === undefined || expected === null || expected === undefined) {
3536
+ return actual === expected;
3537
+ }
3538
+ const text = (value) => (typeof value === 'string' ? value : JSON.stringify(value));
3539
+ return text(actual) === text(expected);
3540
+ }
3541
+ /**
3542
+ * The states without object. For a pattern, js-controller returns only the states of existing state objects,
3543
+ * except in its maintenance mode (only for the admin user), which reads the keys of the states database. The
3544
+ * objects are read with the rights of the adapter, as an object the user may not see is still an object. A
3545
+ * state without object has no ACL, so js-controller lets every user read it anyway.
3546
+ *
3547
+ * @param pattern IDs of the states to check, wildcards allowed
3548
+ * @returns how many states were checked, and the states without object
3549
+ */
3550
+ async findOrphanStates(pattern) {
3551
+ // not a literal argument: the types of the adapter do not know `maintenance`
3552
+ const maintenance = { user: 'system.user.admin', maintenance: true };
3553
+ const states = await this.adapter.getForeignStatesAsync(pattern, maintenance);
3554
+ // The state objects of all IDs the pattern may match
3555
+ const prefix = pattern.split('*')[0];
3556
+ const view = await this.adapter.getObjectViewAsync('system', 'state', {
3557
+ startkey: prefix,
3558
+ endkey: `${prefix}香`,
3559
+ });
3560
+ const objects = new Set(view.rows.map(row => row.id));
3561
+ const orphans = [];
3562
+ for (const id of Object.keys(states).sort()) {
3563
+ // the rare state of an object of another type is no orphan either
3564
+ if (UPLOAD_STATE.test(id) || objects.has(id) || (await this.adapter.getForeignObjectAsync(id))) {
3565
+ continue;
3566
+ }
3567
+ const state = states[id];
3568
+ orphans.push({ id, val: state?.val, ack: state?.ack, ts: state?.ts, from: state?.from });
3569
+ }
3570
+ return { checked: Object.keys(states).length, orphans };
3571
+ }
3160
3572
  /** Coerce an arbitrary value to the given ioBroker state type. */
3161
3573
  static coerceValue(value, type) {
3162
3574
  if (value === null || value === undefined) {
@@ -3465,18 +3877,35 @@ class McpServer {
3465
3877
  this.removeTransfer(params.upload_id);
3466
3878
  }
3467
3879
  const adapterName = extractAdapterName(params.file_path || params.file_name || upload?.name || params.source || '');
3880
+ // The package is installed at this point: errors from here on are reported, not thrown, so the
3881
+ // caller still learns that the installation happened
3468
3882
  const restartTs = Date.now();
3469
- const restarted = result.success && adapterName && params.restart_instances !== false
3470
- ? await this.restartAdapterInstances(adapterName)
3471
- : [];
3883
+ const restarted = [];
3884
+ let restartError;
3885
+ if (result.success && adapterName && params.restart_instances !== false) {
3886
+ try {
3887
+ await this.restartAdapterInstances(adapterName, restarted);
3888
+ }
3889
+ catch (e) {
3890
+ restartError = `Installed, but the instances could not be restarted: ${e instanceof Error ? e.message : e}`;
3891
+ }
3892
+ }
3472
3893
  const startTimeout = params.start_timeout ?? DEFAULT_START_TIMEOUT;
3894
+ let instances;
3895
+ if (restarted.length && startTimeout) {
3896
+ try {
3897
+ instances = await this.waitForStart(restarted, restartTs, startTimeout);
3898
+ }
3899
+ catch (e) {
3900
+ restartError ??= `Installed, but the start of the instances could not be checked: ${e instanceof Error ? e.message : e}`;
3901
+ }
3902
+ }
3473
3903
  return {
3474
3904
  ...result,
3475
3905
  adapter: adapterName,
3476
3906
  restartedInstances: restarted,
3477
- ...(restarted.length && startTimeout
3478
- ? { instances: await this.waitForStart(restarted, restartTs, startTimeout) }
3479
- : {}),
3907
+ ...(restartError ? { restartError } : {}),
3908
+ ...(instances ? { instances } : {}),
3480
3909
  };
3481
3910
  }
3482
3911
  /**
@@ -3552,6 +3981,7 @@ class McpServer {
3552
3981
  * @param files files sent along with the command (the command refers to them by name)
3553
3982
  */
3554
3983
  async runHostCommand(host, command, timeoutSeconds, files) {
3984
+ await this.assertOtherPermission('execute', `run "iobroker ${command}" on host "${host}"`);
3555
3985
  const id = `mcp-${(0, node_crypto_1.randomUUID)()}`;
3556
3986
  const timeout = (timeoutSeconds ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
3557
3987
  const output = [];
@@ -3597,19 +4027,22 @@ class McpServer {
3597
4027
  });
3598
4028
  });
3599
4029
  }
3600
- /** Like admin after an installation: touching the instance objects makes the controller restart them. */
3601
- async restartAdapterInstances(adapterName) {
4030
+ /**
4031
+ * Like admin after an installation: touching the instance objects makes the controller restart them.
4032
+ *
4033
+ * @param adapterName adapter whose enabled instances are restarted
4034
+ * @param restarted receives the ids of the restarted instances, also those before an error
4035
+ */
4036
+ async restartAdapterInstances(adapterName, restarted) {
3602
4037
  const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
3603
4038
  user: this.user,
3604
4039
  });
3605
- const restarted = [];
3606
4040
  for (const [instanceId, instance] of Object.entries(instances || {})) {
3607
4041
  if (instance?.common?.enabled) {
3608
4042
  await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.user });
3609
4043
  restarted.push(instanceId.replace('system.adapter.', ''));
3610
4044
  }
3611
4045
  }
3612
- return restarted;
3613
4046
  }
3614
4047
  /** Read an instance object by its short id (`hue.0`) or full id (`system.adapter.hue.0`). */
3615
4048
  async getInstance(id) {
@@ -3900,6 +4333,7 @@ class McpServer {
3900
4333
  */
3901
4334
  async runScript(params) {
3902
4335
  const instance = await this.getInstance(params.instance || 'javascript.0');
4336
+ await this.assertOtherPermission('sendto', `run code in "${instance.id}"`);
3903
4337
  const timeout = params.timeout ?? 5000;
3904
4338
  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 });
3905
4339
  if (!result || typeof result !== 'object') {
@@ -3921,6 +4355,7 @@ class McpServer {
3921
4355
  if (!this.isSendToAllowed(instance.id)) {
3922
4356
  throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
3923
4357
  }
4358
+ await this.assertOtherPermission('sendto', `send messages to "${instance.id}"`);
3924
4359
  const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.user });
3925
4360
  return { instance: instance.id, command: params.command, response: response ?? null };
3926
4361
  }