@iobroker/mcp-server 1.3.0 → 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"));
@@ -98,6 +99,29 @@ function extractAdapterName(source) {
98
99
  }
99
100
  return null;
100
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
+ }
101
125
  class McpServer {
102
126
  adapter;
103
127
  /** The Express app we attach routes to. Undefined in embedded in-process mode (no HTTP). */
@@ -135,7 +159,9 @@ class McpServer {
135
159
  oauth2;
136
160
  /** Installations started by `install_package`, keyed by the `cmdExec` session id. */
137
161
  cmdSessions = new Map();
138
- constructor(server, webSettings, adapter, instanceSettings, app) {
162
+ constructor(server,
163
+ // The permission toggles are explicit overrides for embedded mode (see `McpPermissions`)
164
+ webSettings, adapter, instanceSettings, app) {
139
165
  this.app = app;
140
166
  this.adapter = adapter;
141
167
  // Clone the source config so we never mutate the host adapter's config object — important in
@@ -153,23 +179,22 @@ class McpServer {
153
179
  this.config.defaultUser = this.defaultUser;
154
180
  this.language = webSettings.language || 'en';
155
181
  // Embedded callers may set the permission toggles explicitly (they don't read the mcp config).
156
- if (webSettings.allowSetState !== undefined) {
157
- this.config.allowSetState = webSettings.allowSetState;
158
- }
159
- if (webSettings.allowObjectChange !== undefined) {
160
- this.config.allowObjectChange = webSettings.allowObjectChange;
161
- }
162
- if (webSettings.setStateDestructive !== undefined) {
163
- this.config.setStateDestructive = webSettings.setStateDestructive;
164
- }
165
- if (webSettings.allowPackageInstall !== undefined) {
166
- this.config.allowPackageInstall = webSettings.allowPackageInstall;
182
+ for (const key of types_1.PERMISSION_KEYS) {
183
+ if (webSettings[key] !== undefined) {
184
+ this.config[key] = webSettings[key];
185
+ }
167
186
  }
168
- // Permission toggles: state writes are allowed by default, object/file changes and
169
- // package installation 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.
170
189
  this.config.allowSetState = this.config.allowSetState !== false;
171
190
  this.config.allowObjectChange = this.config.allowObjectChange === true;
191
+ this.config.allowInstanceControl = this.config.allowInstanceControl === true;
192
+ this.config.allowAdapterManagement = this.config.allowAdapterManagement === true;
172
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;
173
198
  // State writes are declared destructive unless turned off explicitly
174
199
  this.config.setStateDestructive = this.config.setStateDestructive !== false;
175
200
  // Authentication is enforced by us only when we run standalone (we own the Express app) and
@@ -193,8 +218,8 @@ class McpServer {
193
218
  }
194
219
  // Receive ioBroker log messages (only forwarded once a session subscribes via requireLog).
195
220
  this.adapter.on('log', this.onLog);
196
- if (this.config.allowPackageInstall) {
197
- // The host reports the progress of `cmdExec` (install_package) as messages to this instance
221
+ if (this.config.allowPackageInstall || this.config.allowAdapterManagement) {
222
+ // The host reports the progress of `cmdExec` (install_package etc.) as messages to this instance
198
223
  this.adapter.on('message', this.onMessage);
199
224
  }
200
225
  // Wire HTTP routes only when we own/share an Express app. In embedded in-process mode (no app)
@@ -219,22 +244,10 @@ class McpServer {
219
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.
220
245
  * @param options.defaultUser Default user
221
246
  * @param options.language Language
222
- * @param options.allowSetState Allow the state-writing tools `set_state`/`set_states` (opt-out, default true)
223
- * @param options.allowObjectChange Allow the object/file-changing tools (opt-in, default false)
224
- * @param options.setStateDestructive Declare `set_state`/`set_states` as destructive (default true)
225
- * @param options.allowPackageInstall Allow the `install_package` tool (opt-in, default false, developers only)
226
247
  */
227
248
  static createEmbedded(options) {
228
- return new McpServer(null, {
229
- secure: false,
230
- port: 0,
231
- defaultUser: options.defaultUser,
232
- language: options.language,
233
- allowSetState: options.allowSetState,
234
- allowObjectChange: options.allowObjectChange,
235
- setStateDestructive: options.setStateDestructive,
236
- allowPackageInstall: options.allowPackageInstall,
237
- }, options.adapter, null);
249
+ const { adapter, ...settings } = options;
250
+ return new McpServer(null, { secure: false, port: 0, ...settings }, adapter, null);
238
251
  }
239
252
  initRoutes() {
240
253
  const app = this.app;
@@ -718,6 +731,24 @@ class McpServer {
718
731
  this.adapter.log.warn(`MCP tool error: ${message}`);
719
732
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
720
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
+ };
721
752
  server.registerTool('get_states', {
722
753
  title: 'Get state values',
723
754
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -1206,6 +1237,7 @@ class McpServer {
1206
1237
  },
1207
1238
  }, async ({ id, recursive }) => {
1208
1239
  try {
1240
+ await confirm(`Delete the object "${id}"${recursive ? ' and all its children' : ''}?`);
1209
1241
  await this.deleteObject(id, recursive);
1210
1242
  return ok({ ok: true, data: { id, deleted: true } });
1211
1243
  }
@@ -1281,6 +1313,34 @@ class McpServer {
1281
1313
  return fail(e);
1282
1314
  }
1283
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
+ });
1284
1344
  server.registerTool('write_file', {
1285
1345
  title: 'Write a file',
1286
1346
  annotations: {
@@ -1371,6 +1431,260 @@ class McpServer {
1371
1431
  }
1372
1432
  });
1373
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
+ }
1374
1688
  // Installing packages runs arbitrary code on the host, so it is a separate opt-in for developers.
1375
1689
  if (this.config.allowPackageInstall) {
1376
1690
  server.registerTool('install_package', {
@@ -1419,6 +1733,7 @@ class McpServer {
1419
1733
  },
1420
1734
  }, async (args) => {
1421
1735
  try {
1736
+ await confirm(`Install the package "${args.source || args.file_path || args.file_name}"? It runs with full rights on the host.`);
1422
1737
  return ok({ ok: true, data: await this.installPackage(args) });
1423
1738
  }
1424
1739
  catch (e) {
@@ -1426,6 +1741,208 @@ class McpServer {
1426
1741
  }
1427
1742
  });
1428
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
+ }));
1429
1946
  // --- Resources: expose states and objects via the canonical ioBroker URI scheme ---
1430
1947
  // States as `iobstate://<id>`, objects as `iobobject://<id>`. On change the server pushes a
1431
1948
  // `notifications/resources/updated` over the session's SSE stream and the client re-reads.
@@ -2036,27 +2553,7 @@ class McpServer {
2036
2553
  const lang = params.language || this.language;
2037
2554
  const needle = (params.query || '').toLowerCase();
2038
2555
  const limit = params.limit ?? 20;
2039
- const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
2040
- const repositories = (reposObj?.native?.repositories || {});
2041
- // Prefer the active repository; fall back to the first repository that has content.
2042
- const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
2043
- const active = sysCfg?.common?.activeRepo;
2044
- const activeNames = Array.isArray(active) ? active : active ? [active] : [];
2045
- // Merge the JSON of ALL active repositories (or all repositories if none is marked active). An
2046
- // adapter present in several repos is kept once (first/active repo wins). This matters when the
2047
- // user has custom repos active next to the default one (their adapters are not in stable).
2048
- const activeWithJson = activeNames.filter(name => repositories[name]?.json);
2049
- const sources = (activeWithJson.length ? activeWithJson.map(name => repositories[name]) : Object.values(repositories))
2050
- .map(repo => repo?.json)
2051
- .filter((j) => !!j);
2052
- const json = {};
2053
- for (const src of sources) {
2054
- for (const key of Object.keys(src)) {
2055
- if (!(key in json)) {
2056
- json[key] = src[key];
2057
- }
2058
- }
2059
- }
2556
+ const json = await this.getRepositoryJson();
2060
2557
  if (!Object.keys(json).length) {
2061
2558
  return [];
2062
2559
  }
@@ -2421,14 +2918,7 @@ class McpServer {
2421
2918
  if (given.length !== 1) {
2422
2919
  throw new Error('Give exactly one of "source", "file_path" or "file_name" + "file_content"');
2423
2920
  }
2424
- const host = (params.host || this.adapter.host || '').replace(/^system\.host\./, '');
2425
- if (!host || /\s/.test(host)) {
2426
- throw new Error(`Invalid host "${host}"`);
2427
- }
2428
- const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${host}`, { user: this.defaultUser });
2429
- if (hostObj?.type !== 'host') {
2430
- throw new Error(`Host "${host}" not found`);
2431
- }
2921
+ const host = await this.resolveHost(params.host);
2432
2922
  let source;
2433
2923
  let files;
2434
2924
  if (params.source !== undefined) {
@@ -2471,11 +2961,39 @@ class McpServer {
2471
2961
  source = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
2472
2962
  files = [{ name: source, file: data.toString('base64') }];
2473
2963
  }
2474
- const command = `url ${source} --host ${host}${params.debug ? ' --debug' : ''}`;
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) {
2475
2993
  const id = `mcp-${(0, node_crypto_1.randomUUID)()}`;
2476
- const timeout = (params.timeout ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
2994
+ const timeout = (timeoutSeconds ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
2477
2995
  const output = [];
2478
- this.adapter.log.info(`[install_package] ${host}: iobroker ${command}`);
2996
+ this.adapter.log.info(`[cmdExec] ${host}: iobroker ${command}`);
2479
2997
  const exitCode = await new Promise(resolve => {
2480
2998
  const timer = setTimeout(() => {
2481
2999
  this.cmdSessions.delete(id);
@@ -2490,34 +3008,406 @@ class McpServer {
2490
3008
  });
2491
3009
  this.adapter.sendToHost(`system.host.${host}`, 'cmdExec', { data: command, id, files });
2492
3010
  });
2493
- const adapterName = extractAdapterName(params.file_path || params.file_name || params.source || '');
2494
- const restarted = [];
2495
- if (exitCode === 0 && adapterName && params.restart_instances !== false) {
2496
- // Like admin: touching the instance object makes the controller restart it with the new code
2497
- const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
2498
- user: this.defaultUser,
2499
- });
2500
- for (const [instanceId, instance] of Object.entries(instances || {})) {
2501
- if (instance?.common?.enabled) {
2502
- await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.defaultUser });
2503
- restarted.push(instanceId.replace('system.adapter.', ''));
2504
- }
2505
- }
2506
- }
2507
3011
  return {
2508
3012
  host,
2509
3013
  command: `iobroker ${command}`,
2510
- adapter: adapterName,
2511
3014
  ...(exitCode === null
2512
3015
  ? {
2513
3016
  finished: false,
2514
- note: `No result after ${timeout / 1000}s - the installation keeps running on the host, check the log`,
3017
+ note: `No result after ${timeout / 1000}s - the command keeps running on the host, check the log`,
2515
3018
  }
2516
3019
  : { finished: true, success: exitCode === 0, exitCode }),
2517
- restartedInstances: restarted,
2518
3020
  output,
2519
3021
  };
2520
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
+ }
2521
3411
  // --- helpers ---
2522
3412
  /** Resolve the member ids of a room/function enum matched by id or (localized) name. */
2523
3413
  async getEnumMembers(prefix, nameOrId) {