@iobroker/mcp-server 1.3.0 → 1.5.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/README.md +92 -9
- package/build/inProcessClient.d.ts +10 -0
- package/build/inProcessClient.js +4 -11
- package/build/inProcessClient.js.map +1 -1
- package/build/index.d.ts +118 -18
- package/build/index.js +1419 -126
- package/build/index.js.map +1 -1
- package/build/types.d.ts +28 -2
- package/build/types.js +14 -0
- package/build/types.js.map +1 -1
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
9
9
|
const node_net_1 = __importDefault(require("node:net"));
|
|
10
10
|
const node_dns_1 = __importDefault(require("node:dns"));
|
|
11
11
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
13
|
const node_child_process_1 = require("node:child_process");
|
|
13
14
|
const zod_1 = require("zod");
|
|
14
15
|
const express_1 = __importDefault(require("express"));
|
|
@@ -18,6 +19,7 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamable
|
|
|
18
19
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
19
20
|
const devices_1 = require("./devices");
|
|
20
21
|
const iob_uri_1 = require("./iob-uri");
|
|
22
|
+
const types_1 = require("./types");
|
|
21
23
|
const inProcessClient_1 = require("./inProcessClient");
|
|
22
24
|
Object.defineProperty(exports, "createInProcessMcp", { enumerable: true, get: function () { return inProcessClient_1.createInProcessMcp; } });
|
|
23
25
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
@@ -78,6 +80,43 @@ const DEFAULT_MAX_READ_BYTES = 512 * 1024;
|
|
|
78
80
|
const DEFAULT_INSTALL_TIMEOUT = 300;
|
|
79
81
|
/** How many output lines of an installation are returned to the client. */
|
|
80
82
|
const INSTALL_OUTPUT_LINES = 100;
|
|
83
|
+
/**
|
|
84
|
+
* Maximum size of a tool result. MCP clients (Claude.ai / Claude Desktop / Claude Code) reject results above
|
|
85
|
+
* 1 MiB, measured on the JSON-RPC message, in which the result text is escaped once more.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_RESULT_BYTES = 900 * 1024;
|
|
88
|
+
/** How long a download or upload link stays valid (ms). */
|
|
89
|
+
const TRANSFER_TTL = 15 * 60_000;
|
|
90
|
+
/** Largest file that can be uploaded through a link (it travels base64-encoded in a message to the host). */
|
|
91
|
+
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
92
|
+
/** Size of a text inside the JSON-RPC message, where it is escaped once more. */
|
|
93
|
+
function wireSize(text) {
|
|
94
|
+
return Buffer.byteLength(JSON.stringify(text));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Take the items from `offset` on, as many as fit into a tool result next to the rest of the answer.
|
|
98
|
+
* With `fromEnd`, the last items are kept instead (the newest log lines).
|
|
99
|
+
*/
|
|
100
|
+
function fitItems(all, offset = 0, fromEnd = false) {
|
|
101
|
+
const budget = MAX_RESULT_BYTES - 16 * 1024;
|
|
102
|
+
const items = all.slice(offset);
|
|
103
|
+
let size = 0;
|
|
104
|
+
let count = 0;
|
|
105
|
+
while (count < items.length) {
|
|
106
|
+
const item = items[fromEnd ? items.length - 1 - count : count];
|
|
107
|
+
size += wireSize(JSON.stringify(item)) + 1;
|
|
108
|
+
if (size > budget) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
count++;
|
|
112
|
+
}
|
|
113
|
+
if (count === items.length) {
|
|
114
|
+
return { items, total: all.length, offset, truncated: false };
|
|
115
|
+
}
|
|
116
|
+
return fromEnd
|
|
117
|
+
? { items: items.slice(items.length - count), total: all.length, offset, truncated: true }
|
|
118
|
+
: { items: items.slice(0, count), total: all.length, offset, nextOffset: offset + count, truncated: true };
|
|
119
|
+
}
|
|
81
120
|
/**
|
|
82
121
|
* Extract the adapter name from an install source, like `iobroker.admin-7.8.6.tgz`,
|
|
83
122
|
* `ioBroker/ioBroker.admin#main`, `iobroker.admin@7.8.6` or `admin`. Same rules as in ioBroker.admin.
|
|
@@ -98,6 +137,29 @@ function extractAdapterName(source) {
|
|
|
98
137
|
}
|
|
99
138
|
return null;
|
|
100
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Compare two versions like `3.9.0` and `3.10.0-alpha.1`: > 0 if `a` is newer, < 0 if `b` is newer.
|
|
142
|
+
* A pre-release is older than the release with the same numbers.
|
|
143
|
+
*/
|
|
144
|
+
function compareVersions(a, b) {
|
|
145
|
+
const [mainA, preA] = (a || '0').split('-', 2);
|
|
146
|
+
const [mainB, preB] = (b || '0').split('-', 2);
|
|
147
|
+
const partsA = mainA.split('.').map(part => parseInt(part, 10) || 0);
|
|
148
|
+
const partsB = mainB.split('.').map(part => parseInt(part, 10) || 0);
|
|
149
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
150
|
+
const diff = (partsA[i] || 0) - (partsB[i] || 0);
|
|
151
|
+
if (diff) {
|
|
152
|
+
return diff;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (preA === preB) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
if (!preA || !preB) {
|
|
159
|
+
return preA ? -1 : 1;
|
|
160
|
+
}
|
|
161
|
+
return preA.localeCompare(preB, undefined, { numeric: true });
|
|
162
|
+
}
|
|
101
163
|
class McpServer {
|
|
102
164
|
adapter;
|
|
103
165
|
/** The Express app we attach routes to. Undefined in embedded in-process mode (no HTTP). */
|
|
@@ -135,7 +197,23 @@ class McpServer {
|
|
|
135
197
|
oauth2;
|
|
136
198
|
/** Installations started by `install_package`, keyed by the `cmdExec` session id. */
|
|
137
199
|
cmdSessions = new Map();
|
|
138
|
-
|
|
200
|
+
/** Path of the MCP endpoint, e.g. `/mcp`. */
|
|
201
|
+
mcpPath;
|
|
202
|
+
/**
|
|
203
|
+
* Whether tools may hand out download/upload links (`<mcp>/transfer/<token>`). Needs our own HTTP
|
|
204
|
+
* routes; not as a web extension with authentication, where the web adapter asks for a login first.
|
|
205
|
+
*/
|
|
206
|
+
linksAvailable;
|
|
207
|
+
/** Why links are not available, for the answer of the tools. */
|
|
208
|
+
linksUnavailableReason;
|
|
209
|
+
/** Active download and upload links, keyed by their token. */
|
|
210
|
+
transfers = new Map();
|
|
211
|
+
/** Temporary folder for the files of the links, created on first use. */
|
|
212
|
+
transferDir;
|
|
213
|
+
transferTimer;
|
|
214
|
+
constructor(server,
|
|
215
|
+
// The permission toggles are explicit overrides for embedded mode (see `McpPermissions`)
|
|
216
|
+
webSettings, adapter, instanceSettings, app) {
|
|
139
217
|
this.app = app;
|
|
140
218
|
this.adapter = adapter;
|
|
141
219
|
// Clone the source config so we never mutate the host adapter's config object — important in
|
|
@@ -145,6 +223,10 @@ class McpServer {
|
|
|
145
223
|
};
|
|
146
224
|
this.extension = !!instanceSettings;
|
|
147
225
|
this.routerPrefix = this.extension ? `/${WEB_EXTENSION_PREFIX}` : '/';
|
|
226
|
+
// In standalone mode this is `/mcp`; as a web extension we own the `/<adapter>/` namespace
|
|
227
|
+
// (e.g. `/mcp/`) within the shared web server, and strip the trailing slash so the route
|
|
228
|
+
// matches both `/mcp` and `/mcp/` (Express, non-strict).
|
|
229
|
+
this.mcpPath = this.extension ? this.routerPrefix.replace(/\/$/, '') : '/mcp';
|
|
148
230
|
// Determine the ioBroker user whose permissions all MCP requests run with.
|
|
149
231
|
// Prefer this adapter's own setting; when embedded, fall back to the host web server's
|
|
150
232
|
// default user; finally to "admin". Always normalize to the "system.user." prefix.
|
|
@@ -153,23 +235,38 @@ class McpServer {
|
|
|
153
235
|
this.config.defaultUser = this.defaultUser;
|
|
154
236
|
this.language = webSettings.language || 'en';
|
|
155
237
|
// Embedded callers may set the permission toggles explicitly (they don't read the mcp config).
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
238
|
+
for (const key of types_1.PERMISSION_KEYS) {
|
|
239
|
+
if (webSettings[key] !== undefined) {
|
|
240
|
+
this.config[key] = webSettings[key];
|
|
241
|
+
}
|
|
167
242
|
}
|
|
168
|
-
// Permission toggles: state writes are allowed by default,
|
|
169
|
-
//
|
|
243
|
+
// Permission toggles: state writes are allowed by default, everything that changes the
|
|
244
|
+
// system (objects, instances, adapters, packages, scripts, messages) is not.
|
|
170
245
|
this.config.allowSetState = this.config.allowSetState !== false;
|
|
171
246
|
this.config.allowObjectChange = this.config.allowObjectChange === true;
|
|
247
|
+
this.config.allowInstanceControl = this.config.allowInstanceControl === true;
|
|
248
|
+
this.config.allowAdapterManagement = this.config.allowAdapterManagement === true;
|
|
172
249
|
this.config.allowPackageInstall = this.config.allowPackageInstall === true;
|
|
250
|
+
this.config.allowScriptChange = this.config.allowScriptChange === true;
|
|
251
|
+
this.config.sendToAllowlist = (this.config.sendToAllowlist || '').trim();
|
|
252
|
+
// Confirmations via elicitation are on unless turned off explicitly
|
|
253
|
+
this.config.confirmDangerous = this.config.confirmDangerous !== false;
|
|
254
|
+
this.config.transferLinks = this.config.transferLinks !== false;
|
|
255
|
+
if (!this.app) {
|
|
256
|
+
this.linksUnavailableReason = 'Links need the HTTP server of MCP, they are not available in this mode';
|
|
257
|
+
}
|
|
258
|
+
else if (!this.config.transferLinks) {
|
|
259
|
+
this.linksUnavailableReason =
|
|
260
|
+
'Links are turned off in the MCP adapter settings ("Allow download/upload links")';
|
|
261
|
+
}
|
|
262
|
+
else if (this.extension && webSettings.auth) {
|
|
263
|
+
this.linksUnavailableReason =
|
|
264
|
+
'Links are not available as a web extension with authentication enabled in the web adapter';
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
this.linksUnavailableReason = '';
|
|
268
|
+
}
|
|
269
|
+
this.linksAvailable = !this.linksUnavailableReason;
|
|
173
270
|
// State writes are declared destructive unless turned off explicitly
|
|
174
271
|
this.config.setStateDestructive = this.config.setStateDestructive !== false;
|
|
175
272
|
// Authentication is enforced by us only when we run standalone (we own the Express app) and
|
|
@@ -193,8 +290,8 @@ class McpServer {
|
|
|
193
290
|
}
|
|
194
291
|
// Receive ioBroker log messages (only forwarded once a session subscribes via requireLog).
|
|
195
292
|
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
|
|
293
|
+
if (this.config.allowPackageInstall || this.config.allowAdapterManagement) {
|
|
294
|
+
// The host reports the progress of `cmdExec` (install_package etc.) as messages to this instance
|
|
198
295
|
this.adapter.on('message', this.onMessage);
|
|
199
296
|
}
|
|
200
297
|
// Wire HTTP routes only when we own/share an Express app. In embedded in-process mode (no app)
|
|
@@ -219,22 +316,10 @@ class McpServer {
|
|
|
219
316
|
* @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
317
|
* @param options.defaultUser Default user
|
|
221
318
|
* @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
319
|
*/
|
|
227
320
|
static createEmbedded(options) {
|
|
228
|
-
|
|
229
|
-
|
|
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);
|
|
321
|
+
const { adapter, ...settings } = options;
|
|
322
|
+
return new McpServer(null, { secure: false, port: 0, ...settings }, adapter, null);
|
|
238
323
|
}
|
|
239
324
|
initRoutes() {
|
|
240
325
|
const app = this.app;
|
|
@@ -242,10 +327,7 @@ class McpServer {
|
|
|
242
327
|
// Embedded in-process mode: no HTTP transport, so there is nothing to route.
|
|
243
328
|
return;
|
|
244
329
|
}
|
|
245
|
-
|
|
246
|
-
// `/<adapter>/` namespace (e.g. `/mcp/`) within the shared web server. `.replace` strips
|
|
247
|
-
// the trailing slash so the route matches both `/mcp` and `/mcp/` (Express, non-strict).
|
|
248
|
-
const mcpPath = this.extension ? this.routerPrefix.replace(/\/$/, '') : '/mcp';
|
|
330
|
+
const mcpPath = this.mcpPath;
|
|
249
331
|
// The MCP transport needs a parsed JSON body; apply the parser per-route so we never
|
|
250
332
|
// touch the body handling of the web adapter or other extensions when running embedded.
|
|
251
333
|
const jsonParser = express_1.default.json({ limit: '4mb' });
|
|
@@ -262,7 +344,9 @@ class McpServer {
|
|
|
262
344
|
// As an extension these would collide with the web adapter's own routes.
|
|
263
345
|
if (!this.extension) {
|
|
264
346
|
app.use((req, _res, next) => {
|
|
265
|
-
|
|
347
|
+
// The token of a transfer link is a credential, so it does not go into the log
|
|
348
|
+
const url = req.url.replace(/\/transfer\/[^/?]+/, '/transfer/***');
|
|
349
|
+
this.adapter.log.debug(`${req.method} ${url} from ${req.ip}`);
|
|
266
350
|
next();
|
|
267
351
|
});
|
|
268
352
|
app.get('/', (_req, res) => {
|
|
@@ -357,6 +441,18 @@ class McpServer {
|
|
|
357
441
|
app.delete(mcpPath, ...guards, (req, res) => {
|
|
358
442
|
void this.handleMcpSessionRequest(req, res);
|
|
359
443
|
});
|
|
444
|
+
// --- Download and upload links (see createTransfer) ---
|
|
445
|
+
// No guards: the random token in the path is the credential. An authenticated MCP session got
|
|
446
|
+
// it from a tool and hands it to a program without MCP access, e.g. curl in a shell.
|
|
447
|
+
if (this.linksAvailable) {
|
|
448
|
+
app.get(`${mcpPath}/transfer/:token`, (req, res) => this.handleDownload(req, res));
|
|
449
|
+
app.put(`${mcpPath}/transfer/:token`, (req, res) => {
|
|
450
|
+
void this.handleUpload(req, res);
|
|
451
|
+
});
|
|
452
|
+
app.post(`${mcpPath}/transfer/:token`, (req, res) => {
|
|
453
|
+
void this.handleUpload(req, res);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
360
456
|
// Catch-all 404 + error handlers only in standalone mode; the web adapter provides its own.
|
|
361
457
|
if (!this.extension) {
|
|
362
458
|
app.use((req, res) => {
|
|
@@ -527,7 +623,7 @@ class McpServer {
|
|
|
527
623
|
return;
|
|
528
624
|
}
|
|
529
625
|
const subscriptions = new Set();
|
|
530
|
-
const server = this.createServer(subscriptions);
|
|
626
|
+
const server = this.createServer(subscriptions, this.getBaseUrl(req));
|
|
531
627
|
transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
532
628
|
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
533
629
|
onsessioninitialized: (sid) => {
|
|
@@ -709,24 +805,100 @@ class McpServer {
|
|
|
709
805
|
* Create a new MCP SDK server with all ioBroker tools registered.
|
|
710
806
|
*
|
|
711
807
|
* @param subscriptions the per-session set of subscribed state ids (mutated by subscribe/unsubscribe)
|
|
808
|
+
* @param baseUrl public address of this server, for download/upload links (none in-process)
|
|
712
809
|
*/
|
|
713
|
-
createServer(subscriptions) {
|
|
810
|
+
createServer(subscriptions, baseUrl) {
|
|
714
811
|
const server = new mcp_js_1.McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, resources: { subscribe: true } } });
|
|
715
|
-
const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
716
812
|
const fail = (error) => {
|
|
717
813
|
const message = error instanceof Error ? error.message : String(error);
|
|
718
814
|
this.adapter.log.warn(`MCP tool error: ${message}`);
|
|
719
815
|
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: message }) }], isError: true };
|
|
720
816
|
};
|
|
817
|
+
const canLink = this.linksAvailable && !!baseUrl;
|
|
818
|
+
// A download link for data that is too large for a tool result
|
|
819
|
+
const download = (name, data, contentType) => ({
|
|
820
|
+
download_url: this.transferUrl(baseUrl, this.createTransfer('download', name, data, contentType)),
|
|
821
|
+
expires_in: TRANSFER_TTL / 1000,
|
|
822
|
+
});
|
|
823
|
+
const linkHint = 'fetch it with e.g. `curl -o <file> "<download_url>"` if you can run shell commands';
|
|
824
|
+
// Clients reject results above 1 MiB without telling the server, so never send one
|
|
825
|
+
const ok = (data) => {
|
|
826
|
+
const text = JSON.stringify(data);
|
|
827
|
+
if (wireSize(text) <= MAX_RESULT_BYTES) {
|
|
828
|
+
return { content: [{ type: 'text', text }] };
|
|
829
|
+
}
|
|
830
|
+
const size = `${Math.round(Buffer.byteLength(text) / 1024)} KB`;
|
|
831
|
+
if (canLink) {
|
|
832
|
+
return {
|
|
833
|
+
content: [
|
|
834
|
+
{
|
|
835
|
+
type: 'text',
|
|
836
|
+
text: JSON.stringify({
|
|
837
|
+
ok: true,
|
|
838
|
+
too_large: true,
|
|
839
|
+
size,
|
|
840
|
+
...download('result.json', text, 'application/json'),
|
|
841
|
+
hint: `The result (${size}) is too large for one answer: narrow the request down, or ${linkHint}`,
|
|
842
|
+
}),
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return fail(new Error(`The result (${size}) is larger than ${MAX_RESULT_BYTES / 1024} KB, which MCP clients accept. ` +
|
|
848
|
+
'Narrow the request down (filters, wildcards, time range) or use offset/limit.'));
|
|
849
|
+
};
|
|
850
|
+
// Describe a cut list: how to get the rest, and a link to all of it (if it was cut for its size)
|
|
851
|
+
const pageInfo = (page, all, name, link = true) => {
|
|
852
|
+
if (!page.truncated) {
|
|
853
|
+
return {};
|
|
854
|
+
}
|
|
855
|
+
const info = { truncated: true, total: page.total };
|
|
856
|
+
const ways = [];
|
|
857
|
+
if (page.nextOffset !== undefined) {
|
|
858
|
+
info.nextOffset = page.nextOffset;
|
|
859
|
+
ways.push('call again with offset=nextOffset');
|
|
860
|
+
}
|
|
861
|
+
ways.push('narrow the request down');
|
|
862
|
+
if (canLink && link) {
|
|
863
|
+
Object.assign(info, download(name, JSON.stringify(all), 'application/json'));
|
|
864
|
+
ways.push(`get all ${all.length} entries from download_url: ${linkHint}`);
|
|
865
|
+
}
|
|
866
|
+
info.hint = `${link ? 'Not everything fits into one answer' : 'There are more entries'}: ${ways.join(', or ')}`;
|
|
867
|
+
return info;
|
|
868
|
+
};
|
|
869
|
+
const offsetSchema = zod_1.z.number().int().min(0).optional().describe('Skip this many entries (for paging)');
|
|
870
|
+
// Ask the user before a dangerous action, if the client can (MCP elicitation). Clients without
|
|
871
|
+
// elicitation rely on the destructiveHint of the tool instead.
|
|
872
|
+
const confirm = async (message) => {
|
|
873
|
+
if (!this.config.confirmDangerous || !server.server.getClientCapabilities()?.elicitation) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const answer = await server.server.elicitInput({
|
|
877
|
+
message,
|
|
878
|
+
requestedSchema: {
|
|
879
|
+
type: 'object',
|
|
880
|
+
properties: { confirm: { type: 'boolean', title: 'Yes, do it' } },
|
|
881
|
+
required: ['confirm'],
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
if (answer.action !== 'accept' || answer.content?.confirm !== true) {
|
|
885
|
+
throw new Error('Cancelled by the user');
|
|
886
|
+
}
|
|
887
|
+
};
|
|
721
888
|
server.registerTool('get_states', {
|
|
722
889
|
title: 'Get state values',
|
|
723
890
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
724
891
|
description: 'Retrieve the current value of one or multiple states. IDs may contain wildcards, ' +
|
|
725
892
|
'e.g. "hue.0.*.brightness" expands to all matching states.',
|
|
726
|
-
inputSchema: {
|
|
727
|
-
|
|
893
|
+
inputSchema: {
|
|
894
|
+
ids: zod_1.z.array(zod_1.z.string()).describe('Array of state IDs (wildcards "*" allowed)'),
|
|
895
|
+
offset: offsetSchema,
|
|
896
|
+
},
|
|
897
|
+
}, async ({ ids, offset }) => {
|
|
728
898
|
try {
|
|
729
|
-
|
|
899
|
+
const states = await this.getStates(ids);
|
|
900
|
+
const page = fitItems(states, offset);
|
|
901
|
+
return ok({ ok: true, data: { states: page.items, ...pageInfo(page, states, 'states.json') } });
|
|
730
902
|
}
|
|
731
903
|
catch (e) {
|
|
732
904
|
return fail(e);
|
|
@@ -808,7 +980,10 @@ class McpServer {
|
|
|
808
980
|
},
|
|
809
981
|
}, async (args) => {
|
|
810
982
|
try {
|
|
811
|
-
|
|
983
|
+
// Keep the newest lines if not all of them fit
|
|
984
|
+
const logs = await this.getLogs(args);
|
|
985
|
+
const page = fitItems(logs, 0, true);
|
|
986
|
+
return ok({ ok: true, data: { logs: page.items, ...pageInfo(page, logs, 'logs.json') } });
|
|
812
987
|
}
|
|
813
988
|
catch (e) {
|
|
814
989
|
return fail(e);
|
|
@@ -866,11 +1041,23 @@ class McpServer {
|
|
|
866
1041
|
role: zod_1.z.string().optional(),
|
|
867
1042
|
room: zod_1.z.string().optional(),
|
|
868
1043
|
adapter: zod_1.z.string().optional().describe('Filter by source adapter instance, e.g. "hue.0"'),
|
|
869
|
-
limit: zod_1.z.number().int().optional().describe('Maximum number of results (default 50)'),
|
|
1044
|
+
limit: zod_1.z.number().int().min(1).optional().describe('Maximum number of results (default 50)'),
|
|
1045
|
+
offset: offsetSchema,
|
|
870
1046
|
},
|
|
871
|
-
}, async (
|
|
1047
|
+
}, async ({ limit = 50, offset = 0, ...filters }) => {
|
|
872
1048
|
try {
|
|
873
|
-
|
|
1049
|
+
const matches = await this.searchObjects(filters);
|
|
1050
|
+
const page = fitItems(matches.slice(0, offset + limit), offset);
|
|
1051
|
+
const tooLarge = page.truncated;
|
|
1052
|
+
if (!page.truncated && matches.length > offset + limit) {
|
|
1053
|
+
page.truncated = true;
|
|
1054
|
+
page.nextOffset = offset + limit;
|
|
1055
|
+
}
|
|
1056
|
+
page.total = matches.length;
|
|
1057
|
+
return ok({
|
|
1058
|
+
ok: true,
|
|
1059
|
+
data: { results: page.items, ...pageInfo(page, matches, 'objects.json', tooLarge) },
|
|
1060
|
+
});
|
|
874
1061
|
}
|
|
875
1062
|
catch (e) {
|
|
876
1063
|
return fail(e);
|
|
@@ -933,10 +1120,16 @@ class McpServer {
|
|
|
933
1120
|
.describe('Quantile (0-1), only used with agg=quantile'),
|
|
934
1121
|
interval: zod_1.z.string().optional().describe('Aggregation interval, e.g. 15m, 1h'),
|
|
935
1122
|
limit: zod_1.z.number().int().optional().describe('Maximum number of values (default 1000)'),
|
|
1123
|
+
offset: offsetSchema,
|
|
936
1124
|
},
|
|
937
|
-
}, async (args) => {
|
|
1125
|
+
}, async ({ offset, ...args }) => {
|
|
938
1126
|
try {
|
|
939
|
-
|
|
1127
|
+
const result = await this.historyQuery(args);
|
|
1128
|
+
const page = fitItems(result.values, offset);
|
|
1129
|
+
return ok({
|
|
1130
|
+
ok: true,
|
|
1131
|
+
data: { ...result, values: page.items, ...pageInfo(page, result.values, 'history.json') },
|
|
1132
|
+
});
|
|
940
1133
|
}
|
|
941
1134
|
catch (e) {
|
|
942
1135
|
return fail(e);
|
|
@@ -1077,7 +1270,8 @@ class McpServer {
|
|
|
1077
1270
|
`At most \`length\` bytes (default ${DEFAULT_MAX_READ_BYTES}) are returned per call; ` +
|
|
1078
1271
|
'large files are read in chunks: when the result has `truncated: true`, call again with ' +
|
|
1079
1272
|
'`offset` set to the returned `nextOffset` until `truncated` is false. ' +
|
|
1080
|
-
'`size` is the total file size in bytes.'
|
|
1273
|
+
'`size` is the total file size in bytes. If you can run shell commands (e.g. Claude Code), ' +
|
|
1274
|
+
'set `download: true` instead to get a link to the whole file, which you fetch with curl.',
|
|
1081
1275
|
inputSchema: {
|
|
1082
1276
|
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
1083
1277
|
base64: zod_1.z.boolean().optional().describe('Return binary content base64-encoded'),
|
|
@@ -1093,9 +1287,34 @@ class McpServer {
|
|
|
1093
1287
|
.min(1)
|
|
1094
1288
|
.optional()
|
|
1095
1289
|
.describe(`Maximum number of bytes to return (default ${DEFAULT_MAX_READ_BYTES})`),
|
|
1290
|
+
download: zod_1.z
|
|
1291
|
+
.boolean()
|
|
1292
|
+
.optional()
|
|
1293
|
+
.describe('Return a download link to the whole file instead of its content'),
|
|
1096
1294
|
},
|
|
1097
|
-
}, async ({ path, base64, offset, length }) => {
|
|
1295
|
+
}, async ({ path, base64, offset, length, download: asLink }) => {
|
|
1098
1296
|
try {
|
|
1297
|
+
if (asLink) {
|
|
1298
|
+
if (!canLink) {
|
|
1299
|
+
throw new Error(this.linksUnavailableReason || 'Links are not available');
|
|
1300
|
+
}
|
|
1301
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
1302
|
+
const file = await this.adapter.readFileAsync(adapterName, fileName, {
|
|
1303
|
+
user: this.defaultUser,
|
|
1304
|
+
});
|
|
1305
|
+
const data = Buffer.isBuffer(file.file) ? file.file : Buffer.from(file.file || '');
|
|
1306
|
+
const mimeType = file.mimeType || 'application/octet-stream';
|
|
1307
|
+
return ok({
|
|
1308
|
+
ok: true,
|
|
1309
|
+
data: {
|
|
1310
|
+
path,
|
|
1311
|
+
size: data.length,
|
|
1312
|
+
mimeType,
|
|
1313
|
+
...download(fileName.split('/').pop() || 'file', data, mimeType),
|
|
1314
|
+
hint: linkHint,
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1099
1318
|
return ok({ ok: true, data: await this.readFile(path, base64, offset, length) });
|
|
1100
1319
|
}
|
|
1101
1320
|
catch (e) {
|
|
@@ -1106,10 +1325,12 @@ class McpServer {
|
|
|
1106
1325
|
title: 'List a directory',
|
|
1107
1326
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1108
1327
|
description: 'List a directory in an adapter file storage, e.g. "vis-2.0/main" or "0_userdata.0"',
|
|
1109
|
-
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"') },
|
|
1110
|
-
}, async ({ path }) => {
|
|
1328
|
+
inputSchema: { path: zod_1.z.string().describe('Path as "<adapter>[/<dir>]"'), offset: offsetSchema },
|
|
1329
|
+
}, async ({ path, offset }) => {
|
|
1111
1330
|
try {
|
|
1112
|
-
|
|
1331
|
+
const files = await this.listFiles(path);
|
|
1332
|
+
const page = fitItems(files, offset);
|
|
1333
|
+
return ok({ ok: true, data: { path, files: page.items, ...pageInfo(page, files, 'files.json') } });
|
|
1113
1334
|
}
|
|
1114
1335
|
catch (e) {
|
|
1115
1336
|
return fail(e);
|
|
@@ -1206,6 +1427,7 @@ class McpServer {
|
|
|
1206
1427
|
},
|
|
1207
1428
|
}, async ({ id, recursive }) => {
|
|
1208
1429
|
try {
|
|
1430
|
+
await confirm(`Delete the object "${id}"${recursive ? ' and all its children' : ''}?`);
|
|
1209
1431
|
await this.deleteObject(id, recursive);
|
|
1210
1432
|
return ok({ ok: true, data: { id, deleted: true } });
|
|
1211
1433
|
}
|
|
@@ -1281,6 +1503,34 @@ class McpServer {
|
|
|
1281
1503
|
return fail(e);
|
|
1282
1504
|
}
|
|
1283
1505
|
});
|
|
1506
|
+
server.registerTool('create_alias', {
|
|
1507
|
+
title: 'Create an alias',
|
|
1508
|
+
annotations: {
|
|
1509
|
+
readOnlyHint: false,
|
|
1510
|
+
destructiveHint: false,
|
|
1511
|
+
idempotentHint: false,
|
|
1512
|
+
openWorldHint: false,
|
|
1513
|
+
},
|
|
1514
|
+
description: 'Create an alias state (alias.0.*) that points to another state, e.g. to give a device a ' +
|
|
1515
|
+
'stable, readable id that survives replacing the hardware. Type, role, unit and limits are ' +
|
|
1516
|
+
'copied from the target. With write_target, reading and writing use different states ' +
|
|
1517
|
+
'(e.g. a thermostat with separate actual and set values). Fails if the alias exists.',
|
|
1518
|
+
inputSchema: {
|
|
1519
|
+
id: zod_1.z.string().describe('Alias id, e.g. "livingroom.ceiling_light" (becomes alias.0.…)'),
|
|
1520
|
+
target: zod_1.z.string().describe('State the alias reads (and writes), e.g. "hue.0.light.state"'),
|
|
1521
|
+
write_target: zod_1.z.string().optional().describe('Different state for writing'),
|
|
1522
|
+
name: zod_1.z.string().optional().describe('Display name (default: name of the target)'),
|
|
1523
|
+
role: zod_1.z.string().optional().describe('Role (default: role of the target)'),
|
|
1524
|
+
unit: zod_1.z.string().optional().describe('Unit (default: unit of the target)'),
|
|
1525
|
+
},
|
|
1526
|
+
}, async (args) => {
|
|
1527
|
+
try {
|
|
1528
|
+
return ok({ ok: true, data: await this.createAlias(args) });
|
|
1529
|
+
}
|
|
1530
|
+
catch (e) {
|
|
1531
|
+
return fail(e);
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1284
1534
|
server.registerTool('write_file', {
|
|
1285
1535
|
title: 'Write a file',
|
|
1286
1536
|
annotations: {
|
|
@@ -1289,14 +1539,29 @@ class McpServer {
|
|
|
1289
1539
|
idempotentHint: true,
|
|
1290
1540
|
openWorldHint: false,
|
|
1291
1541
|
},
|
|
1292
|
-
description: 'Write a file to an adapter file storage, e.g. "vis-2.0/main/vis-views.json"'
|
|
1542
|
+
description: 'Write a file to an adapter file storage, e.g. "vis-2.0/main/vis-views.json". Give the ' +
|
|
1543
|
+
'content, or - for a large file, if you can run shell commands - upload it with ' +
|
|
1544
|
+
'prepare_upload and give its upload_id.',
|
|
1293
1545
|
inputSchema: {
|
|
1294
1546
|
path: zod_1.z.string().describe('Path as "<adapter>/<dir>/<file>"'),
|
|
1295
|
-
content: zod_1.z.string().describe('File content (UTF-8, or base64 when base64=true)'),
|
|
1547
|
+
content: zod_1.z.string().optional().describe('File content (UTF-8, or base64 when base64=true)'),
|
|
1296
1548
|
base64: zod_1.z.boolean().optional().describe('Treat content as base64-encoded binary'),
|
|
1549
|
+
upload_id: zod_1.z.string().optional().describe('Id of a file uploaded via prepare_upload'),
|
|
1297
1550
|
},
|
|
1298
|
-
}, async ({ path, content, base64 }) => {
|
|
1551
|
+
}, async ({ path, content, base64, upload_id }) => {
|
|
1299
1552
|
try {
|
|
1553
|
+
if ((content === undefined) === (upload_id === undefined)) {
|
|
1554
|
+
throw new Error('Give either "content" or "upload_id"');
|
|
1555
|
+
}
|
|
1556
|
+
if (upload_id !== undefined) {
|
|
1557
|
+
const upload = this.getUpload(upload_id);
|
|
1558
|
+
const { adapterName, fileName } = McpServer.parseFilePath(path);
|
|
1559
|
+
await this.adapter.writeFileAsync(adapterName, fileName, node_fs_1.default.readFileSync(upload.file), {
|
|
1560
|
+
user: this.defaultUser,
|
|
1561
|
+
});
|
|
1562
|
+
this.removeTransfer(upload_id);
|
|
1563
|
+
return ok({ ok: true, data: { path, size: upload.size } });
|
|
1564
|
+
}
|
|
1300
1565
|
await this.writeFile(path, content, base64);
|
|
1301
1566
|
return ok({ ok: true, data: { path } });
|
|
1302
1567
|
}
|
|
@@ -1371,6 +1636,304 @@ class McpServer {
|
|
|
1371
1636
|
}
|
|
1372
1637
|
});
|
|
1373
1638
|
}
|
|
1639
|
+
server.registerTool('list_adapter_updates', {
|
|
1640
|
+
title: 'List adapter updates',
|
|
1641
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1642
|
+
description: 'List the installed adapters that have a newer version in the active repository ' +
|
|
1643
|
+
'(installed and available version). Reads the already-downloaded repository (no network).',
|
|
1644
|
+
inputSchema: {},
|
|
1645
|
+
}, async () => {
|
|
1646
|
+
try {
|
|
1647
|
+
return ok({ ok: true, data: { updates: await this.listAdapterUpdates() } });
|
|
1648
|
+
}
|
|
1649
|
+
catch (e) {
|
|
1650
|
+
return fail(e);
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
server.registerTool('list_notifications', {
|
|
1654
|
+
title: 'List system notifications',
|
|
1655
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1656
|
+
description: 'List the current system notifications of the hosts, as shown by the bell in ioBroker.admin ' +
|
|
1657
|
+
'(e.g. disk space low, adapter crashed repeatedly, adapter updates). Each entry has host, ' +
|
|
1658
|
+
'scope, category, severity, the affected instance and its messages.',
|
|
1659
|
+
inputSchema: {
|
|
1660
|
+
host: zod_1.z.string().optional().describe('Only this host (default: all running hosts)'),
|
|
1661
|
+
language: zod_1.z.enum(LANGUAGES).optional().describe('Language for names and descriptions'),
|
|
1662
|
+
},
|
|
1663
|
+
}, async (args) => {
|
|
1664
|
+
try {
|
|
1665
|
+
return ok({ ok: true, data: { notifications: await this.listNotifications(args) } });
|
|
1666
|
+
}
|
|
1667
|
+
catch (e) {
|
|
1668
|
+
return fail(e);
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
server.registerTool('list_scripts', {
|
|
1672
|
+
title: 'List scripts',
|
|
1673
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1674
|
+
description: 'List the scripts of the javascript adapter (JavaScript, TypeScript, Blockly, Rules) with ' +
|
|
1675
|
+
'id, name, type, whether they run, and the javascript instance - without the source.',
|
|
1676
|
+
inputSchema: {
|
|
1677
|
+
folder: zod_1.z.string().optional().describe('Only scripts in this folder, e.g. "common" or "lights"'),
|
|
1678
|
+
offset: offsetSchema,
|
|
1679
|
+
},
|
|
1680
|
+
}, async ({ folder, offset }) => {
|
|
1681
|
+
try {
|
|
1682
|
+
const scripts = await this.listScripts(folder);
|
|
1683
|
+
const page = fitItems(scripts, offset);
|
|
1684
|
+
return ok({
|
|
1685
|
+
ok: true,
|
|
1686
|
+
data: { scripts: page.items, ...pageInfo(page, scripts, 'scripts.json') },
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
catch (e) {
|
|
1690
|
+
return fail(e);
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
server.registerTool('get_script', {
|
|
1694
|
+
title: 'Read a script',
|
|
1695
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1696
|
+
description: 'Read a script of the javascript adapter including its source code',
|
|
1697
|
+
inputSchema: {
|
|
1698
|
+
id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights" or "common.lights"'),
|
|
1699
|
+
},
|
|
1700
|
+
}, async ({ id }) => {
|
|
1701
|
+
try {
|
|
1702
|
+
const script = await this.getScriptObject(id);
|
|
1703
|
+
return ok({ ok: true, data: this.scriptInfo(script.id, script.obj, true) });
|
|
1704
|
+
}
|
|
1705
|
+
catch (e) {
|
|
1706
|
+
return fail(e);
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
if (this.config.allowSetState) {
|
|
1710
|
+
server.registerTool('clear_notifications', {
|
|
1711
|
+
title: 'Acknowledge notifications',
|
|
1712
|
+
annotations: {
|
|
1713
|
+
readOnlyHint: false,
|
|
1714
|
+
destructiveHint: true,
|
|
1715
|
+
idempotentHint: true,
|
|
1716
|
+
openWorldHint: false,
|
|
1717
|
+
},
|
|
1718
|
+
description: 'Acknowledge (delete) system notifications of a host - all, or only those of one scope, ' +
|
|
1719
|
+
'category and/or instance as returned by list_notifications.',
|
|
1720
|
+
inputSchema: {
|
|
1721
|
+
host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
|
|
1722
|
+
scope: zod_1.z.string().optional().describe('e.g. "system"'),
|
|
1723
|
+
category: zod_1.z.string().optional().describe('e.g. "diskSpaceIssues"'),
|
|
1724
|
+
instance: zod_1.z.string().optional().describe('e.g. "hue.0"'),
|
|
1725
|
+
},
|
|
1726
|
+
}, async (args) => {
|
|
1727
|
+
try {
|
|
1728
|
+
return ok({ ok: true, data: await this.clearNotifications(args) });
|
|
1729
|
+
}
|
|
1730
|
+
catch (e) {
|
|
1731
|
+
return fail(e);
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
if (this.config.allowInstanceControl) {
|
|
1736
|
+
const instanceSchema = { id: zod_1.z.string().describe('Instance, e.g. "hue.0"') };
|
|
1737
|
+
server.registerTool('start_instance', {
|
|
1738
|
+
title: 'Start an instance',
|
|
1739
|
+
annotations: {
|
|
1740
|
+
readOnlyHint: false,
|
|
1741
|
+
destructiveHint: false,
|
|
1742
|
+
idempotentHint: true,
|
|
1743
|
+
openWorldHint: false,
|
|
1744
|
+
},
|
|
1745
|
+
description: 'Start (enable) an adapter instance',
|
|
1746
|
+
inputSchema: instanceSchema,
|
|
1747
|
+
}, async ({ id }) => {
|
|
1748
|
+
try {
|
|
1749
|
+
return ok({ ok: true, data: await this.setInstanceEnabled(id, true) });
|
|
1750
|
+
}
|
|
1751
|
+
catch (e) {
|
|
1752
|
+
return fail(e);
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
server.registerTool('stop_instance', {
|
|
1756
|
+
title: 'Stop an instance',
|
|
1757
|
+
annotations: {
|
|
1758
|
+
readOnlyHint: false,
|
|
1759
|
+
destructiveHint: true,
|
|
1760
|
+
idempotentHint: true,
|
|
1761
|
+
openWorldHint: false,
|
|
1762
|
+
},
|
|
1763
|
+
description: 'Stop (disable) an adapter instance. It stays stopped until it is started again, also ' +
|
|
1764
|
+
'after a restart of ioBroker.',
|
|
1765
|
+
inputSchema: instanceSchema,
|
|
1766
|
+
}, async ({ id }) => {
|
|
1767
|
+
try {
|
|
1768
|
+
await confirm(`Stop the instance "${id}"?`);
|
|
1769
|
+
return ok({ ok: true, data: await this.setInstanceEnabled(id, false) });
|
|
1770
|
+
}
|
|
1771
|
+
catch (e) {
|
|
1772
|
+
return fail(e);
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
server.registerTool('restart_instance', {
|
|
1776
|
+
title: 'Restart an instance',
|
|
1777
|
+
annotations: {
|
|
1778
|
+
readOnlyHint: false,
|
|
1779
|
+
destructiveHint: false,
|
|
1780
|
+
idempotentHint: false,
|
|
1781
|
+
openWorldHint: false,
|
|
1782
|
+
},
|
|
1783
|
+
description: 'Restart a running adapter instance, e.g. after changing its configuration',
|
|
1784
|
+
inputSchema: instanceSchema,
|
|
1785
|
+
}, async ({ id }) => {
|
|
1786
|
+
try {
|
|
1787
|
+
return ok({ ok: true, data: await this.restartInstance(id) });
|
|
1788
|
+
}
|
|
1789
|
+
catch (e) {
|
|
1790
|
+
return fail(e);
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
server.registerTool('set_log_level', {
|
|
1794
|
+
title: 'Set the log level of an instance',
|
|
1795
|
+
annotations: {
|
|
1796
|
+
readOnlyHint: false,
|
|
1797
|
+
destructiveHint: false,
|
|
1798
|
+
idempotentHint: true,
|
|
1799
|
+
openWorldHint: false,
|
|
1800
|
+
},
|
|
1801
|
+
description: 'Change the log level of an adapter instance, e.g. to "debug" to analyze a problem with ' +
|
|
1802
|
+
'get_logs. By default only until the next start of the instance and without a restart; ' +
|
|
1803
|
+
'with persistent=true it is stored in the instance configuration (restarts the instance).',
|
|
1804
|
+
inputSchema: {
|
|
1805
|
+
id: zod_1.z.string().describe('Instance, e.g. "hue.0"'),
|
|
1806
|
+
level: zod_1.z.enum(['silly', 'debug', 'info', 'warn', 'error']),
|
|
1807
|
+
persistent: zod_1.z.boolean().optional().describe('Store it permanently (default false)'),
|
|
1808
|
+
},
|
|
1809
|
+
}, async ({ id, level, persistent }) => {
|
|
1810
|
+
try {
|
|
1811
|
+
return ok({ ok: true, data: await this.setLogLevel(id, level, persistent) });
|
|
1812
|
+
}
|
|
1813
|
+
catch (e) {
|
|
1814
|
+
return fail(e);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
if (this.config.allowAdapterManagement) {
|
|
1819
|
+
const timeoutSchema = zod_1.z
|
|
1820
|
+
.number()
|
|
1821
|
+
.int()
|
|
1822
|
+
.min(1)
|
|
1823
|
+
.max(3600)
|
|
1824
|
+
.optional()
|
|
1825
|
+
.describe(`Seconds to wait (default ${DEFAULT_INSTALL_TIMEOUT}); the command keeps running on the host after that`);
|
|
1826
|
+
server.registerTool('add_instance', {
|
|
1827
|
+
title: 'Add an adapter instance',
|
|
1828
|
+
annotations: {
|
|
1829
|
+
readOnlyHint: false,
|
|
1830
|
+
destructiveHint: false,
|
|
1831
|
+
idempotentHint: false,
|
|
1832
|
+
openWorldHint: true,
|
|
1833
|
+
},
|
|
1834
|
+
description: 'Create a new instance of an adapter from the repository (`iobroker add`). Installs the ' +
|
|
1835
|
+
'adapter first if it is not installed yet. Find adapters with search_adapter_repository.',
|
|
1836
|
+
inputSchema: {
|
|
1837
|
+
adapter: zod_1.z.string().describe('Adapter name, e.g. "hue"'),
|
|
1838
|
+
instance: zod_1.z.number().int().min(0).optional().describe('Instance number (default: next free)'),
|
|
1839
|
+
host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
|
|
1840
|
+
timeout: timeoutSchema,
|
|
1841
|
+
},
|
|
1842
|
+
}, async (args) => {
|
|
1843
|
+
try {
|
|
1844
|
+
return ok({ ok: true, data: await this.addInstance(args) });
|
|
1845
|
+
}
|
|
1846
|
+
catch (e) {
|
|
1847
|
+
return fail(e);
|
|
1848
|
+
}
|
|
1849
|
+
});
|
|
1850
|
+
server.registerTool('delete_instance', {
|
|
1851
|
+
title: 'Delete an adapter instance',
|
|
1852
|
+
annotations: {
|
|
1853
|
+
readOnlyHint: false,
|
|
1854
|
+
destructiveHint: true,
|
|
1855
|
+
idempotentHint: false,
|
|
1856
|
+
openWorldHint: false,
|
|
1857
|
+
},
|
|
1858
|
+
description: 'Delete an adapter instance with its configuration, objects and states (`iobroker del`). ' +
|
|
1859
|
+
'This cannot be undone.',
|
|
1860
|
+
inputSchema: {
|
|
1861
|
+
id: zod_1.z.string().describe('Instance, e.g. "hue.0"'),
|
|
1862
|
+
timeout: timeoutSchema,
|
|
1863
|
+
},
|
|
1864
|
+
}, async (args) => {
|
|
1865
|
+
try {
|
|
1866
|
+
await confirm(`Delete the instance "${args.id}" with its configuration, objects and states? This cannot be undone.`);
|
|
1867
|
+
return ok({ ok: true, data: await this.deleteInstance(args) });
|
|
1868
|
+
}
|
|
1869
|
+
catch (e) {
|
|
1870
|
+
return fail(e);
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
server.registerTool('upgrade_adapter', {
|
|
1874
|
+
title: 'Upgrade an adapter',
|
|
1875
|
+
annotations: {
|
|
1876
|
+
readOnlyHint: false,
|
|
1877
|
+
destructiveHint: true,
|
|
1878
|
+
idempotentHint: true,
|
|
1879
|
+
openWorldHint: true,
|
|
1880
|
+
},
|
|
1881
|
+
description: 'Upgrade an installed adapter to the version of the active repository or to the given ' +
|
|
1882
|
+
'version (`iobroker upgrade`). Its instances are restarted. See list_adapter_updates.',
|
|
1883
|
+
inputSchema: {
|
|
1884
|
+
adapter: zod_1.z.string().describe('Adapter name, e.g. "hue"'),
|
|
1885
|
+
version: zod_1.z.string().optional().describe('Version (default: the repository version)'),
|
|
1886
|
+
host: zod_1.z.string().optional().describe('Host (default: the host of this instance)'),
|
|
1887
|
+
timeout: timeoutSchema,
|
|
1888
|
+
},
|
|
1889
|
+
}, async (args) => {
|
|
1890
|
+
try {
|
|
1891
|
+
await confirm(`Upgrade the adapter "${args.adapter}"${args.version ? ` to ${args.version}` : ''}?`);
|
|
1892
|
+
return ok({ ok: true, data: await this.upgradeAdapter(args) });
|
|
1893
|
+
}
|
|
1894
|
+
catch (e) {
|
|
1895
|
+
return fail(e);
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
// Large files cannot pass through the model: the client uploads them with a separate program
|
|
1900
|
+
if (canLink && (this.config.allowObjectChange || this.config.allowPackageInstall)) {
|
|
1901
|
+
server.registerTool('prepare_upload', {
|
|
1902
|
+
title: 'Prepare a file upload',
|
|
1903
|
+
annotations: {
|
|
1904
|
+
readOnlyHint: false,
|
|
1905
|
+
destructiveHint: false,
|
|
1906
|
+
idempotentHint: false,
|
|
1907
|
+
openWorldHint: false,
|
|
1908
|
+
},
|
|
1909
|
+
description: 'Get a one-time link to upload a file that is too large for a tool argument, e.g. a .tgz ' +
|
|
1910
|
+
'for install_package or a big file for write_file. Only useful if you can run shell ' +
|
|
1911
|
+
'commands (e.g. Claude Code): upload the local file with `curl -T <file> "<url>"`, then ' +
|
|
1912
|
+
`pass the returned upload_id to install_package or write_file. Valid for ${TRANSFER_TTL / 60_000} minutes, ` +
|
|
1913
|
+
`up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`,
|
|
1914
|
+
inputSchema: {
|
|
1915
|
+
name: zod_1.z.string().describe('File name, e.g. "iobroker.hue-3.9.1.tgz"'),
|
|
1916
|
+
},
|
|
1917
|
+
}, ({ name }) => {
|
|
1918
|
+
try {
|
|
1919
|
+
const token = this.createTransfer('upload', name);
|
|
1920
|
+
const url = this.transferUrl(baseUrl, token);
|
|
1921
|
+
return Promise.resolve(ok({
|
|
1922
|
+
ok: true,
|
|
1923
|
+
data: {
|
|
1924
|
+
upload_id: token,
|
|
1925
|
+
url,
|
|
1926
|
+
method: 'PUT',
|
|
1927
|
+
expires_in: TRANSFER_TTL / 1000,
|
|
1928
|
+
command: `curl -T "${name}" "${url}"`,
|
|
1929
|
+
},
|
|
1930
|
+
}));
|
|
1931
|
+
}
|
|
1932
|
+
catch (e) {
|
|
1933
|
+
return Promise.resolve(fail(e));
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1374
1937
|
// Installing packages runs arbitrary code on the host, so it is a separate opt-in for developers.
|
|
1375
1938
|
if (this.config.allowPackageInstall) {
|
|
1376
1939
|
server.registerTool('install_package', {
|
|
@@ -1386,7 +1949,8 @@ class McpServer {
|
|
|
1386
1949
|
'package ("iobroker.hue@3.9.0" or just "hue"), a GitHub repository ' +
|
|
1387
1950
|
'("ioBroker/ioBroker.hue#main" or https URL), an https URL of a .tgz or a .tgz path on the ' +
|
|
1388
1951
|
'target host; `file_path` - a .tgz in the ioBroker file storage ("0_userdata.0/x.tgz"); or ' +
|
|
1389
|
-
'`file_name` + `file_content` - a small .tgz as base64
|
|
1952
|
+
'`file_name` + `file_content` - a small .tgz as base64; or `upload_id` - a .tgz uploaded ' +
|
|
1953
|
+
'via prepare_upload (for large files, if you can run shell commands). Waits until the installation is ' +
|
|
1390
1954
|
'finished (or `timeout`), returns the exit code and the last output lines. Enabled instances ' +
|
|
1391
1955
|
'of the adapter are restarted afterwards; no new instance is created.',
|
|
1392
1956
|
inputSchema: {
|
|
@@ -1403,6 +1967,7 @@ class McpServer {
|
|
|
1403
1967
|
.optional()
|
|
1404
1968
|
.describe('File name of the .tgz in file_content, e.g. "iobroker.hue-3.9.0.tgz"'),
|
|
1405
1969
|
file_content: zod_1.z.string().optional().describe('The .tgz file as base64'),
|
|
1970
|
+
upload_id: zod_1.z.string().optional().describe('Id of a .tgz uploaded via prepare_upload'),
|
|
1406
1971
|
host: zod_1.z.string().optional().describe('Target host (default: the host of this instance)'),
|
|
1407
1972
|
debug: zod_1.z.boolean().optional().describe('Install with --debug for more output'),
|
|
1408
1973
|
restart_instances: zod_1.z
|
|
@@ -1419,6 +1984,7 @@ class McpServer {
|
|
|
1419
1984
|
},
|
|
1420
1985
|
}, async (args) => {
|
|
1421
1986
|
try {
|
|
1987
|
+
await confirm(`Install the package "${args.source || args.file_path || args.file_name || args.upload_id}"? It runs with full rights on the host.`);
|
|
1422
1988
|
return ok({ ok: true, data: await this.installPackage(args) });
|
|
1423
1989
|
}
|
|
1424
1990
|
catch (e) {
|
|
@@ -1426,22 +1992,224 @@ class McpServer {
|
|
|
1426
1992
|
}
|
|
1427
1993
|
});
|
|
1428
1994
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1995
|
+
if (this.config.allowScriptChange) {
|
|
1996
|
+
const engineTypeSchema = zod_1.z
|
|
1997
|
+
.enum(['Javascript/js', 'TypeScript/ts'])
|
|
1998
|
+
.optional()
|
|
1999
|
+
.describe('Script language (default "Javascript/js")');
|
|
2000
|
+
server.registerTool('set_script', {
|
|
2001
|
+
title: 'Create or change a script',
|
|
2002
|
+
annotations: {
|
|
2003
|
+
readOnlyHint: false,
|
|
2004
|
+
destructiveHint: true,
|
|
2005
|
+
idempotentHint: true,
|
|
2006
|
+
openWorldHint: false,
|
|
2007
|
+
},
|
|
2008
|
+
description: 'Create a JavaScript/TypeScript script of the javascript adapter or replace the source of ' +
|
|
2009
|
+
'an existing one (a running script restarts with the new source). New scripts are created ' +
|
|
2010
|
+
'disabled unless enabled=true; missing folders are created. Blockly and Rules scripts ' +
|
|
2011
|
+
'cannot be changed. Test code first with run_script.',
|
|
2012
|
+
inputSchema: {
|
|
2013
|
+
id: zod_1.z.string().describe('Script id, e.g. "common.lights" (folder "common") or "lights"'),
|
|
2014
|
+
source: zod_1.z.string().describe('The complete source code'),
|
|
2015
|
+
engineType: engineTypeSchema,
|
|
2016
|
+
name: zod_1.z.string().optional().describe('Display name (default: last part of the id)'),
|
|
2017
|
+
enabled: zod_1.z.boolean().optional().describe('Start (true) or stop (false) the script'),
|
|
2018
|
+
instance: zod_1.z.string().optional().describe('javascript instance (default "javascript.0")'),
|
|
2019
|
+
},
|
|
2020
|
+
}, async (args) => {
|
|
2021
|
+
try {
|
|
2022
|
+
await confirm(`Save the script "${args.id}"${args.enabled ? ' and start it' : ''}?`);
|
|
2023
|
+
return ok({ ok: true, data: await this.setScript(args) });
|
|
2024
|
+
}
|
|
2025
|
+
catch (e) {
|
|
2026
|
+
return fail(e);
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
server.registerTool('enable_script', {
|
|
2030
|
+
title: 'Start or stop a script',
|
|
2031
|
+
annotations: {
|
|
2032
|
+
readOnlyHint: false,
|
|
2033
|
+
destructiveHint: false,
|
|
2034
|
+
idempotentHint: true,
|
|
2035
|
+
openWorldHint: false,
|
|
2036
|
+
},
|
|
2037
|
+
description: 'Start (enabled=true) or stop (enabled=false) a script of the javascript adapter',
|
|
2038
|
+
inputSchema: {
|
|
2039
|
+
id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights"'),
|
|
2040
|
+
enabled: zod_1.z.boolean(),
|
|
2041
|
+
},
|
|
2042
|
+
}, async ({ id, enabled }) => {
|
|
2043
|
+
try {
|
|
2044
|
+
return ok({ ok: true, data: await this.enableScript(id, enabled) });
|
|
2045
|
+
}
|
|
2046
|
+
catch (e) {
|
|
2047
|
+
return fail(e);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
server.registerTool('delete_script', {
|
|
2051
|
+
title: 'Delete a script',
|
|
2052
|
+
annotations: {
|
|
2053
|
+
readOnlyHint: false,
|
|
2054
|
+
destructiveHint: true,
|
|
2055
|
+
idempotentHint: true,
|
|
2056
|
+
openWorldHint: false,
|
|
2057
|
+
},
|
|
2058
|
+
description: 'Delete a script of the javascript adapter',
|
|
2059
|
+
inputSchema: { id: zod_1.z.string().describe('Script id, e.g. "script.js.common.lights"') },
|
|
2060
|
+
}, async ({ id }) => {
|
|
2061
|
+
try {
|
|
2062
|
+
await confirm(`Delete the script "${id}"?`);
|
|
2063
|
+
return ok({ ok: true, data: await this.deleteScript(id) });
|
|
2064
|
+
}
|
|
2065
|
+
catch (e) {
|
|
2066
|
+
return fail(e);
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
server.registerTool('run_script', {
|
|
2070
|
+
title: 'Run code once',
|
|
2071
|
+
annotations: {
|
|
2072
|
+
readOnlyHint: false,
|
|
2073
|
+
destructiveHint: true,
|
|
2074
|
+
idempotentHint: false,
|
|
2075
|
+
openWorldHint: false,
|
|
2076
|
+
},
|
|
2077
|
+
description: 'Run JavaScript/TypeScript once in the javascript adapter, with the same API as a script ' +
|
|
2078
|
+
'(getState, setState, log, ...), and return everything it logged. Nothing is stored; ' +
|
|
2079
|
+
'timers and subscriptions end after `timeout`. Use it to test code before set_script. ' +
|
|
2080
|
+
'Requires javascript adapter 10.1.0 or newer.',
|
|
2081
|
+
inputSchema: {
|
|
2082
|
+
source: zod_1.z.string().describe('The code, e.g. log(getState("hue.0.light.state").val)'),
|
|
2083
|
+
engineType: engineTypeSchema,
|
|
2084
|
+
timeout: zod_1.z
|
|
2085
|
+
.number()
|
|
2086
|
+
.int()
|
|
2087
|
+
.min(0)
|
|
2088
|
+
.max(60000)
|
|
2089
|
+
.optional()
|
|
2090
|
+
.describe('Milliseconds to collect asynchronous output (default 5000)'),
|
|
2091
|
+
instance: zod_1.z.string().optional().describe('javascript instance (default "javascript.0")'),
|
|
2092
|
+
},
|
|
2093
|
+
}, async (args) => {
|
|
2094
|
+
try {
|
|
2095
|
+
await confirm('Run the code in the javascript adapter?');
|
|
2096
|
+
return ok({ ok: true, data: await this.runScript(args) });
|
|
2097
|
+
}
|
|
2098
|
+
catch (e) {
|
|
2099
|
+
return fail(e);
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
if (this.config.sendToAllowlist) {
|
|
2104
|
+
server.registerTool('send_to', {
|
|
2105
|
+
title: 'Send a message to an adapter',
|
|
2106
|
+
annotations: {
|
|
2107
|
+
readOnlyHint: false,
|
|
2108
|
+
destructiveHint: true,
|
|
2109
|
+
idempotentHint: false,
|
|
2110
|
+
openWorldHint: true,
|
|
2111
|
+
},
|
|
2112
|
+
description: 'Send a message to an adapter instance (`sendTo`) and return its answer, e.g. a Telegram ' +
|
|
2113
|
+
'message ({instance:"telegram.0", command:"send", message:{text:"Hi"}}) or a backup ' +
|
|
2114
|
+
`(backitup.0, "oneClick"). Allowed instances: ${this.config.sendToAllowlist}. The commands ` +
|
|
2115
|
+
'and the message format are described in the documentation of each adapter.',
|
|
2116
|
+
inputSchema: {
|
|
2117
|
+
instance: zod_1.z.string().describe('Instance, e.g. "telegram.0"'),
|
|
2118
|
+
command: zod_1.z.string().describe('Command, e.g. "send"'),
|
|
2119
|
+
message: zod_1.z.any().optional().describe('Message payload (object or string)'),
|
|
2120
|
+
timeout: zod_1.z
|
|
2121
|
+
.number()
|
|
2122
|
+
.int()
|
|
2123
|
+
.min(100)
|
|
2124
|
+
.max(120000)
|
|
2125
|
+
.optional()
|
|
2126
|
+
.describe('Milliseconds to wait for the answer (default 10000)'),
|
|
2127
|
+
},
|
|
2128
|
+
}, async (args) => {
|
|
2129
|
+
try {
|
|
2130
|
+
await confirm(`Send "${args.command}" to "${args.instance}"?`);
|
|
2131
|
+
return ok({ ok: true, data: await this.sendToInstance(args) });
|
|
2132
|
+
}
|
|
2133
|
+
catch (e) {
|
|
2134
|
+
return fail(e);
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
// --- Prompts: ready-made workflows that MCP clients offer as slash commands ---
|
|
2139
|
+
server.registerPrompt('diagnose_adapter', {
|
|
2140
|
+
title: 'Diagnose an adapter',
|
|
2141
|
+
description: 'Find out why an adapter instance does not work and suggest a fix',
|
|
2142
|
+
argsSchema: { instance: zod_1.z.string().describe('Instance, e.g. "hue.0"') },
|
|
2143
|
+
}, ({ instance }) => ({
|
|
2144
|
+
messages: [
|
|
2145
|
+
{
|
|
2146
|
+
role: 'user',
|
|
2147
|
+
content: {
|
|
2148
|
+
type: 'text',
|
|
2149
|
+
text: `Diagnose the ioBroker instance "${instance}". Check with list_instances whether it ` +
|
|
2150
|
+
`is enabled, alive and connected, read its errors and warnings with get_logs ` +
|
|
2151
|
+
`(adapter "${instance}"), look at list_notifications and, if the log names a device ` +
|
|
2152
|
+
`or service address, test it with ping_host. Compare its version with ` +
|
|
2153
|
+
`list_adapter_updates. Explain the cause in plain words and suggest the fix; ask ` +
|
|
2154
|
+
`before you change anything.`,
|
|
2155
|
+
},
|
|
2156
|
+
},
|
|
2157
|
+
],
|
|
2158
|
+
}));
|
|
2159
|
+
server.registerPrompt('room_overview', {
|
|
2160
|
+
title: 'Room overview',
|
|
2161
|
+
description: 'Summarize the devices of a room and their current state',
|
|
2162
|
+
argsSchema: { room: zod_1.z.string().describe('Room name, e.g. "Living room"') },
|
|
2163
|
+
}, ({ room }) => ({
|
|
2164
|
+
messages: [
|
|
2165
|
+
{
|
|
2166
|
+
role: 'user',
|
|
2167
|
+
content: {
|
|
2168
|
+
type: 'text',
|
|
2169
|
+
text: `Give me an overview of the room "${room}" in ioBroker: use list_devices for the ` +
|
|
2170
|
+
`room and get_states for the current values. Group them into lights, climate, ` +
|
|
2171
|
+
`windows/doors and others and point out anything unusual (open windows, lights on, ` +
|
|
2172
|
+
`low batteries, unreachable devices).`,
|
|
2173
|
+
},
|
|
2174
|
+
},
|
|
2175
|
+
],
|
|
2176
|
+
}));
|
|
2177
|
+
server.registerPrompt('create_automation', {
|
|
2178
|
+
title: 'Create an automation',
|
|
2179
|
+
description: 'Turn a description into an ioBroker script',
|
|
2180
|
+
argsSchema: { description: zod_1.z.string().describe('What should happen, e.g. "lights off at 23:00"') },
|
|
2181
|
+
}, ({ description }) => ({
|
|
2182
|
+
messages: [
|
|
2183
|
+
{
|
|
2184
|
+
role: 'user',
|
|
2185
|
+
content: {
|
|
2186
|
+
type: 'text',
|
|
2187
|
+
text: `Create an ioBroker automation: ${description}\n` +
|
|
2188
|
+
`Find the state ids with list_devices and search_objects, check the existing scripts ` +
|
|
2189
|
+
`with list_scripts and write a JavaScript for the javascript adapter (on(), ` +
|
|
2190
|
+
`schedule(), setState()). Test parts with run_script if available, show me the code ` +
|
|
2191
|
+
`and save it with set_script only after I agreed. If the script tools are not ` +
|
|
2192
|
+
`available, show me the code so I can paste it into the script editor.`,
|
|
2193
|
+
},
|
|
2194
|
+
},
|
|
2195
|
+
],
|
|
2196
|
+
}));
|
|
2197
|
+
// --- Resources: expose states and objects via the canonical ioBroker URI scheme ---
|
|
2198
|
+
// States as `iobstate://<id>`, objects as `iobobject://<id>`. On change the server pushes a
|
|
2199
|
+
// `notifications/resources/updated` over the session's SSE stream and the client re-reads.
|
|
2200
|
+
server.registerResource('state', new mcp_js_1.ResourceTemplate('iobstate://{id}', { list: undefined }), {
|
|
2201
|
+
title: 'ioBroker state',
|
|
2202
|
+
description: 'A single ioBroker state value, addressed as iobstate://<id>',
|
|
2203
|
+
mimeType: 'application/json',
|
|
2204
|
+
}, async (uri, variables) => {
|
|
2205
|
+
const id = decodeURIComponent(String(variables.id));
|
|
2206
|
+
const state = await this.adapter.getForeignStateAsync(id, { user: this.defaultUser });
|
|
2207
|
+
const body = state
|
|
2208
|
+
? { id, val: state.val, ack: state.ack, ts: state.ts, lc: state.lc, q: state.q }
|
|
2209
|
+
: { id, val: null };
|
|
2210
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(body) }] };
|
|
2211
|
+
});
|
|
2212
|
+
server.registerResource('object', new mcp_js_1.ResourceTemplate('iobobject://{id}', { list: undefined }), {
|
|
1445
2213
|
title: 'ioBroker object',
|
|
1446
2214
|
description: 'A single ioBroker object, addressed as iobobject://<id>',
|
|
1447
2215
|
mimeType: 'application/json',
|
|
@@ -1912,7 +2680,7 @@ class McpServer {
|
|
|
1912
2680
|
};
|
|
1913
2681
|
}
|
|
1914
2682
|
async searchObjects(params) {
|
|
1915
|
-
const { query = '', type, role, room, adapter
|
|
2683
|
+
const { query = '', type, role, room, adapter } = params;
|
|
1916
2684
|
const result = await this.adapter.getObjectListAsync({
|
|
1917
2685
|
startkey: '',
|
|
1918
2686
|
endkey: '\u9999',
|
|
@@ -1947,9 +2715,6 @@ class McpServer {
|
|
|
1947
2715
|
name,
|
|
1948
2716
|
adapter: sourceAdapter,
|
|
1949
2717
|
});
|
|
1950
|
-
if (results.length >= limit) {
|
|
1951
|
-
break;
|
|
1952
|
-
}
|
|
1953
2718
|
}
|
|
1954
2719
|
return results;
|
|
1955
2720
|
}
|
|
@@ -2036,27 +2801,7 @@ class McpServer {
|
|
|
2036
2801
|
const lang = params.language || this.language;
|
|
2037
2802
|
const needle = (params.query || '').toLowerCase();
|
|
2038
2803
|
const limit = params.limit ?? 20;
|
|
2039
|
-
const
|
|
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
|
-
}
|
|
2804
|
+
const json = await this.getRepositoryJson();
|
|
2060
2805
|
if (!Object.keys(json).length) {
|
|
2061
2806
|
return [];
|
|
2062
2807
|
}
|
|
@@ -2417,17 +3162,17 @@ class McpServer {
|
|
|
2417
3162
|
* ioBroker.admin installs from a custom URL or a .tgz file, and wait for the result.
|
|
2418
3163
|
*/
|
|
2419
3164
|
async installPackage(params) {
|
|
2420
|
-
const given = [params.source, params.file_path, params.file_content].filter(one => one !== undefined);
|
|
3165
|
+
const given = [params.source, params.file_path, params.file_content, params.upload_id].filter(one => one !== undefined);
|
|
2421
3166
|
if (given.length !== 1) {
|
|
2422
|
-
throw new Error('Give exactly one of "source", "file_path"
|
|
2423
|
-
}
|
|
2424
|
-
const host = (params.host || this.adapter.host || '').replace(/^system\.host\./, '');
|
|
2425
|
-
if (!host || /\s/.test(host)) {
|
|
2426
|
-
throw new Error(`Invalid host "${host}"`);
|
|
3167
|
+
throw new Error('Give exactly one of "source", "file_path", "file_name" + "file_content" or "upload_id"');
|
|
2427
3168
|
}
|
|
2428
|
-
const
|
|
2429
|
-
|
|
2430
|
-
|
|
3169
|
+
const host = await this.resolveHost(params.host);
|
|
3170
|
+
let upload;
|
|
3171
|
+
if (params.upload_id !== undefined) {
|
|
3172
|
+
upload = this.getUpload(params.upload_id);
|
|
3173
|
+
if (!upload.name.toLowerCase().endsWith('.tgz')) {
|
|
3174
|
+
throw new Error('Only .tgz files can be installed');
|
|
3175
|
+
}
|
|
2431
3176
|
}
|
|
2432
3177
|
let source;
|
|
2433
3178
|
let files;
|
|
@@ -2451,6 +3196,10 @@ class McpServer {
|
|
|
2451
3196
|
data = Buffer.isBuffer(result.file) ? result.file : Buffer.from(result.file || '');
|
|
2452
3197
|
fileName = storageName.split('/').pop() || '';
|
|
2453
3198
|
}
|
|
3199
|
+
else if (upload) {
|
|
3200
|
+
data = node_fs_1.default.readFileSync(upload.file);
|
|
3201
|
+
fileName = upload.name;
|
|
3202
|
+
}
|
|
2454
3203
|
else {
|
|
2455
3204
|
if (!params.file_name) {
|
|
2456
3205
|
throw new Error('"file_content" requires "file_name"');
|
|
@@ -2471,11 +3220,42 @@ class McpServer {
|
|
|
2471
3220
|
source = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
2472
3221
|
files = [{ name: source, file: data.toString('base64') }];
|
|
2473
3222
|
}
|
|
2474
|
-
const
|
|
3223
|
+
const result = await this.runHostCommand(host, `url ${source} --host ${host}${params.debug ? ' --debug' : ''}`, params.timeout, files);
|
|
3224
|
+
if (upload && result.finished) {
|
|
3225
|
+
this.removeTransfer(params.upload_id);
|
|
3226
|
+
}
|
|
3227
|
+
const adapterName = extractAdapterName(params.file_path || params.file_name || upload?.name || params.source || '');
|
|
3228
|
+
const restarted = result.success && adapterName && params.restart_instances !== false
|
|
3229
|
+
? await this.restartAdapterInstances(adapterName)
|
|
3230
|
+
: [];
|
|
3231
|
+
return { ...result, adapter: adapterName, restartedInstances: restarted };
|
|
3232
|
+
}
|
|
3233
|
+
/** Normalize a host name (with or without `system.host.`) and check that the host exists. */
|
|
3234
|
+
async resolveHost(host) {
|
|
3235
|
+
const name = (host || this.adapter.host || '').replace(/^system\.host\./, '');
|
|
3236
|
+
if (!name || /\s/.test(name)) {
|
|
3237
|
+
throw new Error(`Invalid host "${name}"`);
|
|
3238
|
+
}
|
|
3239
|
+
const hostObj = await this.adapter.getForeignObjectAsync(`system.host.${name}`, { user: this.defaultUser });
|
|
3240
|
+
if (hostObj?.type !== 'host') {
|
|
3241
|
+
throw new Error(`Host "${name}" not found`);
|
|
3242
|
+
}
|
|
3243
|
+
return name;
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Run an ioBroker CLI command on a host via `cmdExec` and wait for its exit code. The host reports
|
|
3247
|
+
* the output and the exit code as messages to this instance, see {@link onMessage}.
|
|
3248
|
+
*
|
|
3249
|
+
* @param host host name without `system.host.`
|
|
3250
|
+
* @param command the CLI arguments, e.g. `upgrade hue@3.9.0`
|
|
3251
|
+
* @param timeoutSeconds how long to wait; the command keeps running on the host after that
|
|
3252
|
+
* @param files files sent along with the command (the command refers to them by name)
|
|
3253
|
+
*/
|
|
3254
|
+
async runHostCommand(host, command, timeoutSeconds, files) {
|
|
2475
3255
|
const id = `mcp-${(0, node_crypto_1.randomUUID)()}`;
|
|
2476
|
-
const timeout = (
|
|
3256
|
+
const timeout = (timeoutSeconds ?? DEFAULT_INSTALL_TIMEOUT) * 1000;
|
|
2477
3257
|
const output = [];
|
|
2478
|
-
this.adapter.log.info(`[
|
|
3258
|
+
this.adapter.log.info(`[cmdExec] ${host}: iobroker ${command}`);
|
|
2479
3259
|
const exitCode = await new Promise(resolve => {
|
|
2480
3260
|
const timer = setTimeout(() => {
|
|
2481
3261
|
this.cmdSessions.delete(id);
|
|
@@ -2490,34 +3270,538 @@ class McpServer {
|
|
|
2490
3270
|
});
|
|
2491
3271
|
this.adapter.sendToHost(`system.host.${host}`, 'cmdExec', { data: command, id, files });
|
|
2492
3272
|
});
|
|
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
3273
|
return {
|
|
2508
3274
|
host,
|
|
2509
3275
|
command: `iobroker ${command}`,
|
|
2510
|
-
adapter: adapterName,
|
|
2511
3276
|
...(exitCode === null
|
|
2512
3277
|
? {
|
|
2513
3278
|
finished: false,
|
|
2514
|
-
note: `No result after ${timeout / 1000}s - the
|
|
3279
|
+
note: `No result after ${timeout / 1000}s - the command keeps running on the host, check the log`,
|
|
2515
3280
|
}
|
|
2516
3281
|
: { finished: true, success: exitCode === 0, exitCode }),
|
|
2517
|
-
restartedInstances: restarted,
|
|
2518
3282
|
output,
|
|
2519
3283
|
};
|
|
2520
3284
|
}
|
|
3285
|
+
/** Request something from a host (`getNotifications`, ...) and wait for the answer. */
|
|
3286
|
+
hostRequest(host, command, message) {
|
|
3287
|
+
return new Promise((resolve, reject) => {
|
|
3288
|
+
const timer = setTimeout(() => reject(new Error(`Host "${host}" did not answer "${command}"`)), 10_000);
|
|
3289
|
+
this.adapter.sendToHost(`system.host.${host}`, command, message, (result) => {
|
|
3290
|
+
clearTimeout(timer);
|
|
3291
|
+
if (result?.error) {
|
|
3292
|
+
reject(new Error(result.error));
|
|
3293
|
+
}
|
|
3294
|
+
else {
|
|
3295
|
+
resolve(result);
|
|
3296
|
+
}
|
|
3297
|
+
});
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
/** Like admin after an installation: touching the instance objects makes the controller restart them. */
|
|
3301
|
+
async restartAdapterInstances(adapterName) {
|
|
3302
|
+
const instances = await this.adapter.getForeignObjectsAsync(`system.adapter.${adapterName}.*`, 'instance', {
|
|
3303
|
+
user: this.defaultUser,
|
|
3304
|
+
});
|
|
3305
|
+
const restarted = [];
|
|
3306
|
+
for (const [instanceId, instance] of Object.entries(instances || {})) {
|
|
3307
|
+
if (instance?.common?.enabled) {
|
|
3308
|
+
await this.adapter.extendForeignObjectAsync(instanceId, {}, { user: this.defaultUser });
|
|
3309
|
+
restarted.push(instanceId.replace('system.adapter.', ''));
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
return restarted;
|
|
3313
|
+
}
|
|
3314
|
+
/** Read an instance object by its short id (`hue.0`) or full id (`system.adapter.hue.0`). */
|
|
3315
|
+
async getInstance(id) {
|
|
3316
|
+
const shortId = id.replace(/^system\.adapter\./, '');
|
|
3317
|
+
if (!/^[a-z0-9][a-z0-9_-]*\.\d+$/i.test(shortId)) {
|
|
3318
|
+
throw new Error(`Invalid instance "${id}", expected e.g. "hue.0"`);
|
|
3319
|
+
}
|
|
3320
|
+
const fullId = `system.adapter.${shortId}`;
|
|
3321
|
+
const obj = await this.adapter.getForeignObjectAsync(fullId, { user: this.defaultUser });
|
|
3322
|
+
if (obj?.type !== 'instance') {
|
|
3323
|
+
throw new Error(`Instance "${shortId}" not found`);
|
|
3324
|
+
}
|
|
3325
|
+
return { id: shortId, fullId, obj: obj };
|
|
3326
|
+
}
|
|
3327
|
+
/** Start (enable) or stop (disable) an instance. */
|
|
3328
|
+
async setInstanceEnabled(id, enabled) {
|
|
3329
|
+
const instance = await this.getInstance(id);
|
|
3330
|
+
const changed = !!instance.obj.common.enabled !== enabled;
|
|
3331
|
+
if (changed) {
|
|
3332
|
+
await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { enabled } }, { user: this.defaultUser });
|
|
3333
|
+
}
|
|
3334
|
+
return { id: instance.id, enabled, changed };
|
|
3335
|
+
}
|
|
3336
|
+
/** Restart a running instance. */
|
|
3337
|
+
async restartInstance(id) {
|
|
3338
|
+
const instance = await this.getInstance(id);
|
|
3339
|
+
if (!instance.obj.common.enabled) {
|
|
3340
|
+
throw new Error(`Instance "${instance.id}" is not enabled - start it instead`);
|
|
3341
|
+
}
|
|
3342
|
+
await this.adapter.extendForeignObjectAsync(instance.fullId, {}, { user: this.defaultUser });
|
|
3343
|
+
return { id: instance.id, restarted: true };
|
|
3344
|
+
}
|
|
3345
|
+
/**
|
|
3346
|
+
* Change the log level of an instance: temporarily via its `logLevel` state (no restart, until the
|
|
3347
|
+
* next start), or persistently in the instance object (restarts the instance).
|
|
3348
|
+
*/
|
|
3349
|
+
async setLogLevel(id, level, persistent) {
|
|
3350
|
+
const instance = await this.getInstance(id);
|
|
3351
|
+
if (persistent) {
|
|
3352
|
+
await this.adapter.extendForeignObjectAsync(instance.fullId, { common: { loglevel: level } }, { user: this.defaultUser });
|
|
3353
|
+
}
|
|
3354
|
+
else {
|
|
3355
|
+
await this.adapter.setForeignStateAsync(`${instance.fullId}.logLevel`, level, false, {
|
|
3356
|
+
user: this.defaultUser,
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
return { id: instance.id, level, persistent: !!persistent };
|
|
3360
|
+
}
|
|
3361
|
+
/** Check an adapter name, as the CLI receives it as an argument. */
|
|
3362
|
+
static checkAdapterName(name) {
|
|
3363
|
+
const adapterName = name
|
|
3364
|
+
.trim()
|
|
3365
|
+
.replace(/^iobroker\./i, '')
|
|
3366
|
+
.toLowerCase();
|
|
3367
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(adapterName)) {
|
|
3368
|
+
throw new Error(`Invalid adapter name "${name}"`);
|
|
3369
|
+
}
|
|
3370
|
+
return adapterName;
|
|
3371
|
+
}
|
|
3372
|
+
/** Create an instance of an adapter (installs the adapter from the repository if necessary). */
|
|
3373
|
+
async addInstance(params) {
|
|
3374
|
+
const adapterName = McpServer.checkAdapterName(params.adapter);
|
|
3375
|
+
const host = await this.resolveHost(params.host);
|
|
3376
|
+
if (params.instance !== undefined) {
|
|
3377
|
+
const existing = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}.${params.instance}`, { user: this.defaultUser });
|
|
3378
|
+
if (existing) {
|
|
3379
|
+
throw new Error(`Instance "${adapterName}.${params.instance}" already exists`);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
return this.runHostCommand(host, `add ${adapterName}${params.instance !== undefined ? ` ${params.instance}` : ''} --host ${host}`, params.timeout);
|
|
3383
|
+
}
|
|
3384
|
+
/** Delete an instance with its objects and states (on the host it runs on). */
|
|
3385
|
+
async deleteInstance(params) {
|
|
3386
|
+
const instance = await this.getInstance(params.id);
|
|
3387
|
+
const host = await this.resolveHost(instance.obj.common.host);
|
|
3388
|
+
return this.runHostCommand(host, `del ${instance.id}`, params.timeout);
|
|
3389
|
+
}
|
|
3390
|
+
/** Upgrade an installed adapter to the repository version (or the given one). */
|
|
3391
|
+
async upgradeAdapter(params) {
|
|
3392
|
+
const adapterName = McpServer.checkAdapterName(params.adapter);
|
|
3393
|
+
if (params.version !== undefined && !/^\d+\.\d+\.\d+[\w.+-]*$/.test(params.version)) {
|
|
3394
|
+
throw new Error(`Invalid version "${params.version}"`);
|
|
3395
|
+
}
|
|
3396
|
+
const adapterObj = await this.adapter.getForeignObjectAsync(`system.adapter.${adapterName}`, {
|
|
3397
|
+
user: this.defaultUser,
|
|
3398
|
+
});
|
|
3399
|
+
if (adapterObj?.type !== 'adapter') {
|
|
3400
|
+
throw new Error(`Adapter "${adapterName}" is not installed - use add_instance to install it`);
|
|
3401
|
+
}
|
|
3402
|
+
const version = params.version || (await this.getRepositoryJson())[adapterName]?.version;
|
|
3403
|
+
if (!version) {
|
|
3404
|
+
throw new Error(`Adapter "${adapterName}" is not in the active repository, give a version`);
|
|
3405
|
+
}
|
|
3406
|
+
const host = await this.resolveHost(params.host);
|
|
3407
|
+
return this.runHostCommand(host, `upgrade ${adapterName}@${version}`, params.timeout);
|
|
3408
|
+
}
|
|
3409
|
+
/** List the installed adapters that have a newer version in the active repository. */
|
|
3410
|
+
async listAdapterUpdates() {
|
|
3411
|
+
const repo = await this.getRepositoryJson();
|
|
3412
|
+
const result = [];
|
|
3413
|
+
for (const adapter of await this.listAdapters()) {
|
|
3414
|
+
const name = adapter.name;
|
|
3415
|
+
const available = repo[name]?.version;
|
|
3416
|
+
if (available && compareVersions(available, adapter.version) > 0) {
|
|
3417
|
+
result.push({ name, installed: adapter.version, available, title: adapter.title });
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
return result;
|
|
3421
|
+
}
|
|
3422
|
+
/**
|
|
3423
|
+
* Read the JSON of the active repositories from `system.repositories` (or of all repositories if none
|
|
3424
|
+
* is marked active). An adapter present in several repos is kept once (first/active repo wins). This
|
|
3425
|
+
* matters when the user has custom repos active next to the default one.
|
|
3426
|
+
*/
|
|
3427
|
+
async getRepositoryJson() {
|
|
3428
|
+
const reposObj = await this.adapter.getForeignObjectAsync('system.repositories', { user: this.defaultUser });
|
|
3429
|
+
const repositories = (reposObj?.native?.repositories || {});
|
|
3430
|
+
const sysCfg = await this.adapter.getForeignObjectAsync('system.config', { user: this.defaultUser });
|
|
3431
|
+
const active = sysCfg?.common?.activeRepo;
|
|
3432
|
+
const activeNames = Array.isArray(active) ? active : active ? [active] : [];
|
|
3433
|
+
const activeWithJson = activeNames.filter(name => repositories[name]?.json);
|
|
3434
|
+
const sources = (activeWithJson.length ? activeWithJson.map(name => repositories[name]) : Object.values(repositories))
|
|
3435
|
+
.map(repo => repo?.json)
|
|
3436
|
+
.filter((j) => !!j);
|
|
3437
|
+
const json = {};
|
|
3438
|
+
for (const src of sources) {
|
|
3439
|
+
for (const key of Object.keys(src)) {
|
|
3440
|
+
if (!(key in json)) {
|
|
3441
|
+
json[key] = src[key];
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
return json;
|
|
3446
|
+
}
|
|
3447
|
+
/** Read the current notifications of one or all hosts, one entry per category and instance. */
|
|
3448
|
+
async listNotifications(params) {
|
|
3449
|
+
const lang = params.language || this.language;
|
|
3450
|
+
const hosts = params.host
|
|
3451
|
+
? [await this.resolveHost(params.host)]
|
|
3452
|
+
: (await this.listHosts()).filter(host => host.alive).map(host => host.id);
|
|
3453
|
+
const result = [];
|
|
3454
|
+
for (const host of hosts) {
|
|
3455
|
+
const answer = await this.hostRequest(host, 'getNotifications', {});
|
|
3456
|
+
const scopes = (answer?.result || {});
|
|
3457
|
+
for (const [scope, scopeObj] of Object.entries(scopes)) {
|
|
3458
|
+
const categories = (scopeObj?.categories || {});
|
|
3459
|
+
for (const [category, categoryObj] of Object.entries(categories)) {
|
|
3460
|
+
const instances = (categoryObj?.instances || {});
|
|
3461
|
+
for (const [instance, instanceObj] of Object.entries(instances)) {
|
|
3462
|
+
const messages = (instanceObj?.messages || []);
|
|
3463
|
+
if (!messages.length) {
|
|
3464
|
+
continue;
|
|
3465
|
+
}
|
|
3466
|
+
result.push({
|
|
3467
|
+
host,
|
|
3468
|
+
scope,
|
|
3469
|
+
category,
|
|
3470
|
+
instance: instance.replace(/^system\.adapter\./, ''),
|
|
3471
|
+
severity: categoryObj.severity,
|
|
3472
|
+
name: getText(categoryObj.name, lang),
|
|
3473
|
+
description: getText(categoryObj.description, lang),
|
|
3474
|
+
messages: messages.map(one => ({ message: one.message, ts: one.ts })),
|
|
3475
|
+
});
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
return result;
|
|
3481
|
+
}
|
|
3482
|
+
/** Acknowledge (delete) notifications on a host, optionally only of one scope/category/instance. */
|
|
3483
|
+
async clearNotifications(params) {
|
|
3484
|
+
const host = await this.resolveHost(params.host);
|
|
3485
|
+
const instance = params.instance ? `system.adapter.${params.instance.replace(/^system\.adapter\./, '')}` : null;
|
|
3486
|
+
await this.hostRequest(host, 'clearNotifications', {
|
|
3487
|
+
scope: params.scope || null,
|
|
3488
|
+
category: params.category || null,
|
|
3489
|
+
instance,
|
|
3490
|
+
});
|
|
3491
|
+
return { host, cleared: true };
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Normalize a script id: `script.js.common.lights`, `common.lights` or `lights` (folder `common`).
|
|
3495
|
+
* Every segment is reduced to the characters the javascript editor uses for ids.
|
|
3496
|
+
*/
|
|
3497
|
+
static scriptId(id) {
|
|
3498
|
+
let path = id.trim().replace(/^script\.js\./, '');
|
|
3499
|
+
if (!path.includes('.')) {
|
|
3500
|
+
path = `common.${path}`;
|
|
3501
|
+
}
|
|
3502
|
+
const segments = path.split('.').map(segment => segment.trim().replace(/[^\p{L}\p{N}_-]/gu, '_'));
|
|
3503
|
+
if (segments.some(segment => !segment)) {
|
|
3504
|
+
throw new Error(`Invalid script id "${id}"`);
|
|
3505
|
+
}
|
|
3506
|
+
return `script.js.${segments.join('.')}`;
|
|
3507
|
+
}
|
|
3508
|
+
/** Summary of a script object, optionally with its source. */
|
|
3509
|
+
scriptInfo(id, obj, withSource) {
|
|
3510
|
+
const common = (obj.common || {});
|
|
3511
|
+
return {
|
|
3512
|
+
id,
|
|
3513
|
+
name: this.getName(common.name),
|
|
3514
|
+
engineType: common.engineType || 'Javascript/js',
|
|
3515
|
+
enabled: !!common.enabled,
|
|
3516
|
+
engine: (common.engine || '').replace(/^system\.adapter\./, ''),
|
|
3517
|
+
...(withSource ? { source: common.source || '' } : {}),
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
/** Read a script object, failing if it is missing or not a script. */
|
|
3521
|
+
async getScriptObject(id) {
|
|
3522
|
+
const scriptId = McpServer.scriptId(id);
|
|
3523
|
+
const obj = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
|
|
3524
|
+
if (obj?.type !== 'script') {
|
|
3525
|
+
throw new Error(`Script "${scriptId}" not found`);
|
|
3526
|
+
}
|
|
3527
|
+
return { id: scriptId, obj };
|
|
3528
|
+
}
|
|
3529
|
+
/** List the scripts of the javascript adapter (without their source). */
|
|
3530
|
+
async listScripts(folder) {
|
|
3531
|
+
const prefix = folder ? `script.js.${folder.replace(/^script\.js\./, '').replace(/\.$/, '')}.` : 'script.js.';
|
|
3532
|
+
const objs = await this.adapter.getForeignObjectsAsync(`${prefix}*`, 'script', { user: this.defaultUser });
|
|
3533
|
+
return Object.entries(objs || {}).map(([id, obj]) => this.scriptInfo(id, obj));
|
|
3534
|
+
}
|
|
3535
|
+
/**
|
|
3536
|
+
* Create a script or change the source of an existing one. Blockly and Rules scripts keep their
|
|
3537
|
+
* blocks in the source, so they are not changed. New scripts start disabled unless `enabled` is given.
|
|
3538
|
+
*/
|
|
3539
|
+
async setScript(params) {
|
|
3540
|
+
const scriptId = McpServer.scriptId(params.id);
|
|
3541
|
+
const existing = await this.adapter.getForeignObjectAsync(scriptId, { user: this.defaultUser });
|
|
3542
|
+
if (existing && existing.type !== 'script') {
|
|
3543
|
+
throw new Error(`"${scriptId}" exists and is not a script`);
|
|
3544
|
+
}
|
|
3545
|
+
if (existing) {
|
|
3546
|
+
const engineType = existing.common.engineType || 'Javascript/js';
|
|
3547
|
+
if (!/^(javascript|typescript)/i.test(engineType)) {
|
|
3548
|
+
throw new Error(`"${scriptId}" is a ${engineType} script; only JavaScript/TypeScript can be changed`);
|
|
3549
|
+
}
|
|
3550
|
+
const common = { source: params.source };
|
|
3551
|
+
if (params.name !== undefined) {
|
|
3552
|
+
common.name = params.name;
|
|
3553
|
+
}
|
|
3554
|
+
if (params.enabled !== undefined) {
|
|
3555
|
+
common.enabled = params.enabled;
|
|
3556
|
+
}
|
|
3557
|
+
await this.adapter.extendForeignObjectAsync(scriptId, { common }, { user: this.defaultUser });
|
|
3558
|
+
return { id: scriptId, created: false };
|
|
3559
|
+
}
|
|
3560
|
+
const engine = (await this.getInstance(params.instance || 'javascript.0')).id;
|
|
3561
|
+
// The parent folders are channels, like the javascript editor creates them
|
|
3562
|
+
const segments = scriptId.split('.');
|
|
3563
|
+
for (let i = 3; i < segments.length; i++) {
|
|
3564
|
+
const folderId = segments.slice(0, i).join('.');
|
|
3565
|
+
if (!(await this.adapter.getForeignObjectAsync(folderId, { user: this.defaultUser }))) {
|
|
3566
|
+
await this.adapter.setForeignObjectAsync(folderId, { type: 'channel', common: { name: segments[i - 1] }, native: {} }, { user: this.defaultUser });
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
await this.adapter.setForeignObjectAsync(scriptId, {
|
|
3570
|
+
type: 'script',
|
|
3571
|
+
common: {
|
|
3572
|
+
name: params.name || segments[segments.length - 1],
|
|
3573
|
+
expert: true,
|
|
3574
|
+
engineType: params.engineType || 'Javascript/js',
|
|
3575
|
+
enabled: !!params.enabled,
|
|
3576
|
+
engine: `system.adapter.${engine}`,
|
|
3577
|
+
source: params.source,
|
|
3578
|
+
debug: false,
|
|
3579
|
+
verbose: false,
|
|
3580
|
+
},
|
|
3581
|
+
native: {},
|
|
3582
|
+
}, { user: this.defaultUser });
|
|
3583
|
+
return { id: scriptId, created: true };
|
|
3584
|
+
}
|
|
3585
|
+
/** Enable (start) or disable (stop) a script. */
|
|
3586
|
+
async enableScript(id, enabled) {
|
|
3587
|
+
const script = await this.getScriptObject(id);
|
|
3588
|
+
await this.adapter.extendForeignObjectAsync(script.id, { common: { enabled } }, { user: this.defaultUser });
|
|
3589
|
+
return { id: script.id, enabled };
|
|
3590
|
+
}
|
|
3591
|
+
/** Delete a script. */
|
|
3592
|
+
async deleteScript(id) {
|
|
3593
|
+
const script = await this.getScriptObject(id);
|
|
3594
|
+
await this.adapter.delForeignObjectAsync(script.id, { user: this.defaultUser });
|
|
3595
|
+
return { id: script.id, deleted: true };
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* Run code once in the javascript adapter (its `execute` message) and return what it logged. Nothing
|
|
3599
|
+
* is stored; timers and subscriptions are cleaned up after `timeout`.
|
|
3600
|
+
*/
|
|
3601
|
+
async runScript(params) {
|
|
3602
|
+
const instance = await this.getInstance(params.instance || 'javascript.0');
|
|
3603
|
+
const timeout = params.timeout ?? 5000;
|
|
3604
|
+
const result = await this.adapter.sendToAsync(instance.id, 'execute', { source: params.source, engineType: params.engineType, timeout, verbose: false, logLevel: 'info' }, { timeout: timeout + 15_000, user: this.defaultUser });
|
|
3605
|
+
if (!result || typeof result !== 'object') {
|
|
3606
|
+
throw new Error(`"${instance.id}" did not answer - javascript adapter 10.1.0 or newer is required`);
|
|
3607
|
+
}
|
|
3608
|
+
return result;
|
|
3609
|
+
}
|
|
3610
|
+
/** Whether `send_to` may address this instance (by adapter name, instance or `*`). */
|
|
3611
|
+
isSendToAllowed(instance) {
|
|
3612
|
+
const allowed = (this.config.sendToAllowlist || '')
|
|
3613
|
+
.split(/[\s,;]+/)
|
|
3614
|
+
.map(one => one.replace(/^system\.adapter\./, ''))
|
|
3615
|
+
.filter(Boolean);
|
|
3616
|
+
return allowed.includes('*') || allowed.includes(instance) || allowed.includes(instance.split('.')[0]);
|
|
3617
|
+
}
|
|
3618
|
+
/** Send a message to an adapter instance (`sendTo`) and return its answer. */
|
|
3619
|
+
async sendToInstance(params) {
|
|
3620
|
+
const instance = await this.getInstance(params.instance);
|
|
3621
|
+
if (!this.isSendToAllowed(instance.id)) {
|
|
3622
|
+
throw new Error(`"${instance.id}" is not in the allowlist of send_to`);
|
|
3623
|
+
}
|
|
3624
|
+
const response = await this.adapter.sendToAsync(instance.id, params.command, (params.message ?? {}), { timeout: params.timeout ?? 10_000, user: this.defaultUser });
|
|
3625
|
+
return { instance: instance.id, command: params.command, response: response ?? null };
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Create an alias (`alias.0.*`) that points to another state. Type, role, unit and limits are copied
|
|
3629
|
+
* from the target unless given.
|
|
3630
|
+
*/
|
|
3631
|
+
async createAlias(params) {
|
|
3632
|
+
const id = params.id.startsWith('alias.') ? params.id : `alias.0.${params.id}`;
|
|
3633
|
+
if (!/^alias\.\d+\.[^*\s]+$/.test(id)) {
|
|
3634
|
+
throw new Error(`Invalid alias id "${params.id}"`);
|
|
3635
|
+
}
|
|
3636
|
+
if (await this.adapter.getForeignObjectAsync(id, { user: this.defaultUser })) {
|
|
3637
|
+
throw new Error(`Object "${id}" already exists`);
|
|
3638
|
+
}
|
|
3639
|
+
const target = await this.adapter.getForeignObjectAsync(params.target, { user: this.defaultUser });
|
|
3640
|
+
if (target?.type !== 'state') {
|
|
3641
|
+
throw new Error(`Target "${params.target}" is not a state`);
|
|
3642
|
+
}
|
|
3643
|
+
if (params.write_target) {
|
|
3644
|
+
const writeTarget = await this.adapter.getForeignObjectAsync(params.write_target, {
|
|
3645
|
+
user: this.defaultUser,
|
|
3646
|
+
});
|
|
3647
|
+
if (writeTarget?.type !== 'state') {
|
|
3648
|
+
throw new Error(`Write target "${params.write_target}" is not a state`);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
const source = target.common;
|
|
3652
|
+
const common = {
|
|
3653
|
+
name: params.name || source.name || id.split('.').pop(),
|
|
3654
|
+
type: source.type || 'mixed',
|
|
3655
|
+
role: params.role || source.role || 'state',
|
|
3656
|
+
read: source.read !== false,
|
|
3657
|
+
write: params.write_target ? true : source.write !== false,
|
|
3658
|
+
alias: {
|
|
3659
|
+
id: params.write_target ? { read: params.target, write: params.write_target } : params.target,
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
for (const key of ['min', 'max', 'step', 'states']) {
|
|
3663
|
+
if (source[key] !== undefined) {
|
|
3664
|
+
common[key] = source[key];
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
if (params.unit !== undefined || source.unit !== undefined) {
|
|
3668
|
+
common.unit = params.unit ?? source.unit;
|
|
3669
|
+
}
|
|
3670
|
+
await this.adapter.setForeignObjectAsync(id, { type: 'state', common, native: {} }, { user: this.defaultUser });
|
|
3671
|
+
return { id, target: params.target, ...(params.write_target ? { write_target: params.write_target } : {}) };
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Create a download link for `data`, or an upload link when `data` is undefined, and return its
|
|
3675
|
+
* token. The file lives in a temporary folder until the link expires (or the upload is used).
|
|
3676
|
+
*/
|
|
3677
|
+
createTransfer(kind, name, data, contentType = 'application/octet-stream') {
|
|
3678
|
+
this.cleanupTransfers();
|
|
3679
|
+
if (!this.transferDir) {
|
|
3680
|
+
this.transferDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'iobroker-mcp-'));
|
|
3681
|
+
this.transferTimer = setInterval(() => this.cleanupTransfers(), 60_000);
|
|
3682
|
+
this.transferTimer.unref?.();
|
|
3683
|
+
}
|
|
3684
|
+
const token = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
3685
|
+
// One folder per link, so the file keeps its name (`npm` needs the .tgz extension)
|
|
3686
|
+
const folder = node_path_1.default.join(this.transferDir, token);
|
|
3687
|
+
node_fs_1.default.mkdirSync(folder);
|
|
3688
|
+
const file = node_path_1.default.join(folder, name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'file');
|
|
3689
|
+
if (data !== undefined) {
|
|
3690
|
+
node_fs_1.default.writeFileSync(file, data);
|
|
3691
|
+
}
|
|
3692
|
+
this.transfers.set(token, {
|
|
3693
|
+
kind,
|
|
3694
|
+
name,
|
|
3695
|
+
file,
|
|
3696
|
+
contentType,
|
|
3697
|
+
expires: Date.now() + TRANSFER_TTL,
|
|
3698
|
+
size: data !== undefined ? Buffer.byteLength(data) : undefined,
|
|
3699
|
+
});
|
|
3700
|
+
return token;
|
|
3701
|
+
}
|
|
3702
|
+
/** The public URL of a download or upload link. */
|
|
3703
|
+
transferUrl(baseUrl, token) {
|
|
3704
|
+
return `${baseUrl}${this.mcpPath}/transfer/${token}`;
|
|
3705
|
+
}
|
|
3706
|
+
/** Remove a link and its file. */
|
|
3707
|
+
removeTransfer(token) {
|
|
3708
|
+
const transfer = this.transfers.get(token);
|
|
3709
|
+
if (transfer) {
|
|
3710
|
+
this.transfers.delete(token);
|
|
3711
|
+
try {
|
|
3712
|
+
node_fs_1.default.rmSync(node_path_1.default.dirname(transfer.file), { recursive: true, force: true });
|
|
3713
|
+
}
|
|
3714
|
+
catch (e) {
|
|
3715
|
+
this.adapter.log.warn(`Cannot delete "${transfer.file}": ${e}`);
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
/** Remove the expired links. */
|
|
3720
|
+
cleanupTransfers() {
|
|
3721
|
+
const now = Date.now();
|
|
3722
|
+
for (const [token, transfer] of this.transfers) {
|
|
3723
|
+
if (transfer.expires < now) {
|
|
3724
|
+
this.removeTransfer(token);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
/** `GET <mcp>/transfer/<token>`: send the file of a download link. */
|
|
3729
|
+
handleDownload(req, res) {
|
|
3730
|
+
const transfer = this.transfers.get(String(req.params.token));
|
|
3731
|
+
if (!transfer || transfer.kind !== 'download' || transfer.expires < Date.now()) {
|
|
3732
|
+
res.status(404).json({ error: 'Unknown or expired link' });
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
res.setHeader('Content-Type', transfer.contentType);
|
|
3736
|
+
res.setHeader('Content-Disposition', `attachment; filename="${node_path_1.default.basename(transfer.file)}"`);
|
|
3737
|
+
if (transfer.size !== undefined) {
|
|
3738
|
+
res.setHeader('Content-Length', transfer.size);
|
|
3739
|
+
}
|
|
3740
|
+
node_fs_1.default.createReadStream(transfer.file).pipe(res);
|
|
3741
|
+
}
|
|
3742
|
+
/** `PUT`/`POST <mcp>/transfer/<token>`: receive the file of an upload link (raw body, e.g. `curl -T`). */
|
|
3743
|
+
async handleUpload(req, res) {
|
|
3744
|
+
const token = String(req.params.token);
|
|
3745
|
+
const transfer = this.transfers.get(token);
|
|
3746
|
+
if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now()) {
|
|
3747
|
+
res.status(404).json({ error: 'Unknown or expired link' });
|
|
3748
|
+
return;
|
|
3749
|
+
}
|
|
3750
|
+
if (transfer.size !== undefined) {
|
|
3751
|
+
res.status(409).json({ error: 'This link was already used' });
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
try {
|
|
3755
|
+
transfer.size = await McpServer.receiveBody(req, transfer.file);
|
|
3756
|
+
this.adapter.log.info(`Received upload "${transfer.name}" (${transfer.size} bytes)`);
|
|
3757
|
+
res.json({ ok: true, upload_id: token, name: transfer.name, size: transfer.size });
|
|
3758
|
+
}
|
|
3759
|
+
catch (e) {
|
|
3760
|
+
const error = e;
|
|
3761
|
+
node_fs_1.default.rmSync(transfer.file, { force: true });
|
|
3762
|
+
res.status(error.status || 400).json({ error: error.message });
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
/** Write the raw body of a request to a file and return its size. */
|
|
3766
|
+
static receiveBody(req, file) {
|
|
3767
|
+
const tooLarge = () => Object.assign(new Error(`The file is larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`), { status: 413 });
|
|
3768
|
+
// The body parsers of the web adapter may have read the stream already and kept the bytes
|
|
3769
|
+
const read = req.rawBody ?? (Buffer.isBuffer(req.body) ? req.body : null);
|
|
3770
|
+
if (read) {
|
|
3771
|
+
if (read.length > MAX_UPLOAD_BYTES) {
|
|
3772
|
+
return Promise.reject(tooLarge());
|
|
3773
|
+
}
|
|
3774
|
+
node_fs_1.default.writeFileSync(file, read);
|
|
3775
|
+
return Promise.resolve(read.length);
|
|
3776
|
+
}
|
|
3777
|
+
return new Promise((resolve, reject) => {
|
|
3778
|
+
const out = node_fs_1.default.createWriteStream(file);
|
|
3779
|
+
let size = 0;
|
|
3780
|
+
req.on('data', (chunk) => {
|
|
3781
|
+
size += chunk.length;
|
|
3782
|
+
if (size > MAX_UPLOAD_BYTES) {
|
|
3783
|
+
req.unpipe(out);
|
|
3784
|
+
out.destroy();
|
|
3785
|
+
reject(tooLarge());
|
|
3786
|
+
}
|
|
3787
|
+
});
|
|
3788
|
+
req.on('error', reject);
|
|
3789
|
+
out.on('error', reject);
|
|
3790
|
+
out.on('finish', () => resolve(size));
|
|
3791
|
+
req.pipe(out);
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
/** Get a received upload by its id, failing if it is unknown, expired or still empty. */
|
|
3795
|
+
getUpload(uploadId) {
|
|
3796
|
+
const transfer = this.transfers.get(uploadId);
|
|
3797
|
+
if (!transfer || transfer.kind !== 'upload' || transfer.expires < Date.now()) {
|
|
3798
|
+
throw new Error(`Unknown or expired upload "${uploadId}" - create a new one with prepare_upload`);
|
|
3799
|
+
}
|
|
3800
|
+
if (transfer.size === undefined) {
|
|
3801
|
+
throw new Error(`Nothing was uploaded to "${uploadId}" yet - upload the file to its url first`);
|
|
3802
|
+
}
|
|
3803
|
+
return transfer;
|
|
3804
|
+
}
|
|
2521
3805
|
// --- helpers ---
|
|
2522
3806
|
/** Resolve the member ids of a room/function enum matched by id or (localized) name. */
|
|
2523
3807
|
async getEnumMembers(prefix, nameOrId) {
|
|
@@ -2558,6 +3842,15 @@ class McpServer {
|
|
|
2558
3842
|
catch {
|
|
2559
3843
|
// ignore
|
|
2560
3844
|
}
|
|
3845
|
+
if (this.transferTimer) {
|
|
3846
|
+
clearInterval(this.transferTimer);
|
|
3847
|
+
this.transferTimer = undefined;
|
|
3848
|
+
}
|
|
3849
|
+
if (this.transferDir) {
|
|
3850
|
+
node_fs_1.default.rmSync(this.transferDir, { recursive: true, force: true });
|
|
3851
|
+
this.transferDir = undefined;
|
|
3852
|
+
this.transfers.clear();
|
|
3853
|
+
}
|
|
2561
3854
|
for (const session of Object.values(this.sessions)) {
|
|
2562
3855
|
try {
|
|
2563
3856
|
void session.transport.close();
|