@iobroker/mcp-server 1.6.1 → 1.7.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/README.md CHANGED
@@ -177,8 +177,9 @@ Point your MCP client at:
177
177
 
178
178
  | Tool | Description |
179
179
  |-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
180
- | `get_states` | Retrieve the current value of one or multiple states; IDs may contain wildcards (e.g. `hue.0.*.brightness`) |
180
+ | `get_states` | Retrieve the current value of one or multiple states; IDs may contain wildcards (e.g. `hue.0.*.brightness`); an ID without a state is returned with `exists: false` or a hint |
181
181
  | `get_object` | Read a single object by its ID |
182
+ | `check_permissions` | Show the user and groups of the MCP session, the rights of its groups and, for given object IDs, whether it may read/write/delete the object and read/write the state |
182
183
  | `search_objects` | Search objects/states by keyword (matched against ID and name); optional filters for object `type`, `role`, `room` and source `adapter` instance |
183
184
  | `list_devices` | List detected devices grouped by room (uses the ioBroker type-detector to expose functional devices with named controls); optional `language` and `room` filter |
184
185
  | `list_instances` | List adapter instances with their status |
@@ -191,7 +192,7 @@ Point your MCP client at:
191
192
  | `read_file` | Read a file from an adapter file storage (optional base64); large files are read in chunks via `offset`/`length` (default 512 KiB per call) — the result carries `size`, `truncated` and `nextOffset`; `download: true` returns a download link instead |
192
193
  | `list_files` | List a directory in an adapter file storage |
193
194
  | `file_exists` | Check whether a file exists in an adapter file storage |
194
- | `get_logs` | Retrieve recent ioBroker log lines; optional filters by `level` (error/warn/info/debug), source `adapter` and start time (`from_ts`) |
195
+ | `get_logs` | Retrieve recent ioBroker log lines; optional filters by `level` (error/warn/info/debug), source `adapter` and start time (`from_ts`); `logFile` tells the time of the newest line and the file size |
195
196
  | `write_log` | Write a message to the ioBroker log |
196
197
  | `system_info` | Get system and js-controller information |
197
198
  | `list_adapter_updates` | List installed adapters that have a newer version in the active repository |
@@ -212,7 +213,7 @@ Point your MCP client at:
212
213
  | `rename_file` | Rename/move a file within the same adapter file storage — requires *Allow object/file changes* |
213
214
  | `mkdir` | Create a directory in an adapter file storage — requires *Allow object/file changes* |
214
215
  | `prepare_upload` | Get a one-time upload link for a file that is too large for a tool argument (e.g. a `.tgz`), for clients that can run shell commands — requires *Allow download/upload links* and *Allow object/file changes* or *Allow installing packages* |
215
- | `install_package` | Install an adapter package (npm, GitHub, URL, `.tgz` from the file storage, as base64 or uploaded via `prepare_upload`) on a host and restart its enabled instances — requires *Allow installing packages*, developers only |
216
+ | `install_package` | Install an adapter package (npm, GitHub, URL, `.tgz` from the file storage, as base64 or uploaded via `prepare_upload`) on a host, restart its enabled instances and report whether they came up, with their first log lines — requires *Allow installing packages*, developers only |
216
217
  | `start_instance` | Start (enable) an instance — requires *Allow controlling instances* |
217
218
  | `stop_instance` | Stop (disable) an instance — requires *Allow controlling instances* |
218
219
  | `restart_instance` | Restart a running instance — requires *Allow controlling instances* |
@@ -310,6 +311,17 @@ client calls it and its result is verified:
310
311
  Placeholder for the next version (at the beginning of the line):
311
312
  ### **WORK IN PROGRESS**
312
313
  -->
314
+ ### 1.7.1 (2026-10-26)
315
+ * (@GermanBluefox) Fixed: `get_logs` always returned `size: null` for the log file, because the host sends the size as a string
316
+
317
+ ### 1.7.0 (2026-10-26)
318
+ * (@GermanBluefox) A confirmation that the client dismissed without an answer is no longer reported as "Cancelled by the user", but explains how to turn off the confirmations for clients that cannot show them
319
+ * (@GermanBluefox) `get_states` returns `exists: false` for an unknown ID instead of a `null` value with the current time
320
+ * (@GermanBluefox) A wrong password is logged with the address and the User-Agent of the client
321
+ * (@GermanBluefox) `get_logs` returns the time of the newest line and the size of the log file, to see when the log is no longer written
322
+ * (@GermanBluefox) Added the `check_permissions` tool: what the user of the MCP session may do with an object
323
+ * (@GermanBluefox) `install_package` reports whether the restarted instances came up again and their first log lines
324
+
313
325
  ### 1.6.1 (2026-10-24)
314
326
  * (@GermanBluefox) An upload can only be used by the user that created it with `prepare_upload`
315
327
 
package/build/index.d.ts CHANGED
@@ -96,6 +96,12 @@ export default class McpServer {
96
96
  * anything that could not be tied to an authenticated ioBroker user.
97
97
  */
98
98
  private authGuard;
99
+ /**
100
+ * Log the address and the User-Agent of a request with a rejected password (HTTP Basic auth or the
101
+ * password grant of `POST /oauth/token`), so a client that keeps retrying can be found. Rejected tokens
102
+ * (e.g. expired ones, which clients refresh) are only logged in debug.
103
+ */
104
+ private logFailedLogin;
99
105
  /**
100
106
  * The actual work behind {@link authGuard}: reject unauthenticated requests, and — when OAuth is
101
107
  * enabled — additionally reject tokens that were issued for a different resource.
@@ -216,6 +222,8 @@ export default class McpServer {
216
222
  /** Build the result entry for one state. */
217
223
  private static stateEntry;
218
224
  private getStates;
225
+ /** Build the result entry for an ID without a state: tell a missing object from a state without a value. */
226
+ private missingStateEntry;
219
227
  /**
220
228
  * Write multiple states as one operation: the write permission of every state is verified first, so a
221
229
  * batch containing a single state the user may not write fails as a whole and nothing is
@@ -238,6 +246,23 @@ export default class McpServer {
238
246
  * @param permissions the merged group permissions of the user
239
247
  */
240
248
  private isStateWritable;
249
+ /**
250
+ * The ACL bit check of js-controller: the owner, owner-group or everyone bits of `bits`, depending on
251
+ * who the user of the request is.
252
+ *
253
+ * @param acl the ACL of the object (owner and owner group)
254
+ * @param bits the `object` or `state` bits of the ACL
255
+ * @param flag {@link ACL_READ} or {@link ACL_WRITE} (as the "everyone" bit)
256
+ * @param permissions the merged group permissions of the user
257
+ */
258
+ private aclGrants;
259
+ /**
260
+ * What the user of the request may do: the general rights of its groups and, per object, the result of
261
+ * the ACL checks js-controller runs for reading, writing and deleting the object and its state.
262
+ *
263
+ * @param ids object IDs to check
264
+ */
265
+ private checkPermissions;
241
266
  /**
242
267
  * Parse the raw log-file lines returned by the host `getLogs` into structured entries.
243
268
  *
@@ -248,6 +273,12 @@ export default class McpServer {
248
273
  * without a leading timestamp as continuations (e.g. stack traces) of the previous entry.
249
274
  */
250
275
  private parseLogLines;
276
+ /**
277
+ * Read the tail of the host log file and filter it.
278
+ *
279
+ * @returns the matching lines plus the time of the newest line in the read window and the file size, so a
280
+ * log file that is no longer written (e.g. the host still reads the one before a rotation) shows
281
+ */
251
282
  private getLogs;
252
283
  /**
253
284
  * Diagnose whether a network device/service is reachable: an ICMP ping to `host` and, if `port`
@@ -330,6 +361,15 @@ export default class McpServer {
330
361
  * ioBroker.admin installs from a custom URL or a .tgz file, and wait for the result.
331
362
  */
332
363
  private installPackage;
364
+ /**
365
+ * Wait until restarted instances report `alive` again, then collect their first log lines since the restart.
366
+ * Instances that are not daemons (schedule, once, ...) do not stay alive and are not waited for.
367
+ *
368
+ * @param ids instance ids like `hue.0`
369
+ * @param since time of the restart (ms); only an `alive` and log lines after it count
370
+ * @param timeoutSeconds how long to wait for all instances together
371
+ */
372
+ private waitForStart;
333
373
  /** Normalize a host name (with or without `system.host.`) and check that the host exists. */
334
374
  private resolveHost;
335
375
  /**
package/build/index.js CHANGED
@@ -81,6 +81,16 @@ const DEFAULT_MAX_READ_BYTES = 512 * 1024;
81
81
  const DEFAULT_INSTALL_TIMEOUT = 300;
82
82
  /** How many output lines of an installation are returned to the client. */
83
83
  const INSTALL_OUTPUT_LINES = 100;
84
+ /** Default time `install_package` waits for the restarted instances to come up (seconds). */
85
+ const DEFAULT_START_TIMEOUT = 20;
86
+ /** How many log lines of a restarted instance `install_package` returns. */
87
+ const START_LOG_LINES = 10;
88
+ /** A log whose newest line is older than this (ms) gets a hint that it may no longer be written. */
89
+ const STALE_LOG_AGE = 30 * 60_000;
90
+ /** The "everyone" read bit of an ioBroker ACL; the group and owner bits are shifted by 4 and 8. */
91
+ const ACL_READ = 0x4;
92
+ /** The "everyone" write bit of an ioBroker ACL; the group and owner bits are shifted by 4 and 8. */
93
+ const ACL_WRITE = 0x2;
84
94
  /**
85
95
  * Maximum size of a tool result. MCP clients (Claude.ai / Claude Desktop / Claude Code) reject results above
86
96
  * 1 MiB, measured on the JSON-RPC message, in which the result text is escaped once more.
@@ -384,6 +394,8 @@ class McpServer {
384
394
  // The OAuth2 token endpoint (added by createOAuth2Server) needs its request body parsed.
385
395
  // Scope the parser to that path so the MCP transport keeps its own body handling.
386
396
  app.use('/oauth/token', express_1.default.urlencoded({ extended: false }), express_1.default.json());
397
+ // The OAuth2 server only logs the user name of a wrong password; tell which client it was
398
+ app.use(this.logFailedLogin);
387
399
  this.oauth2 = (0, webserver_1.createOAuth2Server)(this.adapter, {
388
400
  app,
389
401
  secure: !!this.config.secure,
@@ -485,6 +497,38 @@ class McpServer {
485
497
  authGuard = (req, res, next) => {
486
498
  void this.checkAuthentication(req, res, next);
487
499
  };
500
+ /**
501
+ * Log the address and the User-Agent of a request with a rejected password (HTTP Basic auth or the
502
+ * password grant of `POST /oauth/token`), so a client that keeps retrying can be found. Rejected tokens
503
+ * (e.g. expired ones, which clients refresh) are only logged in debug.
504
+ */
505
+ logFailedLogin = (req, res, next) => {
506
+ const authorization = req.headers.authorization;
507
+ const tokenRequest = req.path === '/oauth/token';
508
+ if (authorization || tokenRequest) {
509
+ res.on('finish', () => {
510
+ if (res.statusCode !== 401 && !(tokenRequest && res.statusCode === 400)) {
511
+ return;
512
+ }
513
+ let username;
514
+ if (tokenRequest) {
515
+ const body = req.body;
516
+ username = body?.grant_type === 'password' ? String(body.username) : undefined;
517
+ }
518
+ else if (authorization?.startsWith('Basic ')) {
519
+ username = Buffer.from(authorization.substring(6), 'base64').toString().split(':')[0];
520
+ }
521
+ const client = `${req.ip} (User-Agent: "${req.headers['user-agent'] || 'unknown'}")`;
522
+ if (username !== undefined) {
523
+ this.adapter.log.warn(`Rejected the password of "${username}" from ${client}`);
524
+ }
525
+ else {
526
+ this.adapter.log.debug(`Rejected credentials from ${client} on ${req.method} ${req.path}`);
527
+ }
528
+ });
529
+ }
530
+ next();
531
+ };
488
532
  /**
489
533
  * The actual work behind {@link authGuard}: reject unauthenticated requests, and — when OAuth is
490
534
  * enabled — additionally reject tokens that were issued for a different resource.
@@ -919,26 +963,43 @@ class McpServer {
919
963
  // Ask the user before a dangerous action, if the client can (MCP elicitation). Clients without
920
964
  // elicitation rely on the destructiveHint of the tool instead.
921
965
  const confirm = async (message) => {
922
- if (!this.config.confirmDangerous || !server.server.getClientCapabilities()?.elicitation) {
966
+ if (!this.config.confirmDangerous || !server.server.getClientCapabilities()?.elicitation?.form) {
923
967
  return;
924
968
  }
925
- const answer = await server.server.elicitInput({
926
- message,
927
- requestedSchema: {
928
- type: 'object',
929
- properties: { confirm: { type: 'boolean', title: 'Yes, do it' } },
930
- required: ['confirm'],
931
- },
932
- });
933
- if (answer.action !== 'accept' || answer.content?.confirm !== true) {
934
- throw new Error('Cancelled by the user');
969
+ // Some clients announce elicitation but cannot show the request and dismiss it on their own
970
+ const noDialog = 'If no confirmation dialog appeared, the MCP client cannot show confirmation requests (elicitation): ' +
971
+ 'turn off "Ask for confirmation before dangerous actions" in the settings of the MCP adapter, ' +
972
+ 'or use a client that supports elicitation.';
973
+ let answer;
974
+ try {
975
+ answer = await server.server.elicitInput({
976
+ message,
977
+ requestedSchema: {
978
+ type: 'object',
979
+ properties: { confirm: { type: 'boolean', title: 'Yes, do it' } },
980
+ required: ['confirm'],
981
+ },
982
+ });
983
+ }
984
+ catch (e) {
985
+ throw new Error(`The confirmation request failed: ${e instanceof Error ? e.message : String(e)}. ${noDialog}`);
935
986
  }
987
+ this.adapter.log.debug(`Confirmation "${message}" answered with "${answer.action}"`);
988
+ if (answer.action === 'accept' && answer.content?.confirm === true) {
989
+ return;
990
+ }
991
+ if (answer.action === 'cancel') {
992
+ throw new Error(`The confirmation request was dismissed without an answer. ${noDialog}`);
993
+ }
994
+ throw new Error('Cancelled by the user');
936
995
  };
937
996
  server.registerTool('get_states', {
938
997
  title: 'Get state values',
939
998
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
940
999
  description: 'Retrieve the current value of one or multiple states. IDs may contain wildcards, ' +
941
- 'e.g. "hue.0.*.brightness" expands to all matching states.',
1000
+ 'e.g. "hue.0.*.brightness" expands to all matching states. An ID without a state is ' +
1001
+ 'returned with exists=false (no such object) or with a hint (the object exists, but ' +
1002
+ 'has no value yet or is not a state).',
942
1003
  inputSchema: {
943
1004
  ids: zod_1.z.array(zod_1.z.string()).describe('Array of state IDs (wildcards "*" allowed)'),
944
1005
  offset: offsetSchema,
@@ -1020,7 +1081,9 @@ class McpServer {
1020
1081
  description: 'Retrieve recent ioBroker log lines from the host log file. Each entry has {ts, level, ' +
1021
1082
  'source, message}. Optionally filter by `level` (error/warn/info/debug), by `adapter` ' +
1022
1083
  '(source, e.g. "hm-rpc.0" or "hm-rpc"), or from `from_ts` (ms). When a level filter is set, ' +
1023
- 'a larger window of the log is scanned so errors/warnings are not hidden behind debug spam.',
1084
+ 'a larger window of the log is scanned so errors/warnings are not hidden behind debug spam. ' +
1085
+ '`logFile` tells the time of the newest line in the file and its size: if they do not change ' +
1086
+ 'between calls, the host does not see new log lines.',
1024
1087
  inputSchema: {
1025
1088
  level: zod_1.z.array(zod_1.z.enum(['error', 'warn', 'info', 'debug'])).optional(),
1026
1089
  from_ts: zod_1.z.number().int().optional(),
@@ -1030,9 +1093,19 @@ class McpServer {
1030
1093
  }, async (args) => {
1031
1094
  try {
1032
1095
  // Keep the newest lines if not all of them fit
1033
- const logs = await this.getLogs(args);
1096
+ const { logs, newestTs, fileSize } = await this.getLogs(args);
1034
1097
  const page = fitItems(logs, 0, true);
1035
- return ok({ ok: true, data: { logs: page.items, ...pageInfo(page, logs, 'logs.json') } });
1098
+ const logFile = { newestLineTs: newestTs ?? null, size: fileSize ?? null };
1099
+ if (newestTs !== undefined && Date.now() - newestTs > STALE_LOG_AGE) {
1100
+ logFile.hint =
1101
+ `The newest line of the log file is ${Math.round((Date.now() - newestTs) / 60_000)} ` +
1102
+ 'minutes old. If newer messages are expected, the host may still read an old log file ' +
1103
+ '(e.g. the one before the daily rotation).';
1104
+ }
1105
+ return ok({
1106
+ ok: true,
1107
+ data: { logs: page.items, logFile, ...pageInfo(page, logs, 'logs.json') },
1108
+ });
1036
1109
  }
1037
1110
  catch (e) {
1038
1111
  return fail(e);
@@ -1312,6 +1385,24 @@ class McpServer {
1312
1385
  return fail(e);
1313
1386
  }
1314
1387
  });
1388
+ server.registerTool('check_permissions', {
1389
+ title: 'Check permissions',
1390
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1391
+ description: 'Show what the ioBroker user of this MCP session may do: its user and groups, the general ' +
1392
+ 'rights of its groups (object/state/file/users/other) and, for the given object IDs, whether ' +
1393
+ 'it may read/write/delete the object and read/write the state (by the ACL of the object). ' +
1394
+ 'Use it to find out in advance why an action would fail with a permissionError.',
1395
+ inputSchema: {
1396
+ ids: zod_1.z.array(zod_1.z.string()).max(100).optional().describe('Object IDs to check (no wildcards)'),
1397
+ },
1398
+ }, async ({ ids }) => {
1399
+ try {
1400
+ return ok({ ok: true, data: await this.checkPermissions(ids || []) });
1401
+ }
1402
+ catch (e) {
1403
+ return fail(e);
1404
+ }
1405
+ });
1315
1406
  server.registerTool('read_file', {
1316
1407
  title: 'Read a file',
1317
1408
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -2001,7 +2092,9 @@ class McpServer {
2001
2092
  '`file_name` + `file_content` - a small .tgz as base64; or `upload_id` - a .tgz uploaded ' +
2002
2093
  'via prepare_upload (for large files, if you can run shell commands). Waits until the installation is ' +
2003
2094
  'finished (or `timeout`), returns the exit code and the last output lines. Enabled instances ' +
2004
- 'of the adapter are restarted afterwards; no new instance is created.',
2095
+ 'of the adapter are restarted afterwards; no new instance is created. `instances` tells for ' +
2096
+ 'each restarted instance whether it came up again (alive) within `start_timeout` and its ' +
2097
+ 'first log lines since the restart.',
2005
2098
  inputSchema: {
2006
2099
  source: zod_1.z
2007
2100
  .string()
@@ -2023,6 +2116,13 @@ class McpServer {
2023
2116
  .boolean()
2024
2117
  .optional()
2025
2118
  .describe('Restart enabled instances of the adapter after installing (default true)'),
2119
+ start_timeout: zod_1.z
2120
+ .number()
2121
+ .int()
2122
+ .min(0)
2123
+ .max(300)
2124
+ .optional()
2125
+ .describe(`Seconds to wait for the restarted instances to come up (default ${DEFAULT_START_TIMEOUT}, 0 = do not wait)`),
2026
2126
  timeout: zod_1.z
2027
2127
  .number()
2028
2128
  .int()
@@ -2309,9 +2409,6 @@ class McpServer {
2309
2409
  // ---------------------------------------------------------------------
2310
2410
  /** Build the result entry for one state. */
2311
2411
  static stateEntry(id, state) {
2312
- if (!state) {
2313
- return { id, value: null, ack: false, ts: Date.now() };
2314
- }
2315
2412
  const entry = { id, value: state.val, ack: state.ack, ts: state.ts };
2316
2413
  if (state.lc !== state.ts) {
2317
2414
  entry.lc = state.lc;
@@ -2325,27 +2422,38 @@ class McpServer {
2325
2422
  if (id.includes('*')) {
2326
2423
  // Wildcard pattern: expand to all matching states.
2327
2424
  const matches = await this.adapter.getForeignStatesAsync(id, { user: this.user });
2328
- for (const matchId of Object.keys(matches || {})) {
2329
- states.push(McpServer.stateEntry(matchId, matches[matchId]));
2425
+ for (const [matchId, state] of Object.entries(matches || {})) {
2426
+ if (state) {
2427
+ states.push(McpServer.stateEntry(matchId, state));
2428
+ }
2330
2429
  }
2331
2430
  }
2332
2431
  else {
2333
2432
  const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
2334
- states.push(McpServer.stateEntry(id, state));
2433
+ states.push(state ? McpServer.stateEntry(id, state) : await this.missingStateEntry(id));
2335
2434
  }
2336
2435
  }
2337
2436
  catch (e) {
2338
- states.push({
2339
- id,
2340
- value: null,
2341
- ack: false,
2342
- ts: Date.now(),
2343
- error: e instanceof Error ? e.message : String(e),
2344
- });
2437
+ states.push({ id, value: null, error: e instanceof Error ? e.message : String(e) });
2345
2438
  }
2346
2439
  }
2347
2440
  return states;
2348
2441
  }
2442
+ /** Build the result entry for an ID without a state: tell a missing object from a state without a value. */
2443
+ async missingStateEntry(id) {
2444
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2445
+ if (!obj) {
2446
+ return { id, value: null, exists: false };
2447
+ }
2448
+ return {
2449
+ id,
2450
+ value: null,
2451
+ exists: true,
2452
+ hint: obj.type === 'state'
2453
+ ? 'The object exists, but the state has no value yet'
2454
+ : `"${id}" is a ${obj.type} object, not a state`,
2455
+ };
2456
+ }
2349
2457
  /**
2350
2458
  * Write multiple states as one operation: the write permission of every state is verified first, so a
2351
2459
  * batch containing a single state the user may not write fails as a whole and nothing is
@@ -2419,17 +2527,81 @@ class McpServer {
2419
2527
  if (!acl || !stateAcl) {
2420
2528
  return true;
2421
2529
  }
2422
- if (!permissions.state?.write) {
2423
- return false;
2424
- }
2425
- let mask = 0x002; // everyone write
2530
+ return !!permissions.state?.write && this.aclGrants(acl, stateAcl, ACL_WRITE, permissions);
2531
+ }
2532
+ /**
2533
+ * The ACL bit check of js-controller: the owner, owner-group or everyone bits of `bits`, depending on
2534
+ * who the user of the request is.
2535
+ *
2536
+ * @param acl the ACL of the object (owner and owner group)
2537
+ * @param bits the `object` or `state` bits of the ACL
2538
+ * @param flag {@link ACL_READ} or {@link ACL_WRITE} (as the "everyone" bit)
2539
+ * @param permissions the merged group permissions of the user
2540
+ */
2541
+ aclGrants(acl, bits, flag, permissions) {
2426
2542
  if (acl.owner === this.user) {
2427
- mask = 0x200; // owner write
2543
+ return !!(bits & (flag << 8));
2428
2544
  }
2429
- else if (acl.ownerGroup && permissions.groups?.includes(acl.ownerGroup)) {
2430
- mask = 0x020; // group write
2545
+ if (acl.ownerGroup && permissions.groups?.includes(acl.ownerGroup)) {
2546
+ return !!(bits & (flag << 4));
2431
2547
  }
2432
- return !!(stateAcl & mask);
2548
+ return !!(bits & flag);
2549
+ }
2550
+ /**
2551
+ * What the user of the request may do: the general rights of its groups and, per object, the result of
2552
+ * the ACL checks js-controller runs for reading, writing and deleting the object and its state.
2553
+ *
2554
+ * @param ids object IDs to check
2555
+ */
2556
+ async checkPermissions(ids) {
2557
+ const permissions = await this.adapter.calculatePermissionsAsync(this.user, []);
2558
+ const admin = this.user === 'system.user.admin' || !!permissions.groups?.includes('system.group.administrator');
2559
+ const rights = {
2560
+ object: permissions.object,
2561
+ state: permissions.state,
2562
+ file: permissions.file,
2563
+ users: permissions.users,
2564
+ other: permissions.other,
2565
+ };
2566
+ const objects = [];
2567
+ for (const id of ids) {
2568
+ // Read with the rights of the adapter: the ACL of objects the user may not read is needed as well
2569
+ const obj = await this.adapter.getForeignObjectAsync(id);
2570
+ if (!obj) {
2571
+ objects.push({ id, exists: false });
2572
+ continue;
2573
+ }
2574
+ const acl = obj.acl;
2575
+ const allowed = (general, bits, flag) => admin || (!!general && (!acl || bits === undefined || this.aclGrants(acl, bits, flag, permissions)));
2576
+ const entry = {
2577
+ id,
2578
+ exists: true,
2579
+ type: obj.type,
2580
+ object: {
2581
+ read: allowed(permissions.object?.read, acl?.object, ACL_READ),
2582
+ write: allowed(permissions.object?.write, acl?.object, ACL_WRITE),
2583
+ delete: allowed(permissions.object?.delete, acl?.object, ACL_WRITE),
2584
+ },
2585
+ };
2586
+ if (obj.type === 'state') {
2587
+ const stateBits = acl?.state ?? acl?.object;
2588
+ entry.state = {
2589
+ read: allowed(permissions.state?.read, stateBits, ACL_READ),
2590
+ write: allowed(permissions.state?.write, stateBits, ACL_WRITE),
2591
+ };
2592
+ }
2593
+ if (acl && (admin || entry.object.read)) {
2594
+ entry.acl = { owner: acl.owner, ownerGroup: acl.ownerGroup, object: acl.object, state: acl.state };
2595
+ }
2596
+ objects.push(entry);
2597
+ }
2598
+ return {
2599
+ user: this.user,
2600
+ groups: permissions.groups || [],
2601
+ admin,
2602
+ rights,
2603
+ ...(ids.length ? { objects } : {}),
2604
+ };
2433
2605
  }
2434
2606
  /**
2435
2607
  * Parse the raw log-file lines returned by the host `getLogs` into structured entries.
@@ -2468,6 +2640,12 @@ class McpServer {
2468
2640
  }
2469
2641
  return entries;
2470
2642
  }
2643
+ /**
2644
+ * Read the tail of the host log file and filter it.
2645
+ *
2646
+ * @returns the matching lines plus the time of the newest line in the read window and the file size, so a
2647
+ * log file that is no longer written (e.g. the host still reads the one before a rotation) shows
2648
+ */
2471
2649
  getLogs(params) {
2472
2650
  const limit = Math.max(1, params.limit || 200);
2473
2651
  const filtering = !!((params.level && params.level.length) || params.adapter || params.from_ts !== undefined);
@@ -2485,11 +2663,20 @@ class McpServer {
2485
2663
  reject(new Error(result.error));
2486
2664
  }
2487
2665
  else {
2488
- resolve([]);
2666
+ resolve({ logs: [] });
2489
2667
  }
2490
2668
  return;
2491
2669
  }
2492
2670
  let logs = this.parseLogLines(rawList);
2671
+ const newestTs = logs.length ? logs[logs.length - 1].ts : undefined;
2672
+ // The host appends the size of the log file as the last element: as a string after reading
2673
+ // the file, as a number only if reading failed
2674
+ const last = rawList[rawList.length - 1];
2675
+ const fileSize = typeof last === 'number'
2676
+ ? last
2677
+ : typeof last === 'string' && /^\d+$/.test(last)
2678
+ ? Number(last)
2679
+ : undefined;
2493
2680
  if (params.level && params.level.length) {
2494
2681
  logs = logs.filter(log => params.level.includes(log.level));
2495
2682
  }
@@ -2504,13 +2691,17 @@ class McpServer {
2504
2691
  if (logs.length > limit) {
2505
2692
  logs = logs.slice(logs.length - limit);
2506
2693
  }
2507
- resolve(logs.map(log => ({
2508
- ts: log.ts,
2509
- level: log.level,
2510
- source: log.source,
2511
- message: log.message,
2512
- host: this.adapter.host,
2513
- })));
2694
+ resolve({
2695
+ logs: logs.map(log => ({
2696
+ ts: log.ts,
2697
+ level: log.level,
2698
+ source: log.source,
2699
+ message: log.message,
2700
+ host: this.adapter.host,
2701
+ })),
2702
+ newestTs,
2703
+ fileSize,
2704
+ });
2514
2705
  });
2515
2706
  });
2516
2707
  }
@@ -3274,10 +3465,70 @@ class McpServer {
3274
3465
  this.removeTransfer(params.upload_id);
3275
3466
  }
3276
3467
  const adapterName = extractAdapterName(params.file_path || params.file_name || upload?.name || params.source || '');
3468
+ const restartTs = Date.now();
3277
3469
  const restarted = result.success && adapterName && params.restart_instances !== false
3278
3470
  ? await this.restartAdapterInstances(adapterName)
3279
3471
  : [];
3280
- return { ...result, adapter: adapterName, restartedInstances: restarted };
3472
+ const startTimeout = params.start_timeout ?? DEFAULT_START_TIMEOUT;
3473
+ return {
3474
+ ...result,
3475
+ adapter: adapterName,
3476
+ restartedInstances: restarted,
3477
+ ...(restarted.length && startTimeout
3478
+ ? { instances: await this.waitForStart(restarted, restartTs, startTimeout) }
3479
+ : {}),
3480
+ };
3481
+ }
3482
+ /**
3483
+ * Wait until restarted instances report `alive` again, then collect their first log lines since the restart.
3484
+ * Instances that are not daemons (schedule, once, ...) do not stay alive and are not waited for.
3485
+ *
3486
+ * @param ids instance ids like `hue.0`
3487
+ * @param since time of the restart (ms); only an `alive` and log lines after it count
3488
+ * @param timeoutSeconds how long to wait for all instances together
3489
+ */
3490
+ async waitForStart(ids, since, timeoutSeconds) {
3491
+ const results = [];
3492
+ const deadline = since + timeoutSeconds * 1000;
3493
+ for (const id of ids) {
3494
+ const obj = await this.adapter.getForeignObjectAsync(`system.adapter.${id}`, { user: this.user });
3495
+ const mode = obj?.common?.mode;
3496
+ if (mode && mode !== 'daemon') {
3497
+ results.push({ id, mode, note: `Not checked: a ${mode} instance does not stay running` });
3498
+ continue;
3499
+ }
3500
+ let alive = false;
3501
+ for (;;) {
3502
+ const state = await this.adapter.getForeignStateAsync(`system.adapter.${id}.alive`, {
3503
+ user: this.user,
3504
+ });
3505
+ // `lc`, not `ts`: the heartbeat of the old process refreshes `ts` until it is stopped
3506
+ alive = !!state?.val && (state.lc ?? state.ts) >= since;
3507
+ if (alive || Date.now() >= deadline) {
3508
+ break;
3509
+ }
3510
+ await new Promise(resolve => setTimeout(resolve, 500));
3511
+ }
3512
+ results.push({ id, alive, ...(alive ? {} : { note: `Not alive after ${timeoutSeconds}s` }) });
3513
+ }
3514
+ // The first lines since the restart show whether the start went well
3515
+ try {
3516
+ for (const entry of results) {
3517
+ if (entry.mode) {
3518
+ continue;
3519
+ }
3520
+ const { logs } = await this.getLogs({ adapter: entry.id, from_ts: since, limit: 500 });
3521
+ entry.logs = logs.slice(0, START_LOG_LINES).map(log => ({
3522
+ ts: log.ts,
3523
+ level: log.level,
3524
+ message: log.message,
3525
+ }));
3526
+ }
3527
+ }
3528
+ catch (e) {
3529
+ this.adapter.log.debug(`Cannot read the log after the restart: ${e instanceof Error ? e.message : e}`);
3530
+ }
3531
+ return results;
3281
3532
  }
3282
3533
  /** Normalize a host name (with or without `system.host.`) and check that the host exists. */
3283
3534
  async resolveHost(host) {