@iobroker/mcp-server 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -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).
@@ -607,15 +612,57 @@ class McpServer {
607
612
  pro: false,
608
613
  };
609
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
+ }
610
654
  /** Handle client -> server messages (initialize, tools/call, ...). */
611
655
  async handleMcpPost(req, res) {
612
656
  const sessionId = req.headers['mcp-session-id'];
613
- let transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
614
- if (!transport) {
615
- if (sessionId) {
616
- 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) {
617
662
  return;
618
663
  }
664
+ }
665
+ else {
619
666
  // No session yet: only an "initialize" request may create one.
620
667
  if (!(0, types_js_1.isInitializeRequest)(req.body)) {
621
668
  res.status(400).json({
@@ -630,8 +677,8 @@ class McpServer {
630
677
  transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
631
678
  sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
632
679
  onsessioninitialized: (sid) => {
633
- this.sessions[sid] = { transport: transport, server, subscriptions };
634
- 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})`);
635
682
  },
636
683
  });
637
684
  transport.onclose = () => {
@@ -644,21 +691,20 @@ class McpServer {
644
691
  };
645
692
  await server.connect(transport);
646
693
  }
647
- await transport.handleRequest(req, res, req.body);
694
+ const sessionTransport = transport;
695
+ await this.runAs(user, () => sessionTransport.handleRequest(req, res, req.body));
648
696
  }
649
697
  /** Handle server -> client SSE stream (GET) and session termination (DELETE). */
650
698
  async handleMcpSessionRequest(req, res) {
651
699
  const sessionId = req.headers['mcp-session-id'];
652
- const transport = sessionId ? this.sessions[sessionId]?.transport : undefined;
653
- if (!transport) {
654
- if (sessionId) {
655
- this.sendSessionNotFound(res, sessionId);
656
- return;
657
- }
700
+ if (!sessionId) {
658
701
  res.status(400).send('Invalid or missing session ID');
659
702
  return;
660
703
  }
661
- 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
+ }
662
708
  }
663
709
  /**
664
710
  * Answer a request of an unknown session, e.g. after a restart of the adapter or when the session was closed.
@@ -1259,7 +1305,7 @@ class McpServer {
1259
1305
  inputSchema: { id: zod_1.z.string().describe('Object ID, e.g. system.adapter.admin.0 or hm-rpc.0.ABC') },
1260
1306
  }, async ({ id }) => {
1261
1307
  try {
1262
- const object = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
1308
+ const object = await this.adapter.getForeignObjectAsync(id, { user: this.user });
1263
1309
  return ok({ ok: true, data: { object: object ?? null } });
1264
1310
  }
1265
1311
  catch (e) {
@@ -1303,7 +1349,7 @@ class McpServer {
1303
1349
  }
1304
1350
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1305
1351
  const file = await this.adapter.readFileAsync(adapterName, fileName, {
1306
- user: this.defaultUser,
1352
+ user: this.user,
1307
1353
  });
1308
1354
  const data = Buffer.isBuffer(file.file) ? file.file : Buffer.from(file.file || '');
1309
1355
  const mimeType = file.mimeType || 'application/octet-stream';
@@ -1348,7 +1394,7 @@ class McpServer {
1348
1394
  try {
1349
1395
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1350
1396
  const exists = await this.adapter.fileExistsAsync(adapterName, fileName, {
1351
- user: this.defaultUser,
1397
+ user: this.user,
1352
1398
  });
1353
1399
  return ok({ ok: true, data: { path, exists: !!exists } });
1354
1400
  }
@@ -1560,7 +1606,7 @@ class McpServer {
1560
1606
  const upload = this.getUpload(upload_id);
1561
1607
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1562
1608
  await this.adapter.writeFileAsync(adapterName, fileName, node_fs_1.default.readFileSync(upload.file), {
1563
- user: this.defaultUser,
1609
+ user: this.user,
1564
1610
  });
1565
1611
  this.removeTransfer(upload_id);
1566
1612
  return ok({ ok: true, data: { path, size: upload.size } });
@@ -1585,7 +1631,7 @@ class McpServer {
1585
1631
  }, async ({ path }) => {
1586
1632
  try {
1587
1633
  const { adapterName, fileName } = McpServer.parseFilePath(path);
1588
- await this.adapter.delFileAsync(adapterName, fileName, { user: this.defaultUser });
1634
+ await this.adapter.delFileAsync(adapterName, fileName, { user: this.user });
1589
1635
  return ok({ ok: true, data: { path, deleted: true } });
1590
1636
  }
1591
1637
  catch (e) {
@@ -1631,7 +1677,7 @@ class McpServer {
1631
1677
  if (!dirName) {
1632
1678
  throw new Error('Path must contain a directory after the adapter, e.g. "0_userdata.0/my-folder"');
1633
1679
  }
1634
- await this.adapter.mkdirAsync(adapterName, dirName, { user: this.defaultUser });
1680
+ await this.adapter.mkdirAsync(adapterName, dirName, { user: this.user });
1635
1681
  return ok({ ok: true, data: { path } });
1636
1682
  }
1637
1683
  catch (e) {
@@ -2206,7 +2252,7 @@ class McpServer {
2206
2252
  mimeType: 'application/json',
2207
2253
  }, async (uri, variables) => {
2208
2254
  const id = decodeURIComponent(String(variables.id));
2209
- const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
2255
+ const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
2210
2256
  const body = state
2211
2257
  ? { id, val: state.val, ack: state.ack, ts: state.ts, lc: state.lc, q: state.q }
2212
2258
  : { id, val: null };
@@ -2218,7 +2264,7 @@ class McpServer {
2218
2264
  mimeType: 'application/json',
2219
2265
  }, async (uri, variables) => {
2220
2266
  const id = decodeURIComponent(String(variables.id));
2221
- const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2267
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2222
2268
  return {
2223
2269
  contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(obj ?? null) }],
2224
2270
  };
@@ -2278,13 +2324,13 @@ class McpServer {
2278
2324
  try {
2279
2325
  if (id.includes('*')) {
2280
2326
  // Wildcard pattern: expand to all matching states.
2281
- const matches = await this.adapter.getForeignStatesAsync(id, { user: this.defaultUser });
2327
+ const matches = await this.adapter.getForeignStatesAsync(id, { user: this.user });
2282
2328
  for (const matchId of Object.keys(matches || {})) {
2283
2329
  states.push(McpServer.stateEntry(matchId, matches[matchId]));
2284
2330
  }
2285
2331
  }
2286
2332
  else {
2287
- const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
2333
+ const state = await this.adapter.getForeignStateAsync(id, { user: this.user });
2288
2334
  states.push(McpServer.stateEntry(id, state));
2289
2335
  }
2290
2336
  }
@@ -2302,7 +2348,7 @@ class McpServer {
2302
2348
  }
2303
2349
  /**
2304
2350
  * Write multiple states as one operation: the write permission of every state is verified first, so a
2305
- * 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
2306
2352
  * written. Other failures of single states are reported per item and do not abort the rest.
2307
2353
  */
2308
2354
  async setStates(items) {
@@ -2320,7 +2366,7 @@ class McpServer {
2320
2366
  return results;
2321
2367
  }
2322
2368
  /**
2323
- * 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
2324
2370
  * `setForeignState`: the user must be able to read the object (needed for the type coercion in
2325
2371
  * {@link setState}) and the state ACL must allow writing — for an alias also on its write target.
2326
2372
  *
@@ -2328,10 +2374,10 @@ class McpServer {
2328
2374
  * @throws {Error} when at least one state may not be written; the message lists all of them
2329
2375
  */
2330
2376
  async assertStatesWritable(ids) {
2331
- if (this.defaultUser === 'system.user.admin') {
2377
+ if (this.user === 'system.user.admin') {
2332
2378
  return;
2333
2379
  }
2334
- const permissions = await this.adapter.calculatePermissionsAsync(this.defaultUser, []);
2380
+ const permissions = await this.adapter.calculatePermissionsAsync(this.user, []);
2335
2381
  if (permissions.groups?.includes('system.group.administrator')) {
2336
2382
  return;
2337
2383
  }
@@ -2339,7 +2385,7 @@ class McpServer {
2339
2385
  for (const id of new Set(ids)) {
2340
2386
  let obj;
2341
2387
  try {
2342
- obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2388
+ obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2343
2389
  }
2344
2390
  catch {
2345
2391
  denied.push(id);
@@ -2358,14 +2404,14 @@ class McpServer {
2358
2404
  }
2359
2405
  }
2360
2406
  if (denied.length) {
2361
- 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`);
2362
2408
  }
2363
2409
  }
2364
2410
  /**
2365
2411
  * The state ACL check of js-controller for `setState` on a single object.
2366
2412
  *
2367
2413
  * @param obj the state object (objects without ACL are not restricted by js-controller)
2368
- * @param permissions the merged group permissions of the default user
2414
+ * @param permissions the merged group permissions of the user
2369
2415
  */
2370
2416
  isStateWritable(obj, permissions) {
2371
2417
  const acl = obj?.acl;
@@ -2377,7 +2423,7 @@ class McpServer {
2377
2423
  return false;
2378
2424
  }
2379
2425
  let mask = 0x002; // everyone write
2380
- if (acl.owner === this.defaultUser) {
2426
+ if (acl.owner === this.user) {
2381
2427
  mask = 0x200; // owner write
2382
2428
  }
2383
2429
  else if (acl.ownerGroup && permissions.groups?.includes(acl.ownerGroup)) {
@@ -2663,13 +2709,13 @@ class McpServer {
2663
2709
  }
2664
2710
  async getSystemInfo() {
2665
2711
  const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${this.adapter.host}`, {
2666
- user: this.defaultUser,
2712
+ user: this.user,
2667
2713
  });
2668
2714
  const jsControllerVersion = hostObj?.common?.installedVersion || 'unknown';
2669
2715
  const totalMem = Math.round(node_os_1.default.totalmem() / (1024 * 1024));
2670
2716
  const freeMem = Math.round(node_os_1.default.freemem() / (1024 * 1024));
2671
2717
  const instanceObjs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
2672
- user: this.defaultUser,
2718
+ user: this.user,
2673
2719
  });
2674
2720
  return {
2675
2721
  js_controller: jsControllerVersion,
@@ -2687,7 +2733,7 @@ class McpServer {
2687
2733
  const result = await this.adapter.getObjectListAsync({
2688
2734
  startkey: '',
2689
2735
  endkey: '\u9999',
2690
- }, { sorted: true, user: this.defaultUser });
2736
+ }, { sorted: true, user: this.user });
2691
2737
  const allObjects = result.rows;
2692
2738
  const roomMembers = room ? await this.getEnumMembers('enum.rooms.', room) : null;
2693
2739
  const needle = query.toLowerCase();
@@ -2723,7 +2769,7 @@ class McpServer {
2723
2769
  }
2724
2770
  async listDevices(params) {
2725
2771
  const lang = params.language || this.language;
2726
- 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 });
2727
2773
  if (params.room) {
2728
2774
  const needle = params.room.toLowerCase();
2729
2775
  return rooms.filter(r => r.roomName.toLowerCase() === needle);
@@ -2753,20 +2799,20 @@ class McpServer {
2753
2799
  options.step = step;
2754
2800
  }
2755
2801
  // GetHistoryOptions has no typed `user` field, but the controller honors it for ACL checks.
2756
- options.user = this.defaultUser;
2802
+ options.user = this.user;
2757
2803
  const res = await this.adapter.getHistoryAsync(params.id, options);
2758
2804
  return { id: params.id, values: res?.result || [] };
2759
2805
  }
2760
2806
  async listInstances() {
2761
2807
  const objs = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance', {
2762
- user: this.defaultUser,
2808
+ user: this.user,
2763
2809
  });
2764
2810
  const result = [];
2765
2811
  for (const [fullId, obj] of Object.entries(objs || {})) {
2766
2812
  const id = fullId.replace('system.adapter.', '');
2767
- const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
2813
+ const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.user });
2768
2814
  const connState = await this.adapter.getForeignStateAsync(`${fullId}.connected`, {
2769
- user: this.defaultUser,
2815
+ user: this.user,
2770
2816
  });
2771
2817
  result.push({
2772
2818
  id,
@@ -2782,7 +2828,7 @@ class McpServer {
2782
2828
  /** List installed adapters (system.adapter.<name> objects, no instances). */
2783
2829
  async listAdapters(language) {
2784
2830
  const lang = language || this.language;
2785
- 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 });
2786
2832
  return view.rows.map(row => {
2787
2833
  const common = (row.value?.common || {});
2788
2834
  return {
@@ -2809,7 +2855,7 @@ class McpServer {
2809
2855
  return [];
2810
2856
  }
2811
2857
  // Determine which adapters are already installed.
2812
- 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 });
2813
2859
  const installed = new Set(installedView.rows.map(row => row.value?.common?.name || row.id.replace('system.adapter.', '')));
2814
2860
  const results = [];
2815
2861
  for (const [name, entry] of Object.entries(json)) {
@@ -2847,10 +2893,10 @@ class McpServer {
2847
2893
  return results;
2848
2894
  }
2849
2895
  async listHosts() {
2850
- 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 });
2851
2897
  const result = [];
2852
2898
  for (const [fullId, obj] of Object.entries(objs || {})) {
2853
- const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.defaultUser });
2899
+ const aliveState = await this.adapter.getForeignStateAsync(`${fullId}.alive`, { user: this.user });
2854
2900
  result.push({
2855
2901
  id: fullId.replace('system.host.', ''),
2856
2902
  alive: !!aliveState?.val,
@@ -2865,7 +2911,7 @@ class McpServer {
2865
2911
  * (ported from the ioBroker n8n node's `readIobEnums`).
2866
2912
  */
2867
2913
  async readEnums(type, language, withIcons) {
2868
- 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 });
2869
2915
  const result = [];
2870
2916
  // Cache member objects so a member shared by several enums is read only once.
2871
2917
  const cache = {};
@@ -2885,7 +2931,7 @@ class McpServer {
2885
2931
  if (obj === undefined) {
2886
2932
  try {
2887
2933
  cache[member] =
2888
- (await this.adapter.getForeignObjectAsync(member, { user: this.defaultUser })) || false;
2934
+ (await this.adapter.getForeignObjectAsync(member, { user: this.user })) || false;
2889
2935
  }
2890
2936
  catch {
2891
2937
  cache[member] = false;
@@ -2914,10 +2960,10 @@ class McpServer {
2914
2960
  }
2915
2961
  /** Write a state, coercing the value to the state's declared type (boolean/number/string). */
2916
2962
  async setState(id, value, ack) {
2917
- const obj = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
2963
+ const obj = await this.adapter.getForeignObjectAsync(id, { user: this.user });
2918
2964
  const stateType = obj?.common?.type;
2919
2965
  const coerced = McpServer.coerceValue(value, stateType);
2920
- await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.defaultUser });
2966
+ await this.adapter.setForeignStateAsync(id, coerced, ack, { user: this.user });
2921
2967
  return coerced;
2922
2968
  }
2923
2969
  /** Coerce an arbitrary value to the given ioBroker state type. */
@@ -2971,7 +3017,7 @@ class McpServer {
2971
3017
  */
2972
3018
  async readFile(path, base64, offset = 0, length = DEFAULT_MAX_READ_BYTES) {
2973
3019
  const { adapterName, fileName } = McpServer.parseFilePath(path);
2974
- const data = await this.adapter.readFileAsync(adapterName, fileName, { user: this.defaultUser });
3020
+ const data = await this.adapter.readFileAsync(adapterName, fileName, { user: this.user });
2975
3021
  const file = data?.file;
2976
3022
  const mimeType = data?.mimeType;
2977
3023
  if (file === undefined || file === null) {
@@ -3017,12 +3063,12 @@ class McpServer {
3017
3063
  async writeFile(path, content, base64) {
3018
3064
  const { adapterName, fileName } = McpServer.parseFilePath(path);
3019
3065
  const data = base64 ? Buffer.from(content, 'base64') : content;
3020
- await this.adapter.writeFileAsync(adapterName, fileName, data, { user: this.defaultUser });
3066
+ await this.adapter.writeFileAsync(adapterName, fileName, data, { user: this.user });
3021
3067
  }
3022
3068
  /** List a directory in an adapter file storage. */
3023
3069
  async listFiles(path) {
3024
3070
  const { adapterName, dirName } = McpServer.parseDirPath(path);
3025
- const entries = await this.adapter.readDirAsync(adapterName, dirName, { user: this.defaultUser });
3071
+ const entries = await this.adapter.readDirAsync(adapterName, dirName, { user: this.user });
3026
3072
  return (entries || []).map(entry => ({
3027
3073
  file: entry.file,
3028
3074
  isDir: !!entry.isDir,
@@ -3037,11 +3083,11 @@ class McpServer {
3037
3083
  if (from.adapterName !== to.adapterName) {
3038
3084
  throw new Error('Renaming across adapter storages is not supported; both paths must share the adapter');
3039
3085
  }
3040
- 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 });
3041
3087
  }
3042
3088
  /** Create or update an object, merging common/native into an existing object (n8n `setIobObject`). */
3043
3089
  async setObject(id, obj) {
3044
- let existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
3090
+ let existing = await this.adapter.getForeignObjectAsync(id, { user: this.user });
3045
3091
  if (existing) {
3046
3092
  if (obj.common) {
3047
3093
  existing.common = { ...existing.common, ...obj.common };
@@ -3053,7 +3099,7 @@ class McpServer {
3053
3099
  else {
3054
3100
  existing = obj;
3055
3101
  }
3056
- return this.adapter.setForeignObjectAsync(id, existing, { user: this.defaultUser });
3102
+ return this.adapter.setForeignObjectAsync(id, existing, { user: this.user });
3057
3103
  }
3058
3104
  /** Split a file path "<adapter>/<dir>/<file>" into adapter name and file name. */
3059
3105
  static parseFilePath(path) {
@@ -3077,15 +3123,15 @@ class McpServer {
3077
3123
  }
3078
3124
  /** Delete an object, optionally with all its children. */
3079
3125
  async deleteObject(id, recursive) {
3080
- const existing = await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser });
3126
+ const existing = await this.adapter.getForeignObjectAsync(id, { user: this.user });
3081
3127
  if (!existing) {
3082
3128
  throw new Error(`Object "${id}" does not exist`);
3083
3129
  }
3084
- await this.adapter.delForeignObjectAsync(id, { user: this.defaultUser, recursive: !!recursive });
3130
+ await this.adapter.delForeignObjectAsync(id, { user: this.user, recursive: !!recursive });
3085
3131
  }
3086
3132
  /** Create a new state object; refuses to overwrite an existing object. */
3087
3133
  async createState(params) {
3088
- const existing = await this.adapter.getForeignObjectAsync(params.id, { user: this.defaultUser });
3134
+ const existing = await this.adapter.getForeignObjectAsync(params.id, { user: this.user });
3089
3135
  if (existing) {
3090
3136
  throw new Error(`Object "${params.id}" already exists - use set_object to modify it`);
3091
3137
  }
@@ -3111,10 +3157,10 @@ class McpServer {
3111
3157
  if (params.desc !== undefined) {
3112
3158
  common.desc = params.desc;
3113
3159
  }
3114
- 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 });
3115
3161
  if (params.def !== undefined) {
3116
3162
  const coerced = McpServer.coerceValue(params.def, common.type);
3117
- await this.adapter.setForeignStateAsync(params.id, coerced, true, { user: this.defaultUser });
3163
+ await this.adapter.setForeignStateAsync(params.id, coerced, true, { user: this.user });
3118
3164
  }
3119
3165
  return { id: params.id };
3120
3166
  }
@@ -3122,7 +3168,7 @@ class McpServer {
3122
3168
  async createScene(params) {
3123
3169
  const instance = params.instance || 'scene.0';
3124
3170
  const instanceObj = await this.adapter.getForeignObjectAsync(`system.adapter.${instance}`, {
3125
- user: this.defaultUser,
3171
+ user: this.user,
3126
3172
  });
3127
3173
  if (!instanceObj) {
3128
3174
  throw new Error(`Scene adapter instance "${instance}" is not installed. Install the ioBroker "scenes" adapter first.`);
@@ -3157,7 +3203,7 @@ class McpServer {
3157
3203
  burstInterval: 0,
3158
3204
  },
3159
3205
  };
3160
- await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.defaultUser });
3206
+ await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.user });
3161
3207
  return { id, members: params.members.length };
3162
3208
  }
3163
3209
  /**
@@ -3195,7 +3241,7 @@ class McpServer {
3195
3241
  let data;
3196
3242
  if (params.file_path !== undefined) {
3197
3243
  const { adapterName, fileName: storageName } = McpServer.parseFilePath(params.file_path);
3198
- const result = await this.adapter.readFileAsync(adapterName, storageName, { user: this.defaultUser });
3244
+ const result = await this.adapter.readFileAsync(adapterName, storageName, { user: this.user });
3199
3245
  data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
3200
3246
  fileName = storageName.split('/').pop() || '';
3201
3247
  }
@@ -3239,7 +3285,7 @@ class McpServer {
3239
3285
  if (!name || /\s/.test(name)) {
3240
3286
  throw new Error(`Invalid host "${name}"`);
3241
3287
  }
3242
- 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 });
3243
3289
  if (hostObj?.type !== 'host') {
3244
3290
  throw new Error(`Host "${name}" not found`);
3245
3291
  }
@@ -3303,12 +3349,12 @@ class McpServer {
3303
3349
  /** Like admin after an installation: touching the instance objects makes the controller restart them. */
3304
3350
  async restartAdapterInstances(adapterName) {
3305
3351
  const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
3306
- user: this.defaultUser,
3352
+ user: this.user,
3307
3353
  });
3308
3354
  const restarted = [];
3309
3355
  for (const [instanceId, instance] of Object.entries(instances || {})) {
3310
3356
  if (instance?.common?.enabled) {
3311
- await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.defaultUser });
3357
+ await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.user });
3312
3358
  restarted.push(instanceId.replace('system.adapter.', ''));
3313
3359
  }
3314
3360
  }
@@ -3321,7 +3367,7 @@ class McpServer {
3321
3367
  throw new Error(`Invalid instance "${id}", expected e.g. "hue.0"`);
3322
3368
  }
3323
3369
  const fullId = `system.adapter.${shortId}`;
3324
- const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.defaultUser });
3370
+ const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.user });
3325
3371
  if (obj?.type !== 'instance') {
3326
3372
  throw new Error(`Instance "${shortId}" not found`);
3327
3373
  }
@@ -3332,7 +3378,7 @@ class McpServer {
3332
3378
  const instance = await this.getInstance(id);
3333
3379
  const changed = !!instance.obj.common.enabled !== enabled;
3334
3380
  if (changed) {
3335
- await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.defaultUser });
3381
+ await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.user });
3336
3382
  }
3337
3383
  return { id: instance.id, enabled, changed };
3338
3384
  }
@@ -3342,7 +3388,7 @@ class McpServer {
3342
3388
  if (!instance.obj.common.enabled) {
3343
3389
  throw new Error(`Instance "${instance.id}" is not enabled - start it instead`);
3344
3390
  }
3345
- await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.defaultUser });
3391
+ await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.user });
3346
3392
  return { id: instance.id, restarted: true };
3347
3393
  }
3348
3394
  /**
@@ -3352,11 +3398,11 @@ class McpServer {
3352
3398
  async setLogLevel(id, level, persistent) {
3353
3399
  const instance = await this.getInstance(id);
3354
3400
  if (persistent) {
3355
- 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 });
3356
3402
  }
3357
3403
  else {
3358
3404
  await this.adapter.setForeignStateAsync(`${instance.fullId}.logLevel`, level, false, {
3359
- user: this.defaultUser,
3405
+ user: this.user,
3360
3406
  });
3361
3407
  }
3362
3408
  return { id: instance.id, level, persistent: !!persistent };
@@ -3377,7 +3423,7 @@ class McpServer {
3377
3423
  const adapterName = McpServer.checkAdapterName(params.adapter);
3378
3424
  const host = await this.resolveHost(params.host);
3379
3425
  if (params.instance !== undefined) {
3380
- 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 });
3381
3427
  if (existing) {
3382
3428
  throw new Error(`Instance "${adapterName}.${params.instance}" already exists`);
3383
3429
  }
@@ -3397,7 +3443,7 @@ class McpServer {
3397
3443
  throw new Error(`Invalid version "${params.version}"`);
3398
3444
  }
3399
3445
  const adapterObj = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}`, {
3400
- user: this.defaultUser,
3446
+ user: this.user,
3401
3447
  });
3402
3448
  if (adapterObj?.type !== 'adapter') {
3403
3449
  throw new Error(`Adapter "${adapterName}" is not installed - use add_instance to install it`);
@@ -3428,9 +3474,9 @@ class McpServer {
3428
3474
  * matters when the user has custom repos active next to the default one.
3429
3475
  */
3430
3476
  async getRepositoryJson() {
3431
- const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
3477
+ const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.user });
3432
3478
  const repositories = (reposObj?.native?.repositories || {});
3433
- const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
3479
+ const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.user });
3434
3480
  const active = sysCfg?.common?.activeRepo;
3435
3481
  const activeNames = Array.isArray(active) ? active : active ? [active] : [];
3436
3482
  const activeWithJson = activeNames.filter(name => repositories[name]?.json);
@@ -3523,7 +3569,7 @@ class McpServer {
3523
3569
  /** Read a script object, failing if it is missing or not a script. */
3524
3570
  async getScriptObject(id) {
3525
3571
  const scriptId = McpServer.scriptId(id);
3526
- const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3572
+ const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.user });
3527
3573
  if (obj?.type !== 'script') {
3528
3574
  throw new Error(`Script "${scriptId}" not found`);
3529
3575
  }
@@ -3532,7 +3578,7 @@ class McpServer {
3532
3578
  /** List the scripts of the javascript adapter (without their source). */
3533
3579
  async listScripts(folder) {
3534
3580
  const prefix = folder ? `script.js.${folder.replace(/^script\.js\./, '').replace(/\.$/, '')}.` : 'script.js.';
3535
- const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.defaultUser });
3581
+ const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.user });
3536
3582
  return Object.entries(objs || {}).map(([id, obj]) => this.scriptInfo(id, obj));
3537
3583
  }
3538
3584
  /**
@@ -3541,7 +3587,7 @@ class McpServer {
3541
3587
  */
3542
3588
  async setScript(params) {
3543
3589
  const scriptId = McpServer.scriptId(params.id);
3544
- const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3590
+ const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.user });
3545
3591
  if (existing && existing.type !== 'script') {
3546
3592
  throw new Error(`"${scriptId}" exists and is not a script`);
3547
3593
  }
@@ -3557,7 +3603,7 @@ class McpServer {
3557
3603
  if (params.enabled !== undefined) {
3558
3604
  common.enabled = params.enabled;
3559
3605
  }
3560
- await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.defaultUser });
3606
+ await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.user });
3561
3607
  return { id: scriptId, created: false };
3562
3608
  }
3563
3609
  const engine = (await this.getInstance(params.instance || 'javascript.0')).id;
@@ -3565,8 +3611,8 @@ class McpServer {
3565
3611
  const segments = scriptId.split('.');
3566
3612
  for (let i = 3; i < segments.length; i++) {
3567
3613
  const folderId = segments.slice(0, i).join('.');
3568
- if (!(await this.adapter.getForeignObjectAsync(folderId, { user: this.defaultUser }))) {
3569
- 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 });
3570
3616
  }
3571
3617
  }
3572
3618
  await this.adapter.setForeignObjectAsync(scriptId, {
@@ -3582,19 +3628,19 @@ class McpServer {
3582
3628
  verbose: false,
3583
3629
  },
3584
3630
  native: {},
3585
- }, { user: this.defaultUser });
3631
+ }, { user: this.user });
3586
3632
  return { id: scriptId, created: true };
3587
3633
  }
3588
3634
  /** Enable (start) or disable (stop) a script. */
3589
3635
  async enableScript(id, enabled) {
3590
3636
  const script = await this.getScriptObject(id);
3591
- await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.defaultUser });
3637
+ await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.user });
3592
3638
  return { id: script.id, enabled };
3593
3639
  }
3594
3640
  /** Delete a script. */
3595
3641
  async deleteScript(id) {
3596
3642
  const script = await this.getScriptObject(id);
3597
- await this.adapter.delForeignObjectAsync(script.id, { user: this.defaultUser });
3643
+ await this.adapter.delForeignObjectAsync(script.id, { user: this.user });
3598
3644
  return { id: script.id, deleted: true };
3599
3645
  }
3600
3646
  /**
@@ -3604,7 +3650,7 @@ class McpServer {
3604
3650
  async runScript(params) {
3605
3651
  const instance = await this.getInstance(params.instance || 'javascript.0');
3606
3652
  const timeout = params.timeout ?? 5000;
3607
- 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 });
3608
3654
  if (!result || typeof result !== 'object') {
3609
3655
  throw new Error(`"${instance.id}" did not answer - javascript adapter 10.1.0 or newer is required`);
3610
3656
  }
@@ -3624,7 +3670,7 @@ class McpServer {
3624
3670
  if (!this.isSendToAllowed(instance.id)) {
3625
3671
  throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
3626
3672
  }
3627
- 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 });
3628
3674
  return { instance: instance.id, command: params.command, response: response ?? null };
3629
3675
  }
3630
3676
  /**
@@ -3636,16 +3682,16 @@ class McpServer {
3636
3682
  if (!/^alias\.\d+\.[^*\s]+$/.test(id)) {
3637
3683
  throw new Error(`Invalid alias id "${params.id}"`);
3638
3684
  }
3639
- if (await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser })) {
3685
+ if (await this.adapter.getForeignObjectAsync(id, { user: this.user })) {
3640
3686
  throw new Error(`Object "${id}" already exists`);
3641
3687
  }
3642
- const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.defaultUser });
3688
+ const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.user });
3643
3689
  if (target?.type !== 'state') {
3644
3690
  throw new Error(`Target "${params.target}" is not a state`);
3645
3691
  }
3646
3692
  if (params.write_target) {
3647
3693
  const writeTarget = await this.adapter.getForeignObjectAsync(params.write_target, {
3648
- user: this.defaultUser,
3694
+ user: this.user,
3649
3695
  });
3650
3696
  if (writeTarget?.type !== 'state') {
3651
3697
  throw new Error(`Write target "${params.write_target}" is not a state`);
@@ -3670,7 +3716,7 @@ class McpServer {
3670
3716
  if (params.unit !== undefined || source.unit !== undefined) {
3671
3717
  common.unit = params.unit ?? source.unit;
3672
3718
  }
3673
- 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 });
3674
3720
  return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
3675
3721
  }
3676
3722
  /**
@@ -3700,6 +3746,7 @@ class McpServer {
3700
3746
  contentType,
3701
3747
  expires: Date.now() + TRANSFER_TTL,
3702
3748
  size: data !== undefined ? Buffer.byteLength(data) : undefined,
3749
+ user: this.user,
3703
3750
  });
3704
3751
  return token;
3705
3752
  }
@@ -3824,10 +3871,13 @@ class McpServer {
3824
3871
  req.pipe(out);
3825
3872
  });
3826
3873
  }
3827
- /** Get a received upload by its id, failing if it is unknown, expired or still empty. */
3874
+ /**
3875
+ * Get a received upload by its id, failing if it is unknown, expired or still empty. An upload of
3876
+ * another user is reported as unknown, so a leaked id gives neither access nor a hint that it exists.
3877
+ */
3828
3878
  getUpload(uploadId) {
3829
3879
  const transfer = this.transfers.get(uploadId);
3830
- if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now()) {
3880
+ if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now() || transfer.user !== this.user) {
3831
3881
  throw new Error(`Unknown or expired upload "${uploadId}" - create a new one with prepare_upload`);
3832
3882
  }
3833
3883
  if (transfer.size === undefined) {
@@ -3838,7 +3888,7 @@ class McpServer {
3838
3888
  // --- helpers ---
3839
3889
  /** Resolve the member ids of a room/function enum matched by id or (localized) name. */
3840
3890
  async getEnumMembers(prefix, nameOrId) {
3841
- const enums = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'enum', { user: this.defaultUser });
3891
+ const enums = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'enum', { user: this.user });
3842
3892
  const needle = nameOrId.toLowerCase();
3843
3893
  for (const [id, obj] of Object.entries(enums || {})) {
3844
3894
  if (id.toLowerCase() === needle || this.getName(obj?.common?.name).toLowerCase() === needle) {