@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 +15 -3
- package/build/index.d.ts +40 -0
- package/build/index.js +297 -46
- package/build/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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.
|
|
2329
|
-
|
|
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
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
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
|
-
|
|
2543
|
+
return !!(bits & (flag << 8));
|
|
2428
2544
|
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2545
|
+
if (acl.ownerGroup && permissions.groups?.includes(acl.ownerGroup)) {
|
|
2546
|
+
return !!(bits & (flag << 4));
|
|
2431
2547
|
}
|
|
2432
|
-
return !!(
|
|
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(
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
-
|
|
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) {
|