@iobroker/mcp-server 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createInProcessMcp = exports.McpServer = void 0;
7
+ const node_async_hooks_1 = require("node:async_hooks");
7
8
  const node_crypto_1 = require("node:crypto");
8
9
  const node_os_1 = __importDefault(require("node:os"));
9
10
  const node_net_1 = __importDefault(require("node:net"));
@@ -173,8 +174,10 @@ class McpServer {
173
174
  config;
174
175
  extension;
175
176
  routerPrefix;
176
- /** The ioBroker user whose permissions every MCP request runs with. */
177
+ /** The ioBroker user whose permissions MCP requests run with when no user is logged in. */
177
178
  defaultUser;
179
+ /** The user of the MCP request being processed, set by {@link runAs} for each HTTP request. */
180
+ requestUser = new node_async_hooks_1.AsyncLocalStorage();
178
181
  /** Default language used to localize device/room/function names. */
179
182
  language;
180
183
  /**
@@ -227,11 +230,13 @@ class McpServer {
227
230
  // (e.g. `/mcp/`) within the shared web server, and strip the trailing slash so the route
228
231
  // matches both `/mcp` and `/mcp/` (Express, non-strict).
229
232
  this.mcpPath = this.extension ? this.routerPrefix.replace(/\/$/, '') : '/mcp';
230
- // Determine the ioBroker user whose permissions all MCP requests run with.
231
- // Prefer this adapter's own setting; when embedded, fall back to the host web server's
232
- // default user; finally to "admin". Always normalize to the "system.user." prefix.
233
- const rawUser = this.config.defaultUser || webSettings.defaultUser || 'admin';
234
- this.defaultUser = (rawUser.startsWith('system.user.') ? rawUser : `system.user.${rawUser}`);
233
+ // Determine the ioBroker user whose permissions unauthenticated MCP requests run with.
234
+ // As a web extension the host `web` instance decides about authentication, so its default
235
+ // user wins there; otherwise prefer this adapter's own setting. Finally fall back to "admin".
236
+ const rawUser = (this.extension
237
+ ? webSettings.defaultUser || this.config.defaultUser
238
+ : this.config.defaultUser || webSettings.defaultUser) || 'admin';
239
+ this.defaultUser = McpServer.toUserId(rawUser);
235
240
  this.config.defaultUser = this.defaultUser;
236
241
  this.language = webSettings.language || 'en';
237
242
  // Embedded callers may set the permission toggles explicitly (they don't read the mcp config).
@@ -267,6 +272,9 @@ class McpServer {
267
272
  this.linksUnavailableReason = '';
268
273
  }
269
274
  this.linksAvailable = !this.linksUnavailableReason;
275
+ if (this.linksAvailable) {
276
+ this.removeStaleTransferDirs();
277
+ }
270
278
  // State writes are declared destructive unless turned off explicitly
271
279
  this.config.setStateDestructive = this.config.setStateDestructive !== false;
272
280
  // Authentication is enforced by us only when we run standalone (we own the Express app) and
@@ -604,15 +612,57 @@ class McpServer {
604
612
  pro: false,
605
613
  };
606
614
  }
615
+ /** Normalize a plain user name or a `system.user.*` id to the full id. */
616
+ static toUserId(user) {
617
+ return (user.startsWith('system.user.') ? user : `system.user.${user}`);
618
+ }
619
+ /**
620
+ * The ioBroker user a request runs as: the logged-in user when the request was authenticated (by
621
+ * us or, as a web extension, by the host `web` instance), otherwise the default user.
622
+ */
623
+ getRequestUser(req) {
624
+ const user = req.user;
625
+ return user ? McpServer.toUserId(user) : this.defaultUser;
626
+ }
627
+ /** The user the current tool call runs as. Outside a request (embedded mode) the default user. */
628
+ get user() {
629
+ return this.requestUser.getStore() || this.defaultUser;
630
+ }
631
+ /**
632
+ * Process a request of a session with the permissions of the session's user. The SDK dispatches the
633
+ * contained messages within this call, so every tool reached from here sees that user in {@link user}.
634
+ */
635
+ runAs(user, handle) {
636
+ return this.requestUser.run(user, handle);
637
+ }
638
+ /**
639
+ * Get the session of a request. A session is bound to the user that opened it: requests of another
640
+ * user are answered like an unknown session, so that a leaked session id gives no access.
641
+ */
642
+ getSession(req, res, sessionId) {
643
+ const session = this.sessions[sessionId];
644
+ if (session && session.user !== this.getRequestUser(req)) {
645
+ this.adapter.log.warn(`Rejected MCP request from ${req.ip}: session ${sessionId} belongs to another user than ${this.getRequestUser(req)}`);
646
+ this.sendSessionNotFound(res, sessionId);
647
+ return undefined;
648
+ }
649
+ if (!session) {
650
+ this.sendSessionNotFound(res, sessionId);
651
+ }
652
+ return session;
653
+ }
607
654
  /** Handle client -> server messages (initialize, tools/call, ...). */
608
655
  async handleMcpPost(req, res) {
609
656
  const sessionId = req.headers['mcp-session-id'];
610
- let transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
611
- if (!transport) {
612
- if (sessionId) {
613
- this.sendSessionNotFound(res, sessionId);
657
+ const user = this.getRequestUser(req);
658
+ let transport;
659
+ if (sessionId) {
660
+ transport = this.getSession(req, res, sessionId)?.transport;
661
+ if (!transport) {
614
662
  return;
615
663
  }
664
+ }
665
+ else {
616
666
  // No session yet: only an "initialize" request may create one.
617
667
  if (!(0, types_js_1.isInitializeRequest)(req.body)) {
618
668
  res.status(400).json({
@@ -627,8 +677,8 @@ class McpServer {
627
677
  transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
628
678
  sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
629
679
  onsessioninitialized: (sid) => {
630
- this.sessions[sid] = { transport: transport, server, subscriptions };
631
- this.adapter.log.debug(`MCP session initialized: ${sid}`);
680
+ this.sessions[sid] = { transport: transport, server, subscriptions, user };
681
+ this.adapter.log.debug(`MCP session initialized: ${sid} (user ${user})`);
632
682
  },
633
683
  });
634
684
  transport.onclose = () => {
@@ -641,21 +691,20 @@ class McpServer {
641
691
  };
642
692
  await server.connect(transport);
643
693
  }
644
- await transport.handleRequest(req, res, req.body);
694
+ const sessionTransport = transport;
695
+ await this.runAs(user, () => sessionTransport.handleRequest(req, res, req.body));
645
696
  }
646
697
  /** Handle server -> client SSE stream (GET) and session termination (DELETE). */
647
698
  async handleMcpSessionRequest(req, res) {
648
699
  const sessionId = req.headers['mcp-session-id'];
649
- const transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
650
- if (!transport) {
651
- if (sessionId) {
652
- this.sendSessionNotFound(res, sessionId);
653
- return;
654
- }
700
+ if (!sessionId) {
655
701
  res.status(400).send('Invalid or missing session ID');
656
702
  return;
657
703
  }
658
- await transport.handleRequest(req, res);
704
+ const session = this.getSession(req, res, sessionId);
705
+ if (session) {
706
+ await this.runAs(session.user, () => session.transport.handleRequest(req, res));
707
+ }
659
708
  }
660
709
  /**
661
710
  * Answer a request of an unknown session, e.g. after a restart of the adapter or when the session was closed.
@@ -1256,7 +1305,7 @@ class McpServer {
1256
1305
  inputSchema: { id: zod_1.z.string().describe('Object ID, e.g. system.adapter.admin.0 or hm-rpc.0.ABC') },
1257
1306
  }, async ({ id }) => {
1258
1307
  try {
1259
- const object = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
1308
+ const object = await this.adapter.getForeignObjectAsync(id, { user: this.user });
1260
1309
  return ok({ ok: true, data: { object: object ?? null } });
1261
1310
  }
1262
1311
  catch (e) {
@@ -1300,7 +1349,7 @@ class McpServer {
1300
1349
  }
1301
1350
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1302
1351
  const file = await this.adapter.readFileAsync(adapterName, fileName, {
1303
- user: this.defaultUser,
1352
+ user: this.user,
1304
1353
  });
1305
1354
  const data = Buffer.isBuffer(file.file) ? file.file : Buffer.from(file.file || '');
1306
1355
  const mimeType = file.mimeType || 'application/octet-stream';
@@ -1345,7 +1394,7 @@ class McpServer {
1345
1394
  try {
1346
1395
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1347
1396
  const exists = await this.adapter.fileExistsAsync(adapterName, fileName, {
1348
- user: this.defaultUser,
1397
+ user: this.user,
1349
1398
  });
1350
1399
  return ok({ ok: true, data: { path, exists: !!exists } });
1351
1400
  }
@@ -1557,7 +1606,7 @@ class McpServer {
1557
1606
  const upload = this.getUpload(upload_id);
1558
1607
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1559
1608
  await this.adapter.writeFileAsync(adapterName, fileName, node_fs_1.default.readFileSync(upload.file), {
1560
- user: this.defaultUser,
1609
+ user: this.user,
1561
1610
  });
1562
1611
  this.removeTransfer(upload_id);
1563
1612
  return ok({ ok: true, data: { path, size: upload.size } });
@@ -1582,7 +1631,7 @@ class McpServer {
1582
1631
  }, async ({ path }) => {
1583
1632
  try {
1584
1633
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1585
- await this.adapter.delFileAsync(adapterName, fileName, { user: this.defaultUser });
1634
+ await this.adapter.delFileAsync(adapterName, fileName, { user: this.user });
1586
1635
  return ok({ ok: true, data: { path, deleted: true } });
1587
1636
  }
1588
1637
  catch (e) {
@@ -1628,7 +1677,7 @@ class McpServer {
1628
1677
  if (!dirName) {
1629
1678
  throw new Error('Path must contain a directory after the adapter, e.g. "0_userdata.0/my-folder"');
1630
1679
  }
1631
- await this.adapter.mkdirAsync(adapterName, dirName, { user: this.defaultUser });
1680
+ await this.adapter.mkdirAsync(adapterName, dirName, { user: this.user });
1632
1681
  return ok({ ok: true, data: { path } });
1633
1682
  }
1634
1683
  catch (e) {
@@ -2203,7 +2252,7 @@ class McpServer {
2203
2252
  mimeType: 'application/json',
2204
2253
  }, async (uri, variables) => {
2205
2254
  const id = decodeURIComponent(String(variables.id));
2206
- const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
2255
+ const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
2207
2256
  const body = state
2208
2257
  ? { id, val: state.val, ack: state.ack, ts: state.ts, lc: state.lc, q: state.q }
2209
2258
  : { id, val: null };
@@ -2215,7 +2264,7 @@ class McpServer {
2215
2264
  mimeType: 'application/json',
2216
2265
  }, async (uri, variables) => {
2217
2266
  const id = decodeURIComponent(String(variables.id));
2218
- const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2267
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2219
2268
  return {
2220
2269
  contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(obj ?? null) }],
2221
2270
  };
@@ -2275,13 +2324,13 @@ class McpServer {
2275
2324
  try {
2276
2325
  if (id.includes('*')) {
2277
2326
  // Wildcard pattern: expand to all matching states.
2278
- const matches = await this.adapter.getForeignStatesAsync(id, { user: this.defaultUser });
2327
+ const matches = await this.adapter.getForeignStatesAsync(id, { user: this.user });
2279
2328
  for (const matchId of Object.keys(matches || {})) {
2280
2329
  states.push(McpServer.stateEntry(matchId, matches[matchId]));
2281
2330
  }
2282
2331
  }
2283
2332
  else {
2284
- const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
2333
+ const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
2285
2334
  states.push(McpServer.stateEntry(id, state));
2286
2335
  }
2287
2336
  }
@@ -2299,7 +2348,7 @@ class McpServer {
2299
2348
  }
2300
2349
  /**
2301
2350
  * Write multiple states as one operation: the write permission of every state is verified first, so a
2302
- * batch containing a single state the default user may not write fails as a whole and nothing is
2351
+ * batch containing a single state the user may not write fails as a whole and nothing is
2303
2352
  * written. Other failures of single states are reported per item and do not abort the rest.
2304
2353
  */
2305
2354
  async setStates(items) {
@@ -2317,7 +2366,7 @@ class McpServer {
2317
2366
  return results;
2318
2367
  }
2319
2368
  /**
2320
- * Verify that the default user may write every given state, mirroring the checks js-controller runs in
2369
+ * Verify that the user of the request may write every given state, mirroring the checks js-controller runs in
2321
2370
  * `setForeignState`: the user must be able to read the object (needed for the type coercion in
2322
2371
  * {@link setState}) and the state ACL must allow writing — for an alias also on its write target.
2323
2372
  *
@@ -2325,10 +2374,10 @@ class McpServer {
2325
2374
  * @throws {Error} when at least one state may not be written; the message lists all of them
2326
2375
  */
2327
2376
  async assertStatesWritable(ids) {
2328
- if (this.defaultUser === 'system.user.admin') {
2377
+ if (this.user === 'system.user.admin') {
2329
2378
  return;
2330
2379
  }
2331
- const permissions = await this.adapter.calculatePermissionsAsync(this.defaultUser, []);
2380
+ const permissions = await this.adapter.calculatePermissionsAsync(this.user, []);
2332
2381
  if (permissions.groups?.includes('system.group.administrator')) {
2333
2382
  return;
2334
2383
  }
@@ -2336,7 +2385,7 @@ class McpServer {
2336
2385
  for (const id of new Set(ids)) {
2337
2386
  let obj;
2338
2387
  try {
2339
- obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2388
+ obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2340
2389
  }
2341
2390
  catch {
2342
2391
  denied.push(id);
@@ -2355,14 +2404,14 @@ class McpServer {
2355
2404
  }
2356
2405
  }
2357
2406
  if (denied.length) {
2358
- throw new Error(`permissionError: user "${this.defaultUser}" may not write ${denied.join(', ')} - nothing was written`);
2407
+ throw new Error(`permissionError: user "${this.user}" may not write ${denied.join(', ')} - nothing was written`);
2359
2408
  }
2360
2409
  }
2361
2410
  /**
2362
2411
  * The state ACL check of js-controller for `setState` on a single object.
2363
2412
  *
2364
2413
  * @param obj the state object (objects without ACL are not restricted by js-controller)
2365
- * @param permissions the merged group permissions of the default user
2414
+ * @param permissions the merged group permissions of the user
2366
2415
  */
2367
2416
  isStateWritable(obj, permissions) {
2368
2417
  const acl = obj?.acl;
@@ -2374,7 +2423,7 @@ class McpServer {
2374
2423
  return false;
2375
2424
  }
2376
2425
  let mask = 0x002; // everyone write
2377
- if (acl.owner === this.defaultUser) {
2426
+ if (acl.owner === this.user) {
2378
2427
  mask = 0x200; // owner write
2379
2428
  }
2380
2429
  else if (acl.ownerGroup && permissions.groups?.includes(acl.ownerGroup)) {
@@ -2660,13 +2709,13 @@ class McpServer {
2660
2709
  }
2661
2710
  async getSystemInfo() {
2662
2711
  const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${this.adapter.host}`, {
2663
- user: this.defaultUser,
2712
+ user: this.user,
2664
2713
  });
2665
2714
  const jsControllerVersion = hostObj?.common?.installedVersion || 'unknown';
2666
2715
  const totalMem = Math.round(node_os_1.default.totalmem() / (1024 * 1024));
2667
2716
  const freeMem = Math.round(node_os_1.default.freemem() / (1024 * 1024));
2668
2717
  const instanceObjs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
2669
- user: this.defaultUser,
2718
+ user: this.user,
2670
2719
  });
2671
2720
  return {
2672
2721
  js_controller: jsControllerVersion,
@@ -2684,7 +2733,7 @@ class McpServer {
2684
2733
  const result = await this.adapter.getObjectListAsync({
2685
2734
  startkey: '',
2686
2735
  endkey: '\u9999',
2687
- }, { sorted: true, user: this.defaultUser });
2736
+ }, { sorted: true, user: this.user });
2688
2737
  const allObjects = result.rows;
2689
2738
  const roomMembers = room ? await this.getEnumMembers('enum.rooms.', room) : null;
2690
2739
  const needle = query.toLowerCase();
@@ -2720,7 +2769,7 @@ class McpServer {
2720
2769
  }
2721
2770
  async listDevices(params) {
2722
2771
  const lang = params.language || this.language;
2723
- const rooms = await (0, devices_1.getAiFriendlyStructure)(this.adapter, lang, { user: this.defaultUser });
2772
+ const rooms = await (0, devices_1.getAiFriendlyStructure)(this.adapter, lang, { user: this.user });
2724
2773
  if (params.room) {
2725
2774
  const needle = params.room.toLowerCase();
2726
2775
  return rooms.filter(r => r.roomName.toLowerCase() === needle);
@@ -2750,20 +2799,20 @@ class McpServer {
2750
2799
  options.step = step;
2751
2800
  }
2752
2801
  // GetHistoryOptions has no typed `user` field, but the controller honors it for ACL checks.
2753
- options.user = this.defaultUser;
2802
+ options.user = this.user;
2754
2803
  const res = await this.adapter.getHistoryAsync(params.id, options);
2755
2804
  return { id: params.id, values: res?.result || [] };
2756
2805
  }
2757
2806
  async listInstances() {
2758
2807
  const objs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
2759
- user: this.defaultUser,
2808
+ user: this.user,
2760
2809
  });
2761
2810
  const result = [];
2762
2811
  for (const [fullId, obj] of Object.entries(objs || {})) {
2763
2812
  const id = fullId.replace('system.adapter.', '');
2764
- const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
2813
+ const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.user });
2765
2814
  const connState = await this.adapter.getForeignStateAsync(`${fullId}.connected`, {
2766
- user: this.defaultUser,
2815
+ user: this.user,
2767
2816
  });
2768
2817
  result.push({
2769
2818
  id,
@@ -2779,7 +2828,7 @@ class McpServer {
2779
2828
  /** List installed adapters (system.adapter.<name> objects, no instances). */
2780
2829
  async listAdapters(language) {
2781
2830
  const lang = language || this.language;
2782
- const view = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.defaultUser });
2831
+ const view = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.user });
2783
2832
  return view.rows.map(row => {
2784
2833
  const common = (row.value?.common || {});
2785
2834
  return {
@@ -2806,7 +2855,7 @@ class McpServer {
2806
2855
  return [];
2807
2856
  }
2808
2857
  // Determine which adapters are already installed.
2809
- const installedView = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.defaultUser });
2858
+ const installedView = await this.adapter.getObjectViewAsync('system', 'adapter', { startkey: 'system.adapter.', endkey: 'system.adapter.香' }, { user: this.user });
2810
2859
  const installed = new Set(installedView.rows.map(row => row.value?.common?.name || row.id.replace('system.adapter.', '')));
2811
2860
  const results = [];
2812
2861
  for (const [name, entry] of Object.entries(json)) {
@@ -2844,10 +2893,10 @@ class McpServer {
2844
2893
  return results;
2845
2894
  }
2846
2895
  async listHosts() {
2847
- const objs = await this.adapter.getForeignObjectsAsync('system.host.*', 'host', { user: this.defaultUser });
2896
+ const objs = await this.adapter.getForeignObjectsAsync('system.host.*', 'host', { user: this.user });
2848
2897
  const result = [];
2849
2898
  for (const [fullId, obj] of Object.entries(objs || {})) {
2850
- const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
2899
+ const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.user });
2851
2900
  result.push({
2852
2901
  id: fullId.replace('system.host.', ''),
2853
2902
  alive: !!aliveState?.val,
@@ -2862,7 +2911,7 @@ class McpServer {
2862
2911
  * (ported from the ioBroker n8n node's `readIobEnums`).
2863
2912
  */
2864
2913
  async readEnums(type, language, withIcons) {
2865
- const enums = await this.adapter.getObjectViewAsync('system', 'enum', { startkey: `enum.${type}.`, endkey: `enum.${type}.香` }, { user: this.defaultUser });
2914
+ const enums = await this.adapter.getObjectViewAsync('system', 'enum', { startkey: `enum.${type}.`, endkey: `enum.${type}.香` }, { user: this.user });
2866
2915
  const result = [];
2867
2916
  // Cache member objects so a member shared by several enums is read only once.
2868
2917
  const cache = {};
@@ -2882,7 +2931,7 @@ class McpServer {
2882
2931
  if (obj === undefined) {
2883
2932
  try {
2884
2933
  cache[member] =
2885
- (await this.adapter.getForeignObjectAsync(member, { user: this.defaultUser })) || false;
2934
+ (await this.adapter.getForeignObjectAsync(member, { user: this.user })) || false;
2886
2935
  }
2887
2936
  catch {
2888
2937
  cache[member] = false;
@@ -2911,10 +2960,10 @@ class McpServer {
2911
2960
  }
2912
2961
  /** Write a state, coercing the value to the state's declared type (boolean/number/string). */
2913
2962
  async setState(id, value, ack) {
2914
- const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2963
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2915
2964
  const stateType = obj?.common?.type;
2916
2965
  const coerced = McpServer.coerceValue(value, stateType);
2917
- await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.defaultUser });
2966
+ await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.user });
2918
2967
  return coerced;
2919
2968
  }
2920
2969
  /** Coerce an arbitrary value to the given ioBroker state type. */
@@ -2968,7 +3017,7 @@ class McpServer {
2968
3017
  */
2969
3018
  async readFile(path, base64, offset = 0, length = DEFAULT_MAX_READ_BYTES) {
2970
3019
  const { adapterName, fileName } = McpServer.parseFilePath(path);
2971
- const data = await this.adapter.readFileAsync(adapterName, fileName, { user: this.defaultUser });
3020
+ const data = await this.adapter.readFileAsync(adapterName, fileName, { user: this.user });
2972
3021
  const file = data?.file;
2973
3022
  const mimeType = data?.mimeType;
2974
3023
  if (file === undefined || file === null) {
@@ -3014,12 +3063,12 @@ class McpServer {
3014
3063
  async writeFile(path, content, base64) {
3015
3064
  const { adapterName, fileName } = McpServer.parseFilePath(path);
3016
3065
  const data = base64 ? Buffer.from(content, 'base64') : content;
3017
- await this.adapter.writeFileAsync(adapterName, fileName, data, { user: this.defaultUser });
3066
+ await this.adapter.writeFileAsync(adapterName, fileName, data, { user: this.user });
3018
3067
  }
3019
3068
  /** List a directory in an adapter file storage. */
3020
3069
  async listFiles(path) {
3021
3070
  const { adapterName, dirName } = McpServer.parseDirPath(path);
3022
- const entries = await this.adapter.readDirAsync(adapterName, dirName, { user: this.defaultUser });
3071
+ const entries = await this.adapter.readDirAsync(adapterName, dirName, { user: this.user });
3023
3072
  return (entries || []).map(entry => ({
3024
3073
  file: entry.file,
3025
3074
  isDir: !!entry.isDir,
@@ -3034,11 +3083,11 @@ class McpServer {
3034
3083
  if (from.adapterName !== to.adapterName) {
3035
3084
  throw new Error('Renaming across adapter storages is not supported; both paths must share the adapter');
3036
3085
  }
3037
- await this.adapter.renameAsync(from.adapterName, from.fileName, to.fileName, { user: this.defaultUser });
3086
+ await this.adapter.renameAsync(from.adapterName, from.fileName, to.fileName, { user: this.user });
3038
3087
  }
3039
3088
  /** Create or update an object, merging common/native into an existing object (n8n `setIobObject`). */
3040
3089
  async setObject(id, obj) {
3041
- let existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
3090
+ let existing = await this.adapter.getForeignObjectAsync(id, { user: this.user });
3042
3091
  if (existing) {
3043
3092
  if (obj.common) {
3044
3093
  existing.common = { ...existing.common, ...obj.common };
@@ -3050,7 +3099,7 @@ class McpServer {
3050
3099
  else {
3051
3100
  existing = obj;
3052
3101
  }
3053
- return this.adapter.setForeignObjectAsync(id, existing, { user: this.defaultUser });
3102
+ return this.adapter.setForeignObjectAsync(id, existing, { user: this.user });
3054
3103
  }
3055
3104
  /** Split a file path "<adapter>/<dir>/<file>" into adapter name and file name. */
3056
3105
  static parseFilePath(path) {
@@ -3074,15 +3123,15 @@ class McpServer {
3074
3123
  }
3075
3124
  /** Delete an object, optionally with all its children. */
3076
3125
  async deleteObject(id, recursive) {
3077
- const existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
3126
+ const existing = await this.adapter.getForeignObjectAsync(id, { user: this.user });
3078
3127
  if (!existing) {
3079
3128
  throw new Error(`Object "${id}" does not exist`);
3080
3129
  }
3081
- await this.adapter.delForeignObjectAsync(id, { user: this.defaultUser, recursive: !!recursive });
3130
+ await this.adapter.delForeignObjectAsync(id, { user: this.user, recursive: !!recursive });
3082
3131
  }
3083
3132
  /** Create a new state object; refuses to overwrite an existing object. */
3084
3133
  async createState(params) {
3085
- const existing = await this.adapter.getForeignObjectAsync(params.id, { user: this.defaultUser });
3134
+ const existing = await this.adapter.getForeignObjectAsync(params.id, { user: this.user });
3086
3135
  if (existing) {
3087
3136
  throw new Error(`Object "${params.id}" already exists - use set_object to modify it`);
3088
3137
  }
@@ -3108,10 +3157,10 @@ class McpServer {
3108
3157
  if (params.desc !== undefined) {
3109
3158
  common.desc = params.desc;
3110
3159
  }
3111
- await this.adapter.setForeignObjectAsync(params.id, { type: 'state', common, native: {} }, { user: this.defaultUser });
3160
+ await this.adapter.setForeignObjectAsync(params.id, { type: 'state', common, native: {} }, { user: this.user });
3112
3161
  if (params.def !== undefined) {
3113
3162
  const coerced = McpServer.coerceValue(params.def, common.type);
3114
- await this.adapter.setForeignStateAsync(params.id, coerced, true, { user: this.defaultUser });
3163
+ await this.adapter.setForeignStateAsync(params.id, coerced, true, { user: this.user });
3115
3164
  }
3116
3165
  return { id: params.id };
3117
3166
  }
@@ -3119,7 +3168,7 @@ class McpServer {
3119
3168
  async createScene(params) {
3120
3169
  const instance = params.instance || 'scene.0';
3121
3170
  const instanceObj = await this.adapter.getForeignObjectAsync(`system.adapter.${instance}`, {
3122
- user: this.defaultUser,
3171
+ user: this.user,
3123
3172
  });
3124
3173
  if (!instanceObj) {
3125
3174
  throw new Error(`Scene adapter instance "${instance}" is not installed. Install the ioBroker "scenes" adapter first.`);
@@ -3154,7 +3203,7 @@ class McpServer {
3154
3203
  burstInterval: 0,
3155
3204
  },
3156
3205
  };
3157
- await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.defaultUser });
3206
+ await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.user });
3158
3207
  return { id, members: params.members.length };
3159
3208
  }
3160
3209
  /**
@@ -3192,7 +3241,7 @@ class McpServer {
3192
3241
  let data;
3193
3242
  if (params.file_path !== undefined) {
3194
3243
  const { adapterName, fileName: storageName } = McpServer.parseFilePath(params.file_path);
3195
- const result = await this.adapter.readFileAsync(adapterName, storageName, { user: this.defaultUser });
3244
+ const result = await this.adapter.readFileAsync(adapterName, storageName, { user: this.user });
3196
3245
  data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
3197
3246
  fileName = storageName.split('/').pop() || '';
3198
3247
  }
@@ -3236,7 +3285,7 @@ class McpServer {
3236
3285
  if (!name || /\s/.test(name)) {
3237
3286
  throw new Error(`Invalid host "${name}"`);
3238
3287
  }
3239
- const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${name}`, { user: this.defaultUser });
3288
+ const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${name}`, { user: this.user });
3240
3289
  if (hostObj?.type !== 'host') {
3241
3290
  throw new Error(`Host "${name}" not found`);
3242
3291
  }
@@ -3300,12 +3349,12 @@ class McpServer {
3300
3349
  /** Like admin after an installation: touching the instance objects makes the controller restart them. */
3301
3350
  async restartAdapterInstances(adapterName) {
3302
3351
  const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
3303
- user: this.defaultUser,
3352
+ user: this.user,
3304
3353
  });
3305
3354
  const restarted = [];
3306
3355
  for (const [instanceId, instance] of Object.entries(instances || {})) {
3307
3356
  if (instance?.common?.enabled) {
3308
- await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.defaultUser });
3357
+ await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.user });
3309
3358
  restarted.push(instanceId.replace('system.adapter.', ''));
3310
3359
  }
3311
3360
  }
@@ -3318,7 +3367,7 @@ class McpServer {
3318
3367
  throw new Error(`Invalid instance "${id}", expected e.g. "hue.0"`);
3319
3368
  }
3320
3369
  const fullId = `system.adapter.${shortId}`;
3321
- const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.defaultUser });
3370
+ const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.user });
3322
3371
  if (obj?.type !== 'instance') {
3323
3372
  throw new Error(`Instance "${shortId}" not found`);
3324
3373
  }
@@ -3329,7 +3378,7 @@ class McpServer {
3329
3378
  const instance = await this.getInstance(id);
3330
3379
  const changed = !!instance.obj.common.enabled !== enabled;
3331
3380
  if (changed) {
3332
- await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.defaultUser });
3381
+ await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.user });
3333
3382
  }
3334
3383
  return { id: instance.id, enabled, changed };
3335
3384
  }
@@ -3339,7 +3388,7 @@ class McpServer {
3339
3388
  if (!instance.obj.common.enabled) {
3340
3389
  throw new Error(`Instance "${instance.id}" is not enabled - start it instead`);
3341
3390
  }
3342
- await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.defaultUser });
3391
+ await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.user });
3343
3392
  return { id: instance.id, restarted: true };
3344
3393
  }
3345
3394
  /**
@@ -3349,11 +3398,11 @@ class McpServer {
3349
3398
  async setLogLevel(id, level, persistent) {
3350
3399
  const instance = await this.getInstance(id);
3351
3400
  if (persistent) {
3352
- await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { loglevel: level } }, { user: this.defaultUser });
3401
+ await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { loglevel: level } }, { user: this.user });
3353
3402
  }
3354
3403
  else {
3355
3404
  await this.adapter.setForeignStateAsync(`${instance.fullId}.logLevel`, level, false, {
3356
- user: this.defaultUser,
3405
+ user: this.user,
3357
3406
  });
3358
3407
  }
3359
3408
  return { id: instance.id, level, persistent: !!persistent };
@@ -3374,7 +3423,7 @@ class McpServer {
3374
3423
  const adapterName = McpServer.checkAdapterName(params.adapter);
3375
3424
  const host = await this.resolveHost(params.host);
3376
3425
  if (params.instance !== undefined) {
3377
- const existing = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}.${params.instance}`, { user: this.defaultUser });
3426
+ const existing = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}.${params.instance}`, { user: this.user });
3378
3427
  if (existing) {
3379
3428
  throw new Error(`Instance "${adapterName}.${params.instance}" already exists`);
3380
3429
  }
@@ -3394,7 +3443,7 @@ class McpServer {
3394
3443
  throw new Error(`Invalid version "${params.version}"`);
3395
3444
  }
3396
3445
  const adapterObj = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}`, {
3397
- user: this.defaultUser,
3446
+ user: this.user,
3398
3447
  });
3399
3448
  if (adapterObj?.type !== 'adapter') {
3400
3449
  throw new Error(`Adapter "${adapterName}" is not installed - use add_instance to install it`);
@@ -3425,9 +3474,9 @@ class McpServer {
3425
3474
  * matters when the user has custom repos active next to the default one.
3426
3475
  */
3427
3476
  async getRepositoryJson() {
3428
- const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
3477
+ const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.user });
3429
3478
  const repositories = (reposObj?.native?.repositories || {});
3430
- const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
3479
+ const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.user });
3431
3480
  const active = sysCfg?.common?.activeRepo;
3432
3481
  const activeNames = Array.isArray(active) ? active : active ? [active] : [];
3433
3482
  const activeWithJson = activeNames.filter(name => repositories[name]?.json);
@@ -3520,7 +3569,7 @@ class McpServer {
3520
3569
  /** Read a script object, failing if it is missing or not a script. */
3521
3570
  async getScriptObject(id) {
3522
3571
  const scriptId = McpServer.scriptId(id);
3523
- const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3572
+ const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.user });
3524
3573
  if (obj?.type !== 'script') {
3525
3574
  throw new Error(`Script "${scriptId}" not found`);
3526
3575
  }
@@ -3529,7 +3578,7 @@ class McpServer {
3529
3578
  /** List the scripts of the javascript adapter (without their source). */
3530
3579
  async listScripts(folder) {
3531
3580
  const prefix = folder ? `script.js.${folder.replace(/^script\.js\./, '').replace(/\.$/, '')}.` : 'script.js.';
3532
- const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.defaultUser });
3581
+ const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.user });
3533
3582
  return Object.entries(objs || {}).map(([id, obj]) => this.scriptInfo(id, obj));
3534
3583
  }
3535
3584
  /**
@@ -3538,7 +3587,7 @@ class McpServer {
3538
3587
  */
3539
3588
  async setScript(params) {
3540
3589
  const scriptId = McpServer.scriptId(params.id);
3541
- const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3590
+ const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.user });
3542
3591
  if (existing && existing.type !== 'script') {
3543
3592
  throw new Error(`"${scriptId}" exists and is not a script`);
3544
3593
  }
@@ -3554,7 +3603,7 @@ class McpServer {
3554
3603
  if (params.enabled !== undefined) {
3555
3604
  common.enabled = params.enabled;
3556
3605
  }
3557
- await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.defaultUser });
3606
+ await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.user });
3558
3607
  return { id: scriptId, created: false };
3559
3608
  }
3560
3609
  const engine = (await this.getInstance(params.instance || 'javascript.0')).id;
@@ -3562,8 +3611,8 @@ class McpServer {
3562
3611
  const segments = scriptId.split('.');
3563
3612
  for (let i = 3; i < segments.length; i++) {
3564
3613
  const folderId = segments.slice(0, i).join('.');
3565
- if (!(await this.adapter.getForeignObjectAsync(folderId, { user: this.defaultUser }))) {
3566
- await this.adapter.setForeignObjectAsync(folderId, { type: 'channel', common: { name: segments[i - 1] }, native: {} }, { user: this.defaultUser });
3614
+ if (!(await this.adapter.getForeignObjectAsync(folderId, { user: this.user }))) {
3615
+ await this.adapter.setForeignObjectAsync(folderId, { type: 'channel', common: { name: segments[i - 1] }, native: {} }, { user: this.user });
3567
3616
  }
3568
3617
  }
3569
3618
  await this.adapter.setForeignObjectAsync(scriptId, {
@@ -3579,19 +3628,19 @@ class McpServer {
3579
3628
  verbose: false,
3580
3629
  },
3581
3630
  native: {},
3582
- }, { user: this.defaultUser });
3631
+ }, { user: this.user });
3583
3632
  return { id: scriptId, created: true };
3584
3633
  }
3585
3634
  /** Enable (start) or disable (stop) a script. */
3586
3635
  async enableScript(id, enabled) {
3587
3636
  const script = await this.getScriptObject(id);
3588
- await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.defaultUser });
3637
+ await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.user });
3589
3638
  return { id: script.id, enabled };
3590
3639
  }
3591
3640
  /** Delete a script. */
3592
3641
  async deleteScript(id) {
3593
3642
  const script = await this.getScriptObject(id);
3594
- await this.adapter.delForeignObjectAsync(script.id, { user: this.defaultUser });
3643
+ await this.adapter.delForeignObjectAsync(script.id, { user: this.user });
3595
3644
  return { id: script.id, deleted: true };
3596
3645
  }
3597
3646
  /**
@@ -3601,7 +3650,7 @@ class McpServer {
3601
3650
  async runScript(params) {
3602
3651
  const instance = await this.getInstance(params.instance || 'javascript.0');
3603
3652
  const timeout = params.timeout ?? 5000;
3604
- 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.defaultUser });
3653
+ 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 });
3605
3654
  if (!result || typeof result !== 'object') {
3606
3655
  throw new Error(`"${instance.id}" did not answer - javascript adapter 10.1.0 or newer is required`);
3607
3656
  }
@@ -3621,7 +3670,7 @@ class McpServer {
3621
3670
  if (!this.isSendToAllowed(instance.id)) {
3622
3671
  throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
3623
3672
  }
3624
- const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.defaultUser });
3673
+ const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.user });
3625
3674
  return { instance: instance.id, command: params.command, response: response ?? null };
3626
3675
  }
3627
3676
  /**
@@ -3633,16 +3682,16 @@ class McpServer {
3633
3682
  if (!/^alias\.\d+\.[^*\s]+$/.test(id)) {
3634
3683
  throw new Error(`Invalid alias id "${params.id}"`);
3635
3684
  }
3636
- if (await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser })) {
3685
+ if (await this.adapter.getForeignObjectAsync(id, { user: this.user })) {
3637
3686
  throw new Error(`Object "${id}" already exists`);
3638
3687
  }
3639
- const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.defaultUser });
3688
+ const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.user });
3640
3689
  if (target?.type !== 'state') {
3641
3690
  throw new Error(`Target "${params.target}" is not a state`);
3642
3691
  }
3643
3692
  if (params.write_target) {
3644
3693
  const writeTarget = await this.adapter.getForeignObjectAsync(params.write_target, {
3645
- user: this.defaultUser,
3694
+ user: this.user,
3646
3695
  });
3647
3696
  if (writeTarget?.type !== 'state') {
3648
3697
  throw new Error(`Write target "${params.write_target}" is not a state`);
@@ -3667,7 +3716,7 @@ class McpServer {
3667
3716
  if (params.unit !== undefined || source.unit !== undefined) {
3668
3717
  common.unit = params.unit ?? source.unit;
3669
3718
  }
3670
- await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.defaultUser });
3719
+ await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.user });
3671
3720
  return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
3672
3721
  }
3673
3722
  /**
@@ -3684,7 +3733,8 @@ class McpServer {
3684
3733
  const token = (0, node_crypto_1.randomBytes)(32).toString('hex');
3685
3734
  // One folder per link, so the file keeps its name (`npm` needs the .tgz extension)
3686
3735
  const folder = node_path_1.default.join(this.transferDir, token);
3687
- node_fs_1.default.mkdirSync(folder);
3736
+ // recursive: another MCP server may have removed our folder as stale (see removeStaleTransferDirs)
3737
+ node_fs_1.default.mkdirSync(folder, { recursive: true });
3688
3738
  const file = node_path_1.default.join(folder, name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'file');
3689
3739
  if (data !== undefined) {
3690
3740
  node_fs_1.default.writeFileSync(file, data);
@@ -3699,6 +3749,35 @@ class McpServer {
3699
3749
  });
3700
3750
  return token;
3701
3751
  }
3752
+ /**
3753
+ * Remove the transfer folders that a process left behind when it was killed. Other MCP servers on this
3754
+ * host (web extension, standalone, admin) use the same temp folder, so only folders unchanged for longer
3755
+ * than a link lives are removed: every new link creates a subfolder and so touches its folder.
3756
+ */
3757
+ removeStaleTransferDirs() {
3758
+ const tmp = node_os_1.default.tmpdir();
3759
+ try {
3760
+ for (const name of node_fs_1.default.readdirSync(tmp)) {
3761
+ if (!name.startsWith('iobroker-mcp-')) {
3762
+ continue;
3763
+ }
3764
+ const dir = node_path_1.default.join(tmp, name);
3765
+ try {
3766
+ const stat = node_fs_1.default.statSync(dir);
3767
+ if (stat.isDirectory() && Date.now() - stat.mtimeMs > TRANSFER_TTL) {
3768
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
3769
+ this.adapter.log.debug(`Removed stale transfer folder "${dir}"`);
3770
+ }
3771
+ }
3772
+ catch (e) {
3773
+ this.adapter.log.debug(`Cannot remove stale transfer folder "${dir}": ${e}`);
3774
+ }
3775
+ }
3776
+ }
3777
+ catch (e) {
3778
+ this.adapter.log.debug(`Cannot read "${tmp}": ${e}`);
3779
+ }
3780
+ }
3702
3781
  /** The public URL of a download or upload link. */
3703
3782
  transferUrl(baseUrl, token) {
3704
3783
  return `${baseUrl}${this.mcpPath}/transfer/${token}`;
@@ -3805,7 +3884,7 @@ class McpServer {
3805
3884
  // --- helpers ---
3806
3885
  /** Resolve the member ids of a room/function enum matched by id or (localized) name. */
3807
3886
  async getEnumMembers(prefix, nameOrId) {
3808
- const enums = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'enum', { user: this.defaultUser });
3887
+ const enums = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'enum', { user: this.user });
3809
3888
  const needle = nameOrId.toLowerCase();
3810
3889
  for (const [id, obj] of Object.entries(enums || {})) {
3811
3890
  if (id.toLowerCase() === needle || this.getName(obj?.common?.name).toLowerCase() === needle) {