@iobroker/mcp-server 1.2.3 → 1.4.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
@@ -18,6 +18,7 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamable
18
18
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
19
19
  const devices_1 = require("./devices");
20
20
  const iob_uri_1 = require("./iob-uri");
21
+ const types_1 = require("./types");
21
22
  const inProcessClient_1 = require("./inProcessClient");
22
23
  Object.defineProperty(exports, "createInProcessMcp", { enumerable: true, get: function () { return inProcessClient_1.createInProcessMcp; } });
23
24
  const package_json_1 = __importDefault(require("../package.json"));
@@ -74,6 +75,53 @@ const LOG_BUFFER_SIZE = 200;
74
75
  * JSON string (escaping) or base64 (+33%), so 512 KiB of raw data keeps the wrapped result safely below that cap.
75
76
  */
76
77
  const DEFAULT_MAX_READ_BYTES = 512 * 1024;
78
+ /** Default time `install_package` waits for the installation to finish (seconds). */
79
+ const DEFAULT_INSTALL_TIMEOUT = 300;
80
+ /** How many output lines of an installation are returned to the client. */
81
+ const INSTALL_OUTPUT_LINES = 100;
82
+ /**
83
+ * Extract the adapter name from an install source, like `iobroker.admin-7.8.6.tgz`,
84
+ * `ioBroker/ioBroker.admin#main`, `iobroker.admin@7.8.6` or `admin`. Same rules as in ioBroker.admin.
85
+ */
86
+ function extractAdapterName(source) {
87
+ // cut off the query string and the anchor, like in `.../ioBroker.xyz/tarball/main?token=1`
88
+ const url = source.trim().split('?')[0].split('#')[0];
89
+ const packed = url.match(/iobroker\.([a-z\d][a-z\d_-]*?)-\d+\.\d+\.\d+[^/\\]*\.tgz$/i);
90
+ if (packed) {
91
+ return packed[1].toLowerCase();
92
+ }
93
+ const named = url.match(/iobroker\.([a-z\d][a-z\d_-]*)/i);
94
+ if (named) {
95
+ return named[1].toLowerCase();
96
+ }
97
+ if (/^[a-z\d][a-z\d_-]*$/i.test(url)) {
98
+ return url.toLowerCase();
99
+ }
100
+ return null;
101
+ }
102
+ /**
103
+ * Compare two versions like `3.9.0` and `3.10.0-alpha.1`: > 0 if `a` is newer, < 0 if `b` is newer.
104
+ * A pre-release is older than the release with the same numbers.
105
+ */
106
+ function compareVersions(a, b) {
107
+ const [mainA, preA] = (a || '0').split('-', 2);
108
+ const [mainB, preB] = (b || '0').split('-', 2);
109
+ const partsA = mainA.split('.').map(part => parseInt(part, 10) || 0);
110
+ const partsB = mainB.split('.').map(part => parseInt(part, 10) || 0);
111
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
112
+ const diff = (partsA[i] || 0) - (partsB[i] || 0);
113
+ if (diff) {
114
+ return diff;
115
+ }
116
+ }
117
+ if (preA === preB) {
118
+ return 0;
119
+ }
120
+ if (!preA || !preB) {
121
+ return preA ? -1 : 1;
122
+ }
123
+ return preA.localeCompare(preB, undefined, { numeric: true });
124
+ }
77
125
  class McpServer {
78
126
  adapter;
79
127
  /** The Express app we attach routes to. Undefined in embedded in-process mode (no HTTP). */
@@ -109,7 +157,11 @@ class McpServer {
109
157
  oauthResource;
110
158
  /** OAuth2 login/token server, created only when {@link authRequired} is true. */
111
159
  oauth2;
112
- constructor(server, webSettings, adapter, instanceSettings, app) {
160
+ /** Installations started by `install_package`, keyed by the `cmdExec` session id. */
161
+ cmdSessions = new Map();
162
+ constructor(server,
163
+ // The permission toggles are explicit overrides for embedded mode (see `McpPermissions`)
164
+ webSettings, adapter, instanceSettings, app) {
113
165
  this.app = app;
114
166
  this.adapter = adapter;
115
167
  // Clone the source config so we never mutate the host adapter's config object — important in
@@ -127,18 +179,22 @@ class McpServer {
127
179
  this.config.defaultUser = this.defaultUser;
128
180
  this.language = webSettings.language || 'en';
129
181
  // Embedded callers may set the permission toggles explicitly (they don't read the mcp config).
130
- if (webSettings.allowSetState !== undefined) {
131
- this.config.allowSetState = webSettings.allowSetState;
132
- }
133
- if (webSettings.allowObjectChange !== undefined) {
134
- this.config.allowObjectChange = webSettings.allowObjectChange;
135
- }
136
- if (webSettings.setStateDestructive !== undefined) {
137
- this.config.setStateDestructive = webSettings.setStateDestructive;
182
+ for (const key of types_1.PERMISSION_KEYS) {
183
+ if (webSettings[key] !== undefined) {
184
+ this.config[key] = webSettings[key];
185
+ }
138
186
  }
139
- // Permission toggles: state writes are allowed by default, object/file changes are not.
187
+ // Permission toggles: state writes are allowed by default, everything that changes the
188
+ // system (objects, instances, adapters, packages, scripts, messages) is not.
140
189
  this.config.allowSetState = this.config.allowSetState !== false;
141
190
  this.config.allowObjectChange = this.config.allowObjectChange === true;
191
+ this.config.allowInstanceControl = this.config.allowInstanceControl === true;
192
+ this.config.allowAdapterManagement = this.config.allowAdapterManagement === true;
193
+ this.config.allowPackageInstall = this.config.allowPackageInstall === true;
194
+ this.config.allowScriptChange = this.config.allowScriptChange === true;
195
+ this.config.sendToAllowlist = (this.config.sendToAllowlist || '').trim();
196
+ // Confirmations via elicitation are on unless turned off explicitly
197
+ this.config.confirmDangerous = this.config.confirmDangerous !== false;
142
198
  // State writes are declared destructive unless turned off explicitly
143
199
  this.config.setStateDestructive = this.config.setStateDestructive !== false;
144
200
  // Authentication is enforced by us only when we run standalone (we own the Express app) and
@@ -162,6 +218,10 @@ class McpServer {
162
218
  }
163
219
  // Receive ioBroker log messages (only forwarded once a session subscribes via requireLog).
164
220
  this.adapter.on('log', this.onLog);
221
+ if (this.config.allowPackageInstall || this.config.allowAdapterManagement) {
222
+ // The host reports the progress of `cmdExec` (install_package etc.) as messages to this instance
223
+ this.adapter.on('message', this.onMessage);
224
+ }
165
225
  // Wire HTTP routes only when we own/share an Express app. In embedded in-process mode (no app)
166
226
  // the server is reached over an in-memory transport instead, see createInProcessServer().
167
227
  this.initRoutes();
@@ -184,20 +244,10 @@ class McpServer {
184
244
  * @param options.adapter The host adapter into which the MCP server is embedded. Used for receiving state/object changes and logs, and for performing actions with the host adapter's permissions.
185
245
  * @param options.defaultUser Default user
186
246
  * @param options.language Language
187
- * @param options.allowSetState Allow the state-writing tools `set_state`/`set_states` (opt-out, default true)
188
- * @param options.allowObjectChange Allow the object/file-changing tools (opt-in, default false)
189
- * @param options.setStateDestructive Declare `set_state`/`set_states` as destructive (default true)
190
247
  */
191
248
  static createEmbedded(options) {
192
- return new McpServer(null, {
193
- secure: false,
194
- port: 0,
195
- defaultUser: options.defaultUser,
196
- language: options.language,
197
- allowSetState: options.allowSetState,
198
- allowObjectChange: options.allowObjectChange,
199
- setStateDestructive: options.setStateDestructive,
200
- }, options.adapter, null);
249
+ const { adapter, ...settings } = options;
250
+ return new McpServer(null, { secure: false, port: 0, ...settings }, adapter, null);
201
251
  }
202
252
  initRoutes() {
203
253
  const app = this.app;
@@ -591,6 +641,25 @@ class McpServer {
591
641
  }
592
642
  }
593
643
  };
644
+ /** Receive the progress of a `cmdExec` started by `install_package` (`cmdStdout`/`cmdStderr`/`cmdExit`). */
645
+ onMessage = (msg) => {
646
+ const id = msg?.message?.id;
647
+ const session = typeof id === 'string' ? this.cmdSessions.get(id) : undefined;
648
+ if (!session) {
649
+ // not ours, e.g. a cmdExec of admin or the second (repeated) cmdExit
650
+ return;
651
+ }
652
+ if (msg.command === 'cmdStdout' || msg.command === 'cmdStderr') {
653
+ session.output.push(String(msg.message.data ?? ''));
654
+ if (session.output.length > INSTALL_OUTPUT_LINES) {
655
+ session.output.splice(0, session.output.length - INSTALL_OUTPUT_LINES);
656
+ }
657
+ }
658
+ else if (msg.command === 'cmdExit') {
659
+ this.cmdSessions.delete(id);
660
+ session.onExit(typeof msg.message.data === 'number' ? msg.message.data : -1);
661
+ }
662
+ };
594
663
  /** Classify a resource URI into its subscription kind, address and ref-count key. */
595
664
  uriKind(uri) {
596
665
  if (uri.startsWith(LOG_URI_PREFIX)) {
@@ -662,6 +731,24 @@ class McpServer {
662
731
  this.adapter.log.warn(`MCP tool error: ${message}`);
663
732
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
664
733
  };
734
+ // Ask the user before a dangerous action, if the client can (MCP elicitation). Clients without
735
+ // elicitation rely on the destructiveHint of the tool instead.
736
+ const confirm = async (message) => {
737
+ if (!this.config.confirmDangerous || !server.server.getClientCapabilities()?.elicitation) {
738
+ return;
739
+ }
740
+ const answer = await server.server.elicitInput({
741
+ message,
742
+ requestedSchema: {
743
+ type: 'object',
744
+ properties: { confirm: { type: 'boolean', title: 'Yes, do it' } },
745
+ required: ['confirm'],
746
+ },
747
+ });
748
+ if (answer.action !== 'accept' || answer.content?.confirm !== true) {
749
+ throw new Error('Cancelled by the user');
750
+ }
751
+ };
665
752
  server.registerTool('get_states', {
666
753
  title: 'Get state values',
667
754
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -1150,6 +1237,7 @@ class McpServer {
1150
1237
  },
1151
1238
  }, async ({ id, recursive }) => {
1152
1239
  try {
1240
+ await confirm(`Delete the object "${id}"${recursive ? ' and all its children' : ''}?`);
1153
1241
  await this.deleteObject(id, recursive);
1154
1242
  return ok({ ok: true, data: { id, deleted: true } });
1155
1243
  }
@@ -1225,6 +1313,34 @@ class McpServer {
1225
1313
  return fail(e);
1226
1314
  }
1227
1315
  });
1316
+ server.registerTool('create_alias', {
1317
+ title: 'Create an alias',
1318
+ annotations: {
1319
+ readOnlyHint: false,
1320
+ destructiveHint: false,
1321
+ idempotentHint: false,
1322
+ openWorldHint: false,
1323
+ },
1324
+ description: 'Create an alias state (alias.0.*) that points to another state, e.g. to give a device a ' +
1325
+ 'stable, readable id that survives replacing the hardware. Type, role, unit and limits are ' +
1326
+ 'copied from the target. With write_target, reading and writing use different states ' +
1327
+ '(e.g. a thermostat with separate actual and set values). Fails if the alias exists.',
1328
+ inputSchema: {
1329
+ id: zod_1.z.string().describe('Alias id, e.g. "livingroom.ceiling_light" (becomes alias.0.…)'),
1330
+ target: zod_1.z.string().describe('State the alias reads (and writes), e.g. "hue.0.light.state"'),
1331
+ write_target: zod_1.z.string().optional().describe('Different state for writing'),
1332
+ name: zod_1.z.string().optional().describe('Display name (default: name of the target)'),
1333
+ role: zod_1.z.string().optional().describe('Role (default: role of the target)'),
1334
+ unit: zod_1.z.string().optional().describe('Unit (default: unit of the target)'),
1335
+ },
1336
+ }, async (args) => {
1337
+ try {
1338
+ return ok({ ok: true, data: await this.createAlias(args) });
1339
+ }
1340
+ catch (e) {
1341
+ return fail(e);
1342
+ }
1343
+ });
1228
1344
  server.registerTool('write_file', {
1229
1345
  title: 'Write a file',
1230
1346
  annotations: {
@@ -1315,6 +1431,518 @@ class McpServer {
1315
1431
  }
1316
1432
  });
1317
1433
  }
1434
+ server.registerTool('list_adapter_updates', {
1435
+ title: 'List adapter updates',
1436
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1437
+ description: 'List the installed adapters that have a newer version in the active repository ' +
1438
+ '(installed and available version). Reads the already-downloaded repository (no network).',
1439
+ inputSchema: {},
1440
+ }, async () => {
1441
+ try {
1442
+ return ok({ ok: true, data: { updates: await this.listAdapterUpdates() } });
1443
+ }
1444
+ catch (e) {
1445
+ return fail(e);
1446
+ }
1447
+ });
1448
+ server.registerTool('list_notifications', {
1449
+ title: 'List system notifications',
1450
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1451
+ description: 'List the current system notifications of the hosts, as shown by the bell in ioBroker.admin ' +
1452
+ '(e.g. disk space low, adapter crashed repeatedly, adapter updates). Each entry has host, ' +
1453
+ 'scope, category, severity, the affected instance and its messages.',
1454
+ inputSchema: {
1455
+ host: zod_1.z.string().optional().describe('Only this host (default: all running hosts)'),
1456
+ language: zod_1.z.enum(LANGUAGES).optional().describe('Language for names and descriptions'),
1457
+ },
1458
+ }, async (args) => {
1459
+ try {
1460
+ return ok({ ok: true, data: { notifications: await this.listNotifications(args) } });
1461
+ }
1462
+ catch (e) {
1463
+ return fail(e);
1464
+ }
1465
+ });
1466
+ server.registerTool('list_scripts', {
1467
+ title: 'List scripts',
1468
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1469
+ description: 'List the scripts of the javascript adapter (JavaScript, TypeScript, Blockly, Rules) with ' +
1470
+ 'id, name, type, whether they run, and the javascript instance - without the source.',
1471
+ inputSchema: {
1472
+ folder: zod_1.z.string().optional().describe('Only scripts in this folder, e.g. "common" or "lights"'),
1473
+ },
1474
+ }, async ({ folder }) => {
1475
+ try {
1476
+ return ok({ ok: true, data: { scripts: await this.listScripts(folder) } });
1477
+ }
1478
+ catch (e) {
1479
+ return fail(e);
1480
+ }
1481
+ });
1482
+ server.registerTool('get_script', {
1483
+ title: 'Read a script',
1484
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1485
+ description: 'Read a script of the javascript adapter including its source code',
1486
+ inputSchema: {
1487
+ id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights" or "common.lights"'),
1488
+ },
1489
+ }, async ({ id }) => {
1490
+ try {
1491
+ const script = await this.getScriptObject(id);
1492
+ return ok({ ok: true, data: this.scriptInfo(script.id, script.obj, true) });
1493
+ }
1494
+ catch (e) {
1495
+ return fail(e);
1496
+ }
1497
+ });
1498
+ if (this.config.allowSetState) {
1499
+ server.registerTool('clear_notifications', {
1500
+ title: 'Acknowledge notifications',
1501
+ annotations: {
1502
+ readOnlyHint: false,
1503
+ destructiveHint: true,
1504
+ idempotentHint: true,
1505
+ openWorldHint: false,
1506
+ },
1507
+ description: 'Acknowledge (delete) system notifications of a host - all, or only those of one scope, ' +
1508
+ 'category and/or instance as returned by list_notifications.',
1509
+ inputSchema: {
1510
+ host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
1511
+ scope: zod_1.z.string().optional().describe('e.g. "system"'),
1512
+ category: zod_1.z.string().optional().describe('e.g. "diskSpaceIssues"'),
1513
+ instance: zod_1.z.string().optional().describe('e.g. "hue.0"'),
1514
+ },
1515
+ }, async (args) => {
1516
+ try {
1517
+ return ok({ ok: true, data: await this.clearNotifications(args) });
1518
+ }
1519
+ catch (e) {
1520
+ return fail(e);
1521
+ }
1522
+ });
1523
+ }
1524
+ if (this.config.allowInstanceControl) {
1525
+ const instanceSchema = { id: zod_1.z.string().describe('Instance, e.g. "hue.0"') };
1526
+ server.registerTool('start_instance', {
1527
+ title: 'Start an instance',
1528
+ annotations: {
1529
+ readOnlyHint: false,
1530
+ destructiveHint: false,
1531
+ idempotentHint: true,
1532
+ openWorldHint: false,
1533
+ },
1534
+ description: 'Start (enable) an adapter instance',
1535
+ inputSchema: instanceSchema,
1536
+ }, async ({ id }) => {
1537
+ try {
1538
+ return ok({ ok: true, data: await this.setInstanceEnabled(id, true) });
1539
+ }
1540
+ catch (e) {
1541
+ return fail(e);
1542
+ }
1543
+ });
1544
+ server.registerTool('stop_instance', {
1545
+ title: 'Stop an instance',
1546
+ annotations: {
1547
+ readOnlyHint: false,
1548
+ destructiveHint: true,
1549
+ idempotentHint: true,
1550
+ openWorldHint: false,
1551
+ },
1552
+ description: 'Stop (disable) an adapter instance. It stays stopped until it is started again, also ' +
1553
+ 'after a restart of ioBroker.',
1554
+ inputSchema: instanceSchema,
1555
+ }, async ({ id }) => {
1556
+ try {
1557
+ await confirm(`Stop the instance "${id}"?`);
1558
+ return ok({ ok: true, data: await this.setInstanceEnabled(id, false) });
1559
+ }
1560
+ catch (e) {
1561
+ return fail(e);
1562
+ }
1563
+ });
1564
+ server.registerTool('restart_instance', {
1565
+ title: 'Restart an instance',
1566
+ annotations: {
1567
+ readOnlyHint: false,
1568
+ destructiveHint: false,
1569
+ idempotentHint: false,
1570
+ openWorldHint: false,
1571
+ },
1572
+ description: 'Restart a running adapter instance, e.g. after changing its configuration',
1573
+ inputSchema: instanceSchema,
1574
+ }, async ({ id }) => {
1575
+ try {
1576
+ return ok({ ok: true, data: await this.restartInstance(id) });
1577
+ }
1578
+ catch (e) {
1579
+ return fail(e);
1580
+ }
1581
+ });
1582
+ server.registerTool('set_log_level', {
1583
+ title: 'Set the log level of an instance',
1584
+ annotations: {
1585
+ readOnlyHint: false,
1586
+ destructiveHint: false,
1587
+ idempotentHint: true,
1588
+ openWorldHint: false,
1589
+ },
1590
+ description: 'Change the log level of an adapter instance, e.g. to "debug" to analyze a problem with ' +
1591
+ 'get_logs. By default only until the next start of the instance and without a restart; ' +
1592
+ 'with persistent=true it is stored in the instance configuration (restarts the instance).',
1593
+ inputSchema: {
1594
+ id: zod_1.z.string().describe('Instance, e.g. "hue.0"'),
1595
+ level: zod_1.z.enum(['silly', 'debug', 'info', 'warn', 'error']),
1596
+ persistent: zod_1.z.boolean().optional().describe('Store it permanently (default false)'),
1597
+ },
1598
+ }, async ({ id, level, persistent }) => {
1599
+ try {
1600
+ return ok({ ok: true, data: await this.setLogLevel(id, level, persistent) });
1601
+ }
1602
+ catch (e) {
1603
+ return fail(e);
1604
+ }
1605
+ });
1606
+ }
1607
+ if (this.config.allowAdapterManagement) {
1608
+ const timeoutSchema = zod_1.z
1609
+ .number()
1610
+ .int()
1611
+ .min(1)
1612
+ .max(3600)
1613
+ .optional()
1614
+ .describe(`Seconds to wait (default ${DEFAULT_INSTALL_TIMEOUT}); the command keeps running on the host after that`);
1615
+ server.registerTool('add_instance', {
1616
+ title: 'Add an adapter instance',
1617
+ annotations: {
1618
+ readOnlyHint: false,
1619
+ destructiveHint: false,
1620
+ idempotentHint: false,
1621
+ openWorldHint: true,
1622
+ },
1623
+ description: 'Create a new instance of an adapter from the repository (`iobroker add`). Installs the ' +
1624
+ 'adapter first if it is not installed yet. Find adapters with search_adapter_repository.',
1625
+ inputSchema: {
1626
+ adapter: zod_1.z.string().describe('Adapter name, e.g. "hue"'),
1627
+ instance: zod_1.z.number().int().min(0).optional().describe('Instance number (default: next free)'),
1628
+ host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
1629
+ timeout: timeoutSchema,
1630
+ },
1631
+ }, async (args) => {
1632
+ try {
1633
+ return ok({ ok: true, data: await this.addInstance(args) });
1634
+ }
1635
+ catch (e) {
1636
+ return fail(e);
1637
+ }
1638
+ });
1639
+ server.registerTool('delete_instance', {
1640
+ title: 'Delete an adapter instance',
1641
+ annotations: {
1642
+ readOnlyHint: false,
1643
+ destructiveHint: true,
1644
+ idempotentHint: false,
1645
+ openWorldHint: false,
1646
+ },
1647
+ description: 'Delete an adapter instance with its configuration, objects and states (`iobroker del`). ' +
1648
+ 'This cannot be undone.',
1649
+ inputSchema: {
1650
+ id: zod_1.z.string().describe('Instance, e.g. "hue.0"'),
1651
+ timeout: timeoutSchema,
1652
+ },
1653
+ }, async (args) => {
1654
+ try {
1655
+ await confirm(`Delete the instance "${args.id}" with its configuration, objects and states? This cannot be undone.`);
1656
+ return ok({ ok: true, data: await this.deleteInstance(args) });
1657
+ }
1658
+ catch (e) {
1659
+ return fail(e);
1660
+ }
1661
+ });
1662
+ server.registerTool('upgrade_adapter', {
1663
+ title: 'Upgrade an adapter',
1664
+ annotations: {
1665
+ readOnlyHint: false,
1666
+ destructiveHint: true,
1667
+ idempotentHint: true,
1668
+ openWorldHint: true,
1669
+ },
1670
+ description: 'Upgrade an installed adapter to the version of the active repository or to the given ' +
1671
+ 'version (`iobroker upgrade`). Its instances are restarted. See list_adapter_updates.',
1672
+ inputSchema: {
1673
+ adapter: zod_1.z.string().describe('Adapter name, e.g. "hue"'),
1674
+ version: zod_1.z.string().optional().describe('Version (default: the repository version)'),
1675
+ host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
1676
+ timeout: timeoutSchema,
1677
+ },
1678
+ }, async (args) => {
1679
+ try {
1680
+ await confirm(`Upgrade the adapter "${args.adapter}"${args.version ? ` to ${args.version}` : ''}?`);
1681
+ return ok({ ok: true, data: await this.upgradeAdapter(args) });
1682
+ }
1683
+ catch (e) {
1684
+ return fail(e);
1685
+ }
1686
+ });
1687
+ }
1688
+ // Installing packages runs arbitrary code on the host, so it is a separate opt-in for developers.
1689
+ if (this.config.allowPackageInstall) {
1690
+ server.registerTool('install_package', {
1691
+ title: 'Install an adapter package',
1692
+ annotations: {
1693
+ readOnlyHint: false,
1694
+ destructiveHint: true,
1695
+ idempotentHint: false,
1696
+ openWorldHint: true,
1697
+ },
1698
+ description: 'Developer tool: install an ioBroker adapter package on a host, like "Install from custom ' +
1699
+ 'URL" / "file" in ioBroker.admin (`iobroker url`). Give exactly one of: `source` - an npm ' +
1700
+ 'package ("iobroker.hue@3.9.0" or just "hue"), a GitHub repository ' +
1701
+ '("ioBroker/ioBroker.hue#main" or https URL), an https URL of a .tgz or a .tgz path on the ' +
1702
+ 'target host; `file_path` - a .tgz in the ioBroker file storage ("0_userdata.0/x.tgz"); or ' +
1703
+ '`file_name` + `file_content` - a small .tgz as base64. Waits until the installation is ' +
1704
+ 'finished (or `timeout`), returns the exit code and the last output lines. Enabled instances ' +
1705
+ 'of the adapter are restarted afterwards; no new instance is created.',
1706
+ inputSchema: {
1707
+ source: zod_1.z
1708
+ .string()
1709
+ .optional()
1710
+ .describe('npm package, GitHub repository, https URL or path of a .tgz on the host'),
1711
+ file_path: zod_1.z
1712
+ .string()
1713
+ .optional()
1714
+ .describe('.tgz in the ioBroker file storage as "<adapter>/<dir>/<file>.tgz"'),
1715
+ file_name: zod_1.z
1716
+ .string()
1717
+ .optional()
1718
+ .describe('File name of the .tgz in file_content, e.g. "iobroker.hue-3.9.0.tgz"'),
1719
+ file_content: zod_1.z.string().optional().describe('The .tgz file as base64'),
1720
+ host: zod_1.z.string().optional().describe('Target host (default: the host of this instance)'),
1721
+ debug: zod_1.z.boolean().optional().describe('Install with --debug for more output'),
1722
+ restart_instances: zod_1.z
1723
+ .boolean()
1724
+ .optional()
1725
+ .describe('Restart enabled instances of the adapter after installing (default true)'),
1726
+ timeout: zod_1.z
1727
+ .number()
1728
+ .int()
1729
+ .min(1)
1730
+ .max(3600)
1731
+ .optional()
1732
+ .describe(`Seconds to wait for the installation (default ${DEFAULT_INSTALL_TIMEOUT}); it keeps running on the host after that`),
1733
+ },
1734
+ }, async (args) => {
1735
+ try {
1736
+ await confirm(`Install the package "${args.source || args.file_path || args.file_name}"? It runs with full rights on the host.`);
1737
+ return ok({ ok: true, data: await this.installPackage(args) });
1738
+ }
1739
+ catch (e) {
1740
+ return fail(e);
1741
+ }
1742
+ });
1743
+ }
1744
+ if (this.config.allowScriptChange) {
1745
+ const engineTypeSchema = zod_1.z
1746
+ .enum(['Javascript/js', 'TypeScript/ts'])
1747
+ .optional()
1748
+ .describe('Script language (default "Javascript/js")');
1749
+ server.registerTool('set_script', {
1750
+ title: 'Create or change a script',
1751
+ annotations: {
1752
+ readOnlyHint: false,
1753
+ destructiveHint: true,
1754
+ idempotentHint: true,
1755
+ openWorldHint: false,
1756
+ },
1757
+ description: 'Create a JavaScript/TypeScript script of the javascript adapter or replace the source of ' +
1758
+ 'an existing one (a running script restarts with the new source). New scripts are created ' +
1759
+ 'disabled unless enabled=true; missing folders are created. Blockly and Rules scripts ' +
1760
+ 'cannot be changed. Test code first with run_script.',
1761
+ inputSchema: {
1762
+ id: zod_1.z.string().describe('Script id, e.g. "common.lights" (folder "common") or "lights"'),
1763
+ source: zod_1.z.string().describe('The complete source code'),
1764
+ engineType: engineTypeSchema,
1765
+ name: zod_1.z.string().optional().describe('Display name (default: last part of the id)'),
1766
+ enabled: zod_1.z.boolean().optional().describe('Start (true) or stop (false) the script'),
1767
+ instance: zod_1.z.string().optional().describe('javascript instance (default "javascript.0")'),
1768
+ },
1769
+ }, async (args) => {
1770
+ try {
1771
+ await confirm(`Save the script "${args.id}"${args.enabled ? ' and start it' : ''}?`);
1772
+ return ok({ ok: true, data: await this.setScript(args) });
1773
+ }
1774
+ catch (e) {
1775
+ return fail(e);
1776
+ }
1777
+ });
1778
+ server.registerTool('enable_script', {
1779
+ title: 'Start or stop a script',
1780
+ annotations: {
1781
+ readOnlyHint: false,
1782
+ destructiveHint: false,
1783
+ idempotentHint: true,
1784
+ openWorldHint: false,
1785
+ },
1786
+ description: 'Start (enabled=true) or stop (enabled=false) a script of the javascript adapter',
1787
+ inputSchema: {
1788
+ id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights"'),
1789
+ enabled: zod_1.z.boolean(),
1790
+ },
1791
+ }, async ({ id, enabled }) => {
1792
+ try {
1793
+ return ok({ ok: true, data: await this.enableScript(id, enabled) });
1794
+ }
1795
+ catch (e) {
1796
+ return fail(e);
1797
+ }
1798
+ });
1799
+ server.registerTool('delete_script', {
1800
+ title: 'Delete a script',
1801
+ annotations: {
1802
+ readOnlyHint: false,
1803
+ destructiveHint: true,
1804
+ idempotentHint: true,
1805
+ openWorldHint: false,
1806
+ },
1807
+ description: 'Delete a script of the javascript adapter',
1808
+ inputSchema: { id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights"') },
1809
+ }, async ({ id }) => {
1810
+ try {
1811
+ await confirm(`Delete the script "${id}"?`);
1812
+ return ok({ ok: true, data: await this.deleteScript(id) });
1813
+ }
1814
+ catch (e) {
1815
+ return fail(e);
1816
+ }
1817
+ });
1818
+ server.registerTool('run_script', {
1819
+ title: 'Run code once',
1820
+ annotations: {
1821
+ readOnlyHint: false,
1822
+ destructiveHint: true,
1823
+ idempotentHint: false,
1824
+ openWorldHint: false,
1825
+ },
1826
+ description: 'Run JavaScript/TypeScript once in the javascript adapter, with the same API as a script ' +
1827
+ '(getState, setState, log, ...), and return everything it logged. Nothing is stored; ' +
1828
+ 'timers and subscriptions end after `timeout`. Use it to test code before set_script. ' +
1829
+ 'Requires javascript adapter 10.1.0 or newer.',
1830
+ inputSchema: {
1831
+ source: zod_1.z.string().describe('The code, e.g. log(getState("hue.0.light.state").val)'),
1832
+ engineType: engineTypeSchema,
1833
+ timeout: zod_1.z
1834
+ .number()
1835
+ .int()
1836
+ .min(0)
1837
+ .max(60000)
1838
+ .optional()
1839
+ .describe('Milliseconds to collect asynchronous output (default 5000)'),
1840
+ instance: zod_1.z.string().optional().describe('javascript instance (default "javascript.0")'),
1841
+ },
1842
+ }, async (args) => {
1843
+ try {
1844
+ await confirm('Run the code in the javascript adapter?');
1845
+ return ok({ ok: true, data: await this.runScript(args) });
1846
+ }
1847
+ catch (e) {
1848
+ return fail(e);
1849
+ }
1850
+ });
1851
+ }
1852
+ if (this.config.sendToAllowlist) {
1853
+ server.registerTool('send_to', {
1854
+ title: 'Send a message to an adapter',
1855
+ annotations: {
1856
+ readOnlyHint: false,
1857
+ destructiveHint: true,
1858
+ idempotentHint: false,
1859
+ openWorldHint: true,
1860
+ },
1861
+ description: 'Send a message to an adapter instance (`sendTo`) and return its answer, e.g. a Telegram ' +
1862
+ 'message ({instance:"telegram.0", command:"send", message:{text:"Hi"}}) or a backup ' +
1863
+ `(backitup.0, "oneClick"). Allowed instances: ${this.config.sendToAllowlist}. The commands ` +
1864
+ 'and the message format are described in the documentation of each adapter.',
1865
+ inputSchema: {
1866
+ instance: zod_1.z.string().describe('Instance, e.g. "telegram.0"'),
1867
+ command: zod_1.z.string().describe('Command, e.g. "send"'),
1868
+ message: zod_1.z.any().optional().describe('Message payload (object or string)'),
1869
+ timeout: zod_1.z
1870
+ .number()
1871
+ .int()
1872
+ .min(100)
1873
+ .max(120000)
1874
+ .optional()
1875
+ .describe('Milliseconds to wait for the answer (default 10000)'),
1876
+ },
1877
+ }, async (args) => {
1878
+ try {
1879
+ await confirm(`Send "${args.command}" to "${args.instance}"?`);
1880
+ return ok({ ok: true, data: await this.sendToInstance(args) });
1881
+ }
1882
+ catch (e) {
1883
+ return fail(e);
1884
+ }
1885
+ });
1886
+ }
1887
+ // --- Prompts: ready-made workflows that MCP clients offer as slash commands ---
1888
+ server.registerPrompt('diagnose_adapter', {
1889
+ title: 'Diagnose an adapter',
1890
+ description: 'Find out why an adapter instance does not work and suggest a fix',
1891
+ argsSchema: { instance: zod_1.z.string().describe('Instance, e.g. "hue.0"') },
1892
+ }, ({ instance }) => ({
1893
+ messages: [
1894
+ {
1895
+ role: 'user',
1896
+ content: {
1897
+ type: 'text',
1898
+ text: `Diagnose the ioBroker instance "${instance}". Check with list_instances whether it ` +
1899
+ `is enabled, alive and connected, read its errors and warnings with get_logs ` +
1900
+ `(adapter "${instance}"), look at list_notifications and, if the log names a device ` +
1901
+ `or service address, test it with ping_host. Compare its version with ` +
1902
+ `list_adapter_updates. Explain the cause in plain words and suggest the fix; ask ` +
1903
+ `before you change anything.`,
1904
+ },
1905
+ },
1906
+ ],
1907
+ }));
1908
+ server.registerPrompt('room_overview', {
1909
+ title: 'Room overview',
1910
+ description: 'Summarize the devices of a room and their current state',
1911
+ argsSchema: { room: zod_1.z.string().describe('Room name, e.g. "Living room"') },
1912
+ }, ({ room }) => ({
1913
+ messages: [
1914
+ {
1915
+ role: 'user',
1916
+ content: {
1917
+ type: 'text',
1918
+ text: `Give me an overview of the room "${room}" in ioBroker: use list_devices for the ` +
1919
+ `room and get_states for the current values. Group them into lights, climate, ` +
1920
+ `windows/doors and others and point out anything unusual (open windows, lights on, ` +
1921
+ `low batteries, unreachable devices).`,
1922
+ },
1923
+ },
1924
+ ],
1925
+ }));
1926
+ server.registerPrompt('create_automation', {
1927
+ title: 'Create an automation',
1928
+ description: 'Turn a description into an ioBroker script',
1929
+ argsSchema: { description: zod_1.z.string().describe('What should happen, e.g. "lights off at 23:00"') },
1930
+ }, ({ description }) => ({
1931
+ messages: [
1932
+ {
1933
+ role: 'user',
1934
+ content: {
1935
+ type: 'text',
1936
+ text: `Create an ioBroker automation: ${description}\n` +
1937
+ `Find the state ids with list_devices and search_objects, check the existing scripts ` +
1938
+ `with list_scripts and write a JavaScript for the javascript adapter (on(), ` +
1939
+ `schedule(), setState()). Test parts with run_script if available, show me the code ` +
1940
+ `and save it with set_script only after I agreed. If the script tools are not ` +
1941
+ `available, show me the code so I can paste it into the script editor.`,
1942
+ },
1943
+ },
1944
+ ],
1945
+ }));
1318
1946
  // --- Resources: expose states and objects via the canonical ioBroker URI scheme ---
1319
1947
  // States as `iobstate://<id>`, objects as `iobobject://<id>`. On change the server pushes a
1320
1948
  // `notifications/resources/updated` over the session's SSE stream and the client re-reads.
@@ -1925,27 +2553,7 @@ class McpServer {
1925
2553
  const lang = params.language || this.language;
1926
2554
  const needle = (params.query || '').toLowerCase();
1927
2555
  const limit = params.limit ?? 20;
1928
- const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
1929
- const repositories = (reposObj?.native?.repositories || {});
1930
- // Prefer the active repository; fall back to the first repository that has content.
1931
- const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
1932
- const active = sysCfg?.common?.activeRepo;
1933
- const activeNames = Array.isArray(active) ? active : active ? [active] : [];
1934
- // Merge the JSON of ALL active repositories (or all repositories if none is marked active). An
1935
- // adapter present in several repos is kept once (first/active repo wins). This matters when the
1936
- // user has custom repos active next to the default one (their adapters are not in stable).
1937
- const activeWithJson = activeNames.filter(name => repositories[name]?.json);
1938
- const sources = (activeWithJson.length ? activeWithJson.map(name => repositories[name]) : Object.values(repositories))
1939
- .map(repo => repo?.json)
1940
- .filter((j) => !!j);
1941
- const json = {};
1942
- for (const src of sources) {
1943
- for (const key of Object.keys(src)) {
1944
- if (!(key in json)) {
1945
- json[key] = src[key];
1946
- }
1947
- }
1948
- }
2556
+ const json = await this.getRepositoryJson();
1949
2557
  if (!Object.keys(json).length) {
1950
2558
  return [];
1951
2559
  }
@@ -2301,6 +2909,505 @@ class McpServer {
2301
2909
  await this.adapter.setForeignObjectAsync(id, sceneObj, { user: this.defaultUser });
2302
2910
  return { id, members: params.members.length };
2303
2911
  }
2912
+ /**
2913
+ * Install an adapter package on a host via `cmdExec` (`iobroker url ...`), the same way
2914
+ * ioBroker.admin installs from a custom URL or a .tgz file, and wait for the result.
2915
+ */
2916
+ async installPackage(params) {
2917
+ const given = [params.source, params.file_path, params.file_content].filter(one => one !== undefined);
2918
+ if (given.length !== 1) {
2919
+ throw new Error('Give exactly one of "source", "file_path" or "file_name" + "file_content"');
2920
+ }
2921
+ const host = await this.resolveHost(params.host);
2922
+ let source;
2923
+ let files;
2924
+ if (params.source !== undefined) {
2925
+ source = params.source.trim();
2926
+ // The host splits the command at spaces, so a source must be one argument and no option
2927
+ if (!source || /\s/.test(source) || source.startsWith('-')) {
2928
+ throw new Error(`Invalid source "${params.source}"`);
2929
+ }
2930
+ // a pure adapter name, like in the "Custom URL" tab of admin
2931
+ if (!/[./:]/.test(source)) {
2932
+ source = `iobroker.${source}`;
2933
+ }
2934
+ }
2935
+ else {
2936
+ let fileName;
2937
+ let data;
2938
+ if (params.file_path !== undefined) {
2939
+ const { adapterName, fileName: storageName } = McpServer.parseFilePath(params.file_path);
2940
+ const result = await this.adapter.readFileAsync(adapterName, storageName, { user: this.defaultUser });
2941
+ data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
2942
+ fileName = storageName.split('/').pop() || '';
2943
+ }
2944
+ else {
2945
+ if (!params.file_name) {
2946
+ throw new Error('"file_content" requires "file_name"');
2947
+ }
2948
+ data = Buffer.from(params.file_content, 'base64');
2949
+ fileName = params.file_name;
2950
+ }
2951
+ if (!fileName.toLowerCase().endsWith('.tgz')) {
2952
+ throw new Error('Only .tgz files can be installed');
2953
+ }
2954
+ if (!data.length) {
2955
+ throw new Error('The .tgz file is empty');
2956
+ }
2957
+ if (!this.adapter.supportsFeature?.('CONTROLLER_CMD_EXEC_FILES')) {
2958
+ throw new Error('Installing a .tgz file requires a js-controller that supports files in cmdExec');
2959
+ }
2960
+ // The command refers to the file by name, so it must not contain spaces, "/" or "\"
2961
+ source = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
2962
+ files = [{ name: source, file: data.toString('base64') }];
2963
+ }
2964
+ const result = await this.runHostCommand(host, `url ${source} --host ${host}${params.debug ? ' --debug' : ''}`, params.timeout, files);
2965
+ const adapterName = extractAdapterName(params.file_path || params.file_name || params.source || '');
2966
+ const restarted = result.success && adapterName && params.restart_instances !== false
2967
+ ? await this.restartAdapterInstances(adapterName)
2968
+ : [];
2969
+ return { ...result, adapter: adapterName, restartedInstances: restarted };
2970
+ }
2971
+ /** Normalize a host name (with or without `system.host.`) and check that the host exists. */
2972
+ async resolveHost(host) {
2973
+ const name = (host || this.adapter.host || '').replace(/^system\.host\./, '');
2974
+ if (!name || /\s/.test(name)) {
2975
+ throw new Error(`Invalid host "${name}"`);
2976
+ }
2977
+ const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${name}`, { user: this.defaultUser });
2978
+ if (hostObj?.type !== 'host') {
2979
+ throw new Error(`Host "${name}" not found`);
2980
+ }
2981
+ return name;
2982
+ }
2983
+ /**
2984
+ * Run an ioBroker CLI command on a host via `cmdExec` and wait for its exit code. The host reports
2985
+ * the output and the exit code as messages to this instance, see {@link onMessage}.
2986
+ *
2987
+ * @param host host name without `system.host.`
2988
+ * @param command the CLI arguments, e.g. `upgrade hue@3.9.0`
2989
+ * @param timeoutSeconds how long to wait; the command keeps running on the host after that
2990
+ * @param files files sent along with the command (the command refers to them by name)
2991
+ */
2992
+ async runHostCommand(host, command, timeoutSeconds, files) {
2993
+ const id = `mcp-${(0, node_crypto_1.randomUUID)()}`;
2994
+ const timeout = (timeoutSeconds ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
2995
+ const output = [];
2996
+ this.adapter.log.info(`[cmdExec] ${host}: iobroker ${command}`);
2997
+ const exitCode = await new Promise(resolve => {
2998
+ const timer = setTimeout(() => {
2999
+ this.cmdSessions.delete(id);
3000
+ resolve(null);
3001
+ }, timeout);
3002
+ this.cmdSessions.set(id, {
3003
+ output,
3004
+ onExit: code => {
3005
+ clearTimeout(timer);
3006
+ resolve(code);
3007
+ },
3008
+ });
3009
+ this.adapter.sendToHost(`system.host.${host}`, 'cmdExec', { data: command, id, files });
3010
+ });
3011
+ return {
3012
+ host,
3013
+ command: `iobroker ${command}`,
3014
+ ...(exitCode === null
3015
+ ? {
3016
+ finished: false,
3017
+ note: `No result after ${timeout / 1000}s - the command keeps running on the host, check the log`,
3018
+ }
3019
+ : { finished: true, success: exitCode === 0, exitCode }),
3020
+ output,
3021
+ };
3022
+ }
3023
+ /** Request something from a host (`getNotifications`, ...) and wait for the answer. */
3024
+ hostRequest(host, command, message) {
3025
+ return new Promise((resolve, reject) => {
3026
+ const timer = setTimeout(() => reject(new Error(`Host "${host}" did not answer "${command}"`)), 10_000);
3027
+ this.adapter.sendToHost(`system.host.${host}`, command, message, (result) => {
3028
+ clearTimeout(timer);
3029
+ if (result?.error) {
3030
+ reject(new Error(result.error));
3031
+ }
3032
+ else {
3033
+ resolve(result);
3034
+ }
3035
+ });
3036
+ });
3037
+ }
3038
+ /** Like admin after an installation: touching the instance objects makes the controller restart them. */
3039
+ async restartAdapterInstances(adapterName) {
3040
+ const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
3041
+ user: this.defaultUser,
3042
+ });
3043
+ const restarted = [];
3044
+ for (const [instanceId, instance] of Object.entries(instances || {})) {
3045
+ if (instance?.common?.enabled) {
3046
+ await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.defaultUser });
3047
+ restarted.push(instanceId.replace('system.adapter.', ''));
3048
+ }
3049
+ }
3050
+ return restarted;
3051
+ }
3052
+ /** Read an instance object by its short id (`hue.0`) or full id (`system.adapter.hue.0`). */
3053
+ async getInstance(id) {
3054
+ const shortId = id.replace(/^system\.adapter\./, '');
3055
+ if (!/^[a-z0-9][a-z0-9_-]*\.\d+$/i.test(shortId)) {
3056
+ throw new Error(`Invalid instance "${id}", expected e.g. "hue.0"`);
3057
+ }
3058
+ const fullId = `system.adapter.${shortId}`;
3059
+ const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.defaultUser });
3060
+ if (obj?.type !== 'instance') {
3061
+ throw new Error(`Instance "${shortId}" not found`);
3062
+ }
3063
+ return { id: shortId, fullId, obj: obj };
3064
+ }
3065
+ /** Start (enable) or stop (disable) an instance. */
3066
+ async setInstanceEnabled(id, enabled) {
3067
+ const instance = await this.getInstance(id);
3068
+ const changed = !!instance.obj.common.enabled !== enabled;
3069
+ if (changed) {
3070
+ await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.defaultUser });
3071
+ }
3072
+ return { id: instance.id, enabled, changed };
3073
+ }
3074
+ /** Restart a running instance. */
3075
+ async restartInstance(id) {
3076
+ const instance = await this.getInstance(id);
3077
+ if (!instance.obj.common.enabled) {
3078
+ throw new Error(`Instance "${instance.id}" is not enabled - start it instead`);
3079
+ }
3080
+ await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.defaultUser });
3081
+ return { id: instance.id, restarted: true };
3082
+ }
3083
+ /**
3084
+ * Change the log level of an instance: temporarily via its `logLevel` state (no restart, until the
3085
+ * next start), or persistently in the instance object (restarts the instance).
3086
+ */
3087
+ async setLogLevel(id, level, persistent) {
3088
+ const instance = await this.getInstance(id);
3089
+ if (persistent) {
3090
+ await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { loglevel: level } }, { user: this.defaultUser });
3091
+ }
3092
+ else {
3093
+ await this.adapter.setForeignStateAsync(`${instance.fullId}.logLevel`, level, false, {
3094
+ user: this.defaultUser,
3095
+ });
3096
+ }
3097
+ return { id: instance.id, level, persistent: !!persistent };
3098
+ }
3099
+ /** Check an adapter name, as the CLI receives it as an argument. */
3100
+ static checkAdapterName(name) {
3101
+ const adapterName = name
3102
+ .trim()
3103
+ .replace(/^iobroker\./i, '')
3104
+ .toLowerCase();
3105
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(adapterName)) {
3106
+ throw new Error(`Invalid adapter name "${name}"`);
3107
+ }
3108
+ return adapterName;
3109
+ }
3110
+ /** Create an instance of an adapter (installs the adapter from the repository if necessary). */
3111
+ async addInstance(params) {
3112
+ const adapterName = McpServer.checkAdapterName(params.adapter);
3113
+ const host = await this.resolveHost(params.host);
3114
+ if (params.instance !== undefined) {
3115
+ const existing = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}.${params.instance}`, { user: this.defaultUser });
3116
+ if (existing) {
3117
+ throw new Error(`Instance "${adapterName}.${params.instance}" already exists`);
3118
+ }
3119
+ }
3120
+ return this.runHostCommand(host, `add ${adapterName}${params.instance !== undefined ? ` ${params.instance}` : ''} --host ${host}`, params.timeout);
3121
+ }
3122
+ /** Delete an instance with its objects and states (on the host it runs on). */
3123
+ async deleteInstance(params) {
3124
+ const instance = await this.getInstance(params.id);
3125
+ const host = await this.resolveHost(instance.obj.common.host);
3126
+ return this.runHostCommand(host, `del ${instance.id}`, params.timeout);
3127
+ }
3128
+ /** Upgrade an installed adapter to the repository version (or the given one). */
3129
+ async upgradeAdapter(params) {
3130
+ const adapterName = McpServer.checkAdapterName(params.adapter);
3131
+ if (params.version !== undefined && !/^\d+\.\d+\.\d+[\w.+-]*$/.test(params.version)) {
3132
+ throw new Error(`Invalid version "${params.version}"`);
3133
+ }
3134
+ const adapterObj = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}`, {
3135
+ user: this.defaultUser,
3136
+ });
3137
+ if (adapterObj?.type !== 'adapter') {
3138
+ throw new Error(`Adapter "${adapterName}" is not installed - use add_instance to install it`);
3139
+ }
3140
+ const version = params.version || (await this.getRepositoryJson())[adapterName]?.version;
3141
+ if (!version) {
3142
+ throw new Error(`Adapter "${adapterName}" is not in the active repository, give a version`);
3143
+ }
3144
+ const host = await this.resolveHost(params.host);
3145
+ return this.runHostCommand(host, `upgrade ${adapterName}@${version}`, params.timeout);
3146
+ }
3147
+ /** List the installed adapters that have a newer version in the active repository. */
3148
+ async listAdapterUpdates() {
3149
+ const repo = await this.getRepositoryJson();
3150
+ const result = [];
3151
+ for (const adapter of await this.listAdapters()) {
3152
+ const name = adapter.name;
3153
+ const available = repo[name]?.version;
3154
+ if (available && compareVersions(available, adapter.version) > 0) {
3155
+ result.push({ name, installed: adapter.version, available, title: adapter.title });
3156
+ }
3157
+ }
3158
+ return result;
3159
+ }
3160
+ /**
3161
+ * Read the JSON of the active repositories from `system.repositories` (or of all repositories if none
3162
+ * is marked active). An adapter present in several repos is kept once (first/active repo wins). This
3163
+ * matters when the user has custom repos active next to the default one.
3164
+ */
3165
+ async getRepositoryJson() {
3166
+ const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
3167
+ const repositories = (reposObj?.native?.repositories || {});
3168
+ const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
3169
+ const active = sysCfg?.common?.activeRepo;
3170
+ const activeNames = Array.isArray(active) ? active : active ? [active] : [];
3171
+ const activeWithJson = activeNames.filter(name => repositories[name]?.json);
3172
+ const sources = (activeWithJson.length ? activeWithJson.map(name => repositories[name]) : Object.values(repositories))
3173
+ .map(repo => repo?.json)
3174
+ .filter((j) => !!j);
3175
+ const json = {};
3176
+ for (const src of sources) {
3177
+ for (const key of Object.keys(src)) {
3178
+ if (!(key in json)) {
3179
+ json[key] = src[key];
3180
+ }
3181
+ }
3182
+ }
3183
+ return json;
3184
+ }
3185
+ /** Read the current notifications of one or all hosts, one entry per category and instance. */
3186
+ async listNotifications(params) {
3187
+ const lang = params.language || this.language;
3188
+ const hosts = params.host
3189
+ ? [await this.resolveHost(params.host)]
3190
+ : (await this.listHosts()).filter(host => host.alive).map(host => host.id);
3191
+ const result = [];
3192
+ for (const host of hosts) {
3193
+ const answer = await this.hostRequest(host, 'getNotifications', {});
3194
+ const scopes = (answer?.result || {});
3195
+ for (const [scope, scopeObj] of Object.entries(scopes)) {
3196
+ const categories = (scopeObj?.categories || {});
3197
+ for (const [category, categoryObj] of Object.entries(categories)) {
3198
+ const instances = (categoryObj?.instances || {});
3199
+ for (const [instance, instanceObj] of Object.entries(instances)) {
3200
+ const messages = (instanceObj?.messages || []);
3201
+ if (!messages.length) {
3202
+ continue;
3203
+ }
3204
+ result.push({
3205
+ host,
3206
+ scope,
3207
+ category,
3208
+ instance: instance.replace(/^system\.adapter\./, ''),
3209
+ severity: categoryObj.severity,
3210
+ name: getText(categoryObj.name, lang),
3211
+ description: getText(categoryObj.description, lang),
3212
+ messages: messages.map(one => ({ message: one.message, ts: one.ts })),
3213
+ });
3214
+ }
3215
+ }
3216
+ }
3217
+ }
3218
+ return result;
3219
+ }
3220
+ /** Acknowledge (delete) notifications on a host, optionally only of one scope/category/instance. */
3221
+ async clearNotifications(params) {
3222
+ const host = await this.resolveHost(params.host);
3223
+ const instance = params.instance ? `system.adapter.${params.instance.replace(/^system\.adapter\./, '')}` : null;
3224
+ await this.hostRequest(host, 'clearNotifications', {
3225
+ scope: params.scope || null,
3226
+ category: params.category || null,
3227
+ instance,
3228
+ });
3229
+ return { host, cleared: true };
3230
+ }
3231
+ /**
3232
+ * Normalize a script id: `script.js.common.lights`, `common.lights` or `lights` (folder `common`).
3233
+ * Every segment is reduced to the characters the javascript editor uses for ids.
3234
+ */
3235
+ static scriptId(id) {
3236
+ let path = id.trim().replace(/^script\.js\./, '');
3237
+ if (!path.includes('.')) {
3238
+ path = `common.${path}`;
3239
+ }
3240
+ const segments = path.split('.').map(segment => segment.trim().replace(/[^\p{L}\p{N}_-]/gu, '_'));
3241
+ if (segments.some(segment => !segment)) {
3242
+ throw new Error(`Invalid script id "${id}"`);
3243
+ }
3244
+ return `script.js.${segments.join('.')}`;
3245
+ }
3246
+ /** Summary of a script object, optionally with its source. */
3247
+ scriptInfo(id, obj, withSource) {
3248
+ const common = (obj.common || {});
3249
+ return {
3250
+ id,
3251
+ name: this.getName(common.name),
3252
+ engineType: common.engineType || 'Javascript/js',
3253
+ enabled: !!common.enabled,
3254
+ engine: (common.engine || '').replace(/^system\.adapter\./, ''),
3255
+ ...(withSource ? { source: common.source || '' } : {}),
3256
+ };
3257
+ }
3258
+ /** Read a script object, failing if it is missing or not a script. */
3259
+ async getScriptObject(id) {
3260
+ const scriptId = McpServer.scriptId(id);
3261
+ const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3262
+ if (obj?.type !== 'script') {
3263
+ throw new Error(`Script "${scriptId}" not found`);
3264
+ }
3265
+ return { id: scriptId, obj };
3266
+ }
3267
+ /** List the scripts of the javascript adapter (without their source). */
3268
+ async listScripts(folder) {
3269
+ const prefix = folder ? `script.js.${folder.replace(/^script\.js\./, '').replace(/\.$/, '')}.` : 'script.js.';
3270
+ const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.defaultUser });
3271
+ return Object.entries(objs || {}).map(([id, obj]) => this.scriptInfo(id, obj));
3272
+ }
3273
+ /**
3274
+ * Create a script or change the source of an existing one. Blockly and Rules scripts keep their
3275
+ * blocks in the source, so they are not changed. New scripts start disabled unless `enabled` is given.
3276
+ */
3277
+ async setScript(params) {
3278
+ const scriptId = McpServer.scriptId(params.id);
3279
+ const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
3280
+ if (existing && existing.type !== 'script') {
3281
+ throw new Error(`"${scriptId}" exists and is not a script`);
3282
+ }
3283
+ if (existing) {
3284
+ const engineType = existing.common.engineType || 'Javascript/js';
3285
+ if (!/^(javascript|typescript)/i.test(engineType)) {
3286
+ throw new Error(`"${scriptId}" is a ${engineType} script; only JavaScript/TypeScript can be changed`);
3287
+ }
3288
+ const common = { source: params.source };
3289
+ if (params.name !== undefined) {
3290
+ common.name = params.name;
3291
+ }
3292
+ if (params.enabled !== undefined) {
3293
+ common.enabled = params.enabled;
3294
+ }
3295
+ await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.defaultUser });
3296
+ return { id: scriptId, created: false };
3297
+ }
3298
+ const engine = (await this.getInstance(params.instance || 'javascript.0')).id;
3299
+ // The parent folders are channels, like the javascript editor creates them
3300
+ const segments = scriptId.split('.');
3301
+ for (let i = 3; i < segments.length; i++) {
3302
+ const folderId = segments.slice(0, i).join('.');
3303
+ if (!(await this.adapter.getForeignObjectAsync(folderId, { user: this.defaultUser }))) {
3304
+ await this.adapter.setForeignObjectAsync(folderId, { type: 'channel', common: { name: segments[i - 1] }, native: {} }, { user: this.defaultUser });
3305
+ }
3306
+ }
3307
+ await this.adapter.setForeignObjectAsync(scriptId, {
3308
+ type: 'script',
3309
+ common: {
3310
+ name: params.name || segments[segments.length - 1],
3311
+ expert: true,
3312
+ engineType: params.engineType || 'Javascript/js',
3313
+ enabled: !!params.enabled,
3314
+ engine: `system.adapter.${engine}`,
3315
+ source: params.source,
3316
+ debug: false,
3317
+ verbose: false,
3318
+ },
3319
+ native: {},
3320
+ }, { user: this.defaultUser });
3321
+ return { id: scriptId, created: true };
3322
+ }
3323
+ /** Enable (start) or disable (stop) a script. */
3324
+ async enableScript(id, enabled) {
3325
+ const script = await this.getScriptObject(id);
3326
+ await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.defaultUser });
3327
+ return { id: script.id, enabled };
3328
+ }
3329
+ /** Delete a script. */
3330
+ async deleteScript(id) {
3331
+ const script = await this.getScriptObject(id);
3332
+ await this.adapter.delForeignObjectAsync(script.id, { user: this.defaultUser });
3333
+ return { id: script.id, deleted: true };
3334
+ }
3335
+ /**
3336
+ * Run code once in the javascript adapter (its `execute` message) and return what it logged. Nothing
3337
+ * is stored; timers and subscriptions are cleaned up after `timeout`.
3338
+ */
3339
+ async runScript(params) {
3340
+ const instance = await this.getInstance(params.instance || 'javascript.0');
3341
+ const timeout = params.timeout ?? 5000;
3342
+ 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 });
3343
+ if (!result || typeof result !== 'object') {
3344
+ throw new Error(`"${instance.id}" did not answer - javascript adapter 10.1.0 or newer is required`);
3345
+ }
3346
+ return result;
3347
+ }
3348
+ /** Whether `send_to` may address this instance (by adapter name, instance or `*`). */
3349
+ isSendToAllowed(instance) {
3350
+ const allowed = (this.config.sendToAllowlist || '')
3351
+ .split(/[\s,;]+/)
3352
+ .map(one => one.replace(/^system\.adapter\./, ''))
3353
+ .filter(Boolean);
3354
+ return allowed.includes('*') || allowed.includes(instance) || allowed.includes(instance.split('.')[0]);
3355
+ }
3356
+ /** Send a message to an adapter instance (`sendTo`) and return its answer. */
3357
+ async sendToInstance(params) {
3358
+ const instance = await this.getInstance(params.instance);
3359
+ if (!this.isSendToAllowed(instance.id)) {
3360
+ throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
3361
+ }
3362
+ const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.defaultUser });
3363
+ return { instance: instance.id, command: params.command, response: response ?? null };
3364
+ }
3365
+ /**
3366
+ * Create an alias (`alias.0.*`) that points to another state. Type, role, unit and limits are copied
3367
+ * from the target unless given.
3368
+ */
3369
+ async createAlias(params) {
3370
+ const id = params.id.startsWith('alias.') ? params.id : `alias.0.${params.id}`;
3371
+ if (!/^alias\.\d+\.[^*\s]+$/.test(id)) {
3372
+ throw new Error(`Invalid alias id "${params.id}"`);
3373
+ }
3374
+ if (await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser })) {
3375
+ throw new Error(`Object "${id}" already exists`);
3376
+ }
3377
+ const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.defaultUser });
3378
+ if (target?.type !== 'state') {
3379
+ throw new Error(`Target "${params.target}" is not a state`);
3380
+ }
3381
+ if (params.write_target) {
3382
+ const writeTarget = await this.adapter.getForeignObjectAsync(params.write_target, {
3383
+ user: this.defaultUser,
3384
+ });
3385
+ if (writeTarget?.type !== 'state') {
3386
+ throw new Error(`Write target "${params.write_target}" is not a state`);
3387
+ }
3388
+ }
3389
+ const source = target.common;
3390
+ const common = {
3391
+ name: params.name || source.name || id.split('.').pop(),
3392
+ type: source.type || 'mixed',
3393
+ role: params.role || source.role || 'state',
3394
+ read: source.read !== false,
3395
+ write: params.write_target ? true : source.write !== false,
3396
+ alias: {
3397
+ id: params.write_target ? { read: params.target, write: params.write_target } : params.target,
3398
+ },
3399
+ };
3400
+ for (const key of ['min', 'max', 'step', 'states']) {
3401
+ if (source[key] !== undefined) {
3402
+ common[key] = source[key];
3403
+ }
3404
+ }
3405
+ if (params.unit !== undefined || source.unit !== undefined) {
3406
+ common.unit = params.unit ?? source.unit;
3407
+ }
3408
+ await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.defaultUser });
3409
+ return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
3410
+ }
2304
3411
  // --- helpers ---
2305
3412
  /** Resolve the member ids of a room/function enum matched by id or (localized) name. */
2306
3413
  async getEnumMembers(prefix, nameOrId) {
@@ -2336,6 +3443,7 @@ class McpServer {
2336
3443
  unload() {
2337
3444
  try {
2338
3445
  this.adapter.removeListener('log', this.onLog);
3446
+ this.adapter.removeListener('message', this.onMessage);
2339
3447
  }
2340
3448
  catch {
2341
3449
  // ignore